ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

小程序投票系统源码拆解:数据模型、状态机与票数统计闭环

小程序投票系统源码拆解:数据模型、状态机与票数统计闭环

简介:这是一套面向微信小程序初学者与进阶开发者的投票系统实战源码,聚焦创建投票、发布投票、结果统计与展示三类核心功能的完整落地,可支撑意见征集、评选活动、市场调研等轻量业务场景,也可作为小程序工程结构与 wxml 页面编写的参考样例。压缩包共 90 个文件、约 153KB,以 49 个 wxss 样式表与 6 个 wxml 结构文件搭建页面骨架,8 个 js 与 8 个 json 承载业务逻辑和页面配置,另含 png、jpg 图标资源、php 接口示例、sql 建表脚本及 weui 基础样式与 README 说明,体量轻小便携。已有 3647 人学习下载,在同类小程序示例中具备一定参考价值。目录按 pages、utils、components、model、mock 等模块拆分,读者可据此理解页面生命周期、网络请求封装、组件复用与模拟数据调试的常规做法,并在此基础上扩展登录、权限控制与防刷等能力。

1. 小程序投票系统先看发布链路:创建、发布、投票三态怎么串起来

拿到这份小程序投票系统创建投票发布 Demo 完整源码时,我先不看页面样式,而是把创建投票、发布投票、用户投票三条链路各跑一遍。它解决的是投票类活动最费时间的骨架问题:管理端建活动、配置选项、发布后让小程序端拉到列表、提交选项并汇总结果。适合课程设计、接私活做原型、小团队临时活动页的人。源码包能不能直接用,关键不在页面多漂亮,而在发布状态、选项绑定和票数统计是否闭环。下面按我拆包的顺序写,先讲数据模型,再落到本地跑通和排错,最后给一套验收习惯。

2. 拆投票数据模型:HTML 管理端与 MP 小程序端怎么共用一套字段

投票 Demo 最常见的翻车不是页面报错,而是“管理端能建、用户端看不到”或者“票数对不上”。根源通常是字段和状态没统一。我的习惯先画一条链路:HTML 管理端提交投票草稿,服务端保存投票主表和选项表;发布接口只负责改状态;MP 小程序端按状态和时间窗口拉列表;用户提交后写入记录表;结果接口从记录表聚合票数。这个链路里,投票主表存标题、类型、状态、时间;选项表存选项文本和排序;记录表存用户标识、投票 ID、选项 ID 快照和提交时间。不要把票数直接堆在选项表里当唯一数据源,票数是聚合结果,记录表才是黑匣子,后面查重复投票、回滚活动、核对历史结果都靠它。

2.1 四张表的最小字段设计

我拆这类源码时,会先找建表脚本或数据库初始化文件,把字段对一遍。下面这套字段够跑通创建、发布、投票、统计四步,也方便后面换成关系型数据库。字段名用下划线,接口层再转成前端习惯的驼峰,别让数据库字段直接漏到页面里。

表关键字段用途
voteid、title、vote_type、max_choices、status、start_at、end_at、created_at投票主表,控制类型、状态和时间窗口
vote_optionid、vote_id、label、sort_no选项表,排序和文案都在这里
vote_recordid、vote_id、option_id、voter_key、option_snapshot、created_at投票记录,票数从这里聚合
可选用户表id、voter_key、created_at需要昵称或头像时再扩展,不急着加
-- 投票主表:status 只允许 draft/published/closed CREATE TABLE vote ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, vote_type TEXT NOT NULL DEFAULT 'single', max_choices INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'draft', start_at TEXT, end_at TEXT, created_at TEXT NOT NULL ); -- 选项表:sort_no 控制展示顺序,别依赖 id 排序 CREATE TABLE vote_option ( id INTEGER PRIMARY KEY, vote_id INTEGER NOT NULL, label TEXT NOT NULL, sort_no INTEGER NOT NULL DEFAULT 0 ); -- 记录表:option_snapshot 存提交时的选项文案,后续改文案不影响历史 CREATE TABLE vote_record ( id INTEGER PRIMARY KEY, vote_id INTEGER NOT NULL, option_id INTEGER NOT NULL, voter_key TEXT NOT NULL, option_snapshot TEXT NOT NULL, created_at TEXT NOT NULL ); -- 同一用户对同一选项只能投一次,多选时不同 option_id 仍可写入 CREATE UNIQUE INDEX uk_vote_voter_option ON vote_record(vote_id, voter_key, option_id);

逻辑说明:vote管活动本身,vote_option管选项,vote_record管每一次选择。票数不落库到选项表,结果页每次聚合,数据口径更稳。option_snapshot是我很看重的字段,投票发布后难免有人想改文案,没有快照就会出现“旧票算到新文案上”的玄学问题。

参数说明:vote_type用single和multi两种值,页面判断和接口校验都认这两个值;max_choices在单选时固定为 1,多选时不能超过选项总数;status只允许draft、published、closed,不要出现true/false和字符串混用;voter_key用本地生成的稳定标识,不要每次提交随机生成;sort_no用来固定选项顺序,用户提交后顺序变化会让人怀疑票数错位。

提示:不同数据库对自增主键和唯一索引冲突的写法不一样,建表脚本先按你手里的数据库方言改,别直接复制到生产库跑。

2.2 HTML 管理端与 MP 端的接口字段约定

HTML 管理端和 MP 小程序端共用一套接口时,最怕一边传voteType,一边收vote_type。我的做法是数据库保留蛇形字段,服务端出口统一转驼峰,页面只认驼峰。管理端创建表单提交title、voteType、maxChoices、options;小程序端详情接口返回同样的结构,选项只多一个sortNo。这样管理端加字段时,小程序端不用跟着改一堆映射。

// 服务端出口统一转 DTO,避免数据库字段直接漏给页面 function toVoteDto(row, options) { return { id: Number(row.id), title: row.title, voteType: row.vote_type, maxChoices: Number(row.max_choices), status: row.status, startAt: row.start_at || null, endAt: row.end_at || null, options: options .sort((a, b) => Number(a.sort_no) - Number(b.sort_no)) .map(o => ({ id: Number(o.id), label: o.label, sortNo: Number(o.sort_no) })) }; }

逻辑说明:toVoteDto把数据库行和选项数组拼成页面能直接用的结构。options先按sort_no排序再映射,保证管理端和小程序端看到的顺序一致。id和maxChoices显式转数字,减少页面层"1"和1比较带来的坑。

参数说明:row是投票主表记录,options是当前投票的选项数组;startAt、endAt为空时返回null,页面判断时间窗口时不用猜空字符串;status原样返回,页面只对published展示投票按钮。接口外层建议统一成{ code, data, message },页面先判code再取data,不要每个接口写一套错误判断。

2.3 状态机先定死,再写页面

投票系统的状态不多,但特别容易写散。创建时默认draft,发布时只允许draft -> published,关闭时只允许published -> closed。已关闭的投票不能重新发布,要重新发起就复制一条新草稿。这个规则提前写进服务端,页面按钮只是展示,不能靠前端隐藏按钮来保证状态正确。

// 状态流转表:只允许向前走,不允许回退 const ALLOWED = { draft: ['published'], published: ['closed'], closed: [] }; function assertTransition(from, to) { if (!ALLOWED[from] || !ALLOWED[from].includes(to)) { throw new Error(`非法状态流转: ${from} -> ${to}`); } }

逻辑说明:assertTransition放在发布和关闭接口的入口,先校验再更新。这样即使有人直接调接口,也不会把已关闭的投票改回发布中。页面层的“发布”按钮只在draft显示,“关闭”按钮只在published显示,减少误操作。

参数说明:from是数据库当前状态,to是本次请求目标状态;错误信息里带原状态和目标状态,排查日志时一眼能看出是哪一步被拒。发布接口还要补三条校验:选项至少两个、结束时间晚于开始时间、maxChoices不超过选项数。这三条不补,后面投票提交和结果聚合都会出现边界数据。

3. 本地跑通创建投票:从建表到接口联调的实操顺序

很多源码包解压后第一反应是找页面入口,我更建议先看服务端配置和建表脚本。顺序是:确认服务端入口,配置数据库路径和端口,导入建表 SQL,启动服务,用接口工具创建一条草稿,再打开 HTML 管理端看列表。不要一上来就点“创建投票”,接口报错没有日志,只能靠猜。常见做法是把服务端日志级别开高,所有请求打印路径、入参摘要和响应码;小程序端请求地址集中到一个配置文件,别散落在页面里。

3.1 服务端配置与启动

先看服务端目录有没有依赖清单和入口文件。有依赖清单就先安装依赖,没有就找单文件入口。启动命令里的端口和数据库路径用环境变量覆盖,后面换环境不用改代码。日志出现监听端口后再联调,别服务还没起来就怀疑页面。

# 进入服务端目录,先看入口和配置文件 ls cat package.json 2>/dev/null || true # 有依赖清单时安装依赖;没有就跳过这一步 npm install # 启动服务:端口和数据库文件路径用环境变量覆盖 PORT=3000 DB_FILE=./data/vote.db node app.js

逻辑说明:PORT是接口端口,HTML 管理端和小程序端都按这个端口拼地址;DB_FILE是本地数据库文件路径,第一次启动时目录要先存在;node app.js要替换成源码包里的实际入口文件。日志里出现类似listen 3000的输出,再走下一步。

参数说明:端口不要和本机已占用服务冲突,换端口时记得同步改前端apiBase;数据库文件路径用相对路径时,注意启动命令所在目录,很多“建表成功但查询为空”就是启动目录不同导致读到了新库。没有依赖清单的源码包,先看入口文件里的require或import,缺什么补什么,不要盲目全量安装。

3.2 创建投票接口的入参和校验

创建接口只生成草稿,不直接发布。入参最少要有标题、投票类型、最大可选数和选项数组。选项数组至少两个,单选时maxChoices强制为 1,多选时不能超过选项总数。先用接口工具打一条,确认返回的id和status正确,再打开管理端页面。

# 创建一条草稿投票,注意字段名要和接口约定一致 curl -X POST http://127.0.0.1:3000/api/votes \ -H 'Content-Type: application/json' \ -d '{ "title": "周末活动方案投票", "voteType": "single", "maxChoices": 1, "options": ["方案A", "方案B", "方案C"] }'

逻辑说明:这条请求走创建接口,服务端应该返回{ code: 0, data: { id, status: "draft" } }。如果返回 400,先看字段名是voteType还是vote_type,再看选项数组是不是被当成了字符串。创建成功后去数据库查vote和vote_option两张表,确认主表和选项都落库。

参数说明:title去掉首尾空格后不能为空;voteType只传single或multi;maxChoices单选传 1,多选传大于 1 的整数;options传字符串数组,空字符串先过滤。接口层不要直接信任前端,服务端再校验一遍,尤其是多选上限。

// 创建接口的核心校验和落库顺序,结构示意 app.post('/api/votes', async (req, res) => { const { title, voteType = 'single', maxChoices = 1, options = [] } = req.body; if (!title || !Array.isArray(options) || options.length < 2) { return res.status(400).json({ code: 400, message: '标题和至少两个选项必填' }); } if (voteType === 'single' && Number(maxChoices) !== 1) { return res.status(400).json({ code: 400, message: '单选投票 maxChoices 必须为 1' }); } if (voteType === 'multi' && Number(maxChoices) > options.length) { return res.status(400).json({ code: 400, message: '多选上限不能超过选项数' }); } const voteId = await db.insertVote({ title: title.trim(), voteType, maxChoices: Number(maxChoices), status: 'draft' }); await db.insertOptions(voteId, options.map(s => String(s).trim()).filter(Boolean)); res.json({ code: 0, data: { id: voteId, status: 'draft' } }); });

逻辑说明:先校验再落库,避免主表写进去、选项表失败留下半条数据。insertVote和insertOptions是抽象方法,实际源码里可能叫别的名字,按入口文件替换。创建接口只返回草稿 ID,不返回发布态,后面发布按钮单独调接口。

参数说明:title为空、选项少于两个直接 400;单选时maxChoices必须等于 1;多选时maxChoices不能超过选项数;options过滤空字符串后再判断数量,否则用户多敲一个空行就会绕过校验。落库顺序先主表后选项表,失败时要么回滚,要么把主表标记为无效草稿。

3.3 HTML 管理端联调:列表、创建、发布三个按钮

管理端页面联调先跑三个按钮:创建、刷新列表、发布。创建表单里选项用多行文本,一行一个,提交前过滤空行;单选时maxChoices强制 1;发布按钮单独调发布接口,不要在创建接口里顺手改状态。页面请求统一走一个fetch封装,错误信息直接弹出来,别只写控制台。

// 管理端创建投票:表单值转接口入参 async function createVote(form) { const options = form.options.value .split('\n') .map(s => s.trim()) .filter(Boolean); const payload = { title: form.title.value.trim(), voteType: form.voteType.value, maxChoices: form.voteType.value === 'single' ? 1 : Number(form.maxChoices.value), options }; const resp = await fetch('/api/votes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const body = await resp.json(); if (body.code !== 0) throw new Error(body.message || '创建失败'); return body.data; }

逻辑说明:split('\n')按行拆选项,trim去空格,filter(Boolean)过滤空行。voteType为single时maxChoices直接给 1,不读输入框,减少前端误填。返回body.data里有新投票 ID,列表刷新用这个 ID 高亮新记录。

参数说明:form.title、form.options是表单控件,实际页面按你的 DOM 结构调整;maxChoices多选时转数字,别传字符串;fetch的地址按当前管理端路径拼接,前后端不同端口时要么配代理,要么写完整接口地址。发布按钮调/api/votes/:id/publish,成功后把列表状态从draft改成published,不要只改页面不刷库。

4. 发布与投票提交:MP 小程序端页面逻辑、校验与结果聚合

发布不是改个状态就完事。发布后要保证 MP 小程序端能按时间窗口拉取、选项顺序固定、提交记录幂等、结果聚合不重复。我的顺序是先写发布接口和状态校验,再写小程序页面加载详情,最后写提交和统计。页面层只做展示和轻校验,真正校验在服务端,别把maxChoices判断只放在前端,否则改包或直接调接口就绕过去了。血泪经验是:投票类 Demo 的票数问题,十有八九不是统计 SQL 写错,而是提交入口没防重。

4.1 发布接口的时间窗口和状态校验

发布接口先查投票是否存在,再校验状态必须是draft,选项至少两个,结束时间晚于开始时间。开始时间不传就取当前时间,结束时间不传表示长期有效。时间统一存 ISO 字符串,展示层再本地化。发布成功后返回最新状态,管理端列表按返回值刷新。

// 发布接口:只允许草稿发布,时间窗口在入口校验 app.post('/api/votes/:id/publish', async (req, res) => { const vote = await db.findVote(req.params.id); if (!vote) return res.status(404).json({ code: 404, message: '投票不存在' }); if (vote.status !== 'draft') { return res.status(409).json({ code: 409, message: '只有草稿能发布' }); } const options = await db.findOptions(vote.id); if (options.length < 2) { return res.status(400).json({ code: 400, message: '选项不足' }); } const startAt = req.body.startAt || new Date().toISOString(); const endAt = req.body.endAt || null; if (endAt && new Date(endAt) <= new Date(startAt)) { return res.status(400).json({ code: 400, message: '结束时间必须晚于开始时间' }); } await db.updateVote(vote.id, { status: 'published', start_at: startAt, end_at: endAt }); res.json({ code: 0, data: { id: vote.id, status: 'published' } }); });

逻辑说明:先查再校验,避免对不存在的投票做更新。状态不是draft直接 409,管理端拿到 409 就知道按钮状态过期,刷新列表即可。时间窗口在发布时落库,小程序端列表接口再按时间过滤。

参数说明:startAt、endAt用 ISO 字符串,服务端存统一时区,页面展示再转本地;endAt为空表示不设结束时间;vote.id从路径参数取,记得转数字再查库。发布接口不建议做“重复发布返回成功”,否则管理端会误以为状态被重置。

4.2 MP 端 onLoad 拉详情与提交投票

小程序端页面加载时先拉详情,判断status是否为published,不是就提示未发布。选项点击时单选直接替换数组,多选先判断上限再追加。提交时带optionIds和voterKey,按钮加submitting锁,成功后刷新结果或跳结果页。

// 小程序端页面逻辑示意:网络请求和提示按实际运行时替换 Page({ data: { vote: null, selected: [], submitting: false }, onLoad(query) { this.voteId = Number(query.id); this.loadVote(); }, async loadVote() { const body = await request(`/api/votes/${this.voteId}`); if (body.code !== 0 || body.data.status !== 'published') { showToast('投票未发布'); return; } this.setData({ vote: body.data }); }, toggleOption(e) { const id = Number(e.currentTarget.dataset.id); const { vote, selected } = this.data; // 单选直接替换,多选判断上限 if (vote.voteType === 'single') { this.setData({ selected: [id] }); return; } const next = selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]; if (next.length > vote.maxChoices) { showToast(`最多选 ${vote.maxChoices} 项`); return; } this.setData({ selected: next }); }, async submit() { if (this.data.submitting || this.data.selected.length === 0) return; this.setData({ submitting: true }); const body = await request(`/api/votes/${this.voteId}/records`, { method: 'POST', data: { optionIds: this.data.selected.map(id => Number(id)), voterKey: getApp().globalData.voterKey } }); this.setData({ submitting: false }); if (body.code === 0) showToast('提交成功'); else showToast(body.message || '提交失败'); } });

逻辑说明:onLoad里把路径参数转数字,避免后面和接口返回的 ID 类型不一致。toggleOption单选和多选分开处理,多选先算下一组再判断上限,不能先setData再判断。submit用submitting锁住连点,selected为空直接返回。

参数说明:query.id是页面路径带的投票 ID;selected始终是数组,单选长度为 1;voterKey从全局配置取稳定值,不要每次提交随机生成;request和showToast是抽象方法,实际替换成小程序运行时的网络请求和提示 API。提交成功后刷新详情或跳结果页,别只弹提示不更新页面。

4.3 结果聚合与多选票数口径

结果页最容易把“选项票数”和“投票人数”混在一起。单选时每个用户一条记录,按选项COUNT就是票数;多选时同一用户多个选项各一条记录,按选项COUNT是该选项被选次数,总人数要单独COUNT(DISTINCT voter_key)。两个口径分开查,别在一个 SQL 里既算人数又算选项票数。

-- 选项票数:多选时统计的是每个选项被选次数 SELECT o.id, o.label, COUNT(r.id) AS vote_count FROM vote_option o LEFT JOIN vote_record r ON r.option_id = o.id WHERE o.vote_id = ? GROUP BY o.id, o.label, o.sort_no ORDER BY o.sort_no ASC; -- 总投票人数:和选项票数分开查 SELECT COUNT(DISTINCT voter_key) AS voter_count FROM vote_record WHERE vote_id = ?;

逻辑说明:第一条用LEFT JOIN,没人投的选项也返回 0,页面不会漏项。GROUP BY带上sort_no是为了排序稳定。第二条单独查总人数,多选时不会把一个人算成多次。

参数说明:?是当前投票 ID,接口层从路径参数取;vote_count是该选项被选次数,不是人数;voter_count是去重后的人数。结果页展示时把两个数字的标签写清楚,别把“票数”和“参与人数”混用。

5. 避坑与排查:投票 Demo 最容易翻车的 5 个现场

这份 Demo 的页面逻辑不复杂,真正耗时间的是状态、字段和统计口径。下面 5 条是我拆投票类源码时反复遇到的现场,按现象、原因、解决写,照着排查能省不少猜。遇到问题先别改页面,先看服务端日志和数据库记录,页面只是结果,数据链路才是黑匣子。

5.1 创建成功但小程序列表为空

现象:HTML 管理端创建后列表有数据,MP 小程序端刷新看不到。原因通常有三种:创建接口默认draft,小程序列表只查published;分页参数写死page=0,后端从page=1开始;开始时间和结束时间时区处理不一致,刚发布就被过滤掉。解决顺序是先查数据库status,再看列表接口 SQL 条件,最后打印时间参数。

-- 列表接口的过滤条件,时间参数统一用 UTC 字符串 SELECT id, title FROM vote WHERE status = 'published' AND (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at >= ?) ORDER BY id DESC;

逻辑说明:这条 SQL 只返回已发布且在时间窗口内的投票。start_at为空表示不限开始时间,end_at为空表示不限结束时间。小程序端列表空时,先手动执行这条 SQL,把两个时间参数换成当前时间,看能不能查出数据。

参数说明:?分别是当前时间和当前时间,格式要和入库格式一致;分页参数检查page起始值,前端传 0 后端按 1 处理时要在接口层统一;时间统一存 UTC,页面展示再转本地,别一半存本地一半存 UTC。

5.2 提交投票报 400 或“选项不存在”

现象:页面明明选中了选项,提交返回 400。原因多是类型不一致:接口返回的选项 ID 是数字,页面dataset取出来变字符串,服务端用===比较;或者管理端建选项传数组,服务端只接收逗号分隔字符串;多选时传了单个 ID 而不是数组。解决是入口统一转数字,提交前打印 payload,服务端再校验选项属于当前投票。

// 提交前把选项 ID 统一转数字,服务端同样做类型归一化 const payload = { optionIds: selected.map(id => Number(id)), voterKey: String(voterKey) }; console.log('submit payload', payload);

逻辑说明:map(Number)把页面取到的字符串 ID 转成数字,String(voterKey)保证用户标识是字符串。打印 payload 是为了在控制台直接看到提交结构,和接口文档对一遍。服务端收到后还要查vote_option,确认每个option_id都属于当前vote_id。

参数说明:selected必须是数组,单选也传长度为 1 的数组;voterKey不能为空;服务端比较 ID 时统一转数字,别用字符串和数字混比。返回 400 时把错误信息写具体,比如“选项不属于当前投票”,比只返回“参数错误”好排查。

5.3 票数越投越多或重复提交

现象:同一用户连续点击提交,票数翻倍。原因是前端没有submitting锁,后端没有唯一约束,提交接口不是幂等。解决分三层:前端按钮锁住,后端记录表加

本文还有配套的精品资源,点击获取

返回列表