简介:这份资源是微信小程序投票系统的完整源码包,面向希望学习小程序开发或快速搭建投票应用的开发者,可用于意见收集、评选活动、市场调研等场景。压缩包共90个文件,约153KB,以49个wxss样式文件、15个png图片资源为主,另含8个js逻辑文件、8个json配置、6个wxml结构文件,以及php接口、sql建表脚本和md说明文档,覆盖页面结构、样式、逻辑与数据交互各层。源码按小程序标准目录组织,包含全局配置、页面模块、工具函数、自定义组件、数据模型与模拟数据等部分,投票创建、发布与结果展示流程完整,网络请求与数据处理逻辑清晰,便于理解页面生命周期、组件化开发与图形数据可视化。目前已有3644人学习下载,适合作为小程序入门练手或二次开发的参考模板,可在此基础上扩展用户登录、权限控制与防刷机制。
1. 微信小程序投票系统:从创建到发布,一个 demo 能跑通多少事
很多人第一次接触微信小程序投票系统,都是被一个很具体的需求推着走的:社团要选优秀作品、公司要做内部方案评选、班级要选班委,用第三方投票工具吧,数据在别人服务器上,还动不动弹广告、限制选项数;自己从头写吧,登录、鉴权、数据库、防刷票,光想想就头大。于是「微信小程序投票系统创建投票发布demo完整源码下载.zip」这类东西就成了搜索框里的高频词——大家想要的其实不是一份能直接上线的成品,而是一套能跑起来、能看懂、能改的最小闭环。
这篇笔记就按这个思路来:把「创建投票 → 发布 → 投票 → 看结果」这条链路拆开,讲清楚一个 demo 里到底该有哪些东西,哪些参数必须自己定,哪些坑一踩就翻车。适合两类人:一类是刚学完小程序基础、想找个完整项目练手的开发者;另一类是要快速搭个内部投票、不想被第三方平台绑住的从业者。我不会假装见过某个具体的压缩包,而是按这个标题下最常见、最可靠的做法,把一套可复现的方案讲透。
2. 投票系统的数据模型与页面骨架:先想清楚再动手
动手写代码之前,最该花时间的是数据模型。投票系统看着简单,但「一个投票有多个选项、一个用户对一个投票只能投一次、投票有开始和结束时间」这几条规则,直接决定了你后面要不要写一堆补丁。我一般会把核心实体拆成三张表:投票(vote)、选项(option)、投票记录(record)。用户信息不单独建表,直接用微信登录拿到的 openid 当唯一标识,省事又够用。
2.1 三张核心表怎么设计才不用返工
先看投票主表。它要存标题、描述、类型(单选/多选)、状态(草稿/进行中/已结束)、开始结束时间、创建者 openid。这里有个容易忽略的点:状态不要只靠时间算,最好落一个显式字段。因为「到点自动结束」在小程序里没有后台定时任务时很难精确触发,显式状态 + 前端按时间判断,双保险更稳。
选项表相对简单,但要带一个 voteId 外键和排序字段 sort,否则选项顺序在不同端展示会乱。投票记录表是防刷票的关键,它至少要有 voteId、openid、optionId、创建时间,并且要在 voteId + openid 上建唯一索引——这一条索引能帮你挡掉绝大多数重复提交。
-- 投票主表 CREATE TABLE vote ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, -- 投票标题 desc_text VARCHAR(500) DEFAULT '', -- 描述,注意 desc 是保留字 vote_type TINYINT DEFAULT 1, -- 1 单选 2 多选 max_choose TINYINT DEFAULT 1, -- 多选时最多可选几项 status TINYINT DEFAULT 0, -- 0 草稿 1 进行中 2 已结束 start_time DATETIME NULL, end_time DATETIME NULL, creator VARCHAR(64) NOT NULL, -- 创建者 openid created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 选项表 CREATE TABLE vote_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vote_id BIGINT NOT NULL, content VARCHAR(200) NOT NULL, sort INT DEFAULT 0, INDEX idx_vote (vote_id) ); -- 投票记录表,唯一索引是防刷票底线 CREATE TABLE vote_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vote_id BIGINT NOT NULL, openid VARCHAR(64) NOT NULL, option_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_vote_user (vote_id, openid) );字段说明里几个点值得单独拎出来:desc_text不用desc,因为它是 SQL 保留字,新手在这里翻车的不在少数;max_choose只在多选时生效,单选时固定为 1;creator存 openid 而不是自增 userId,是为了省掉一次用户表关联查询。唯一索引uk_vote_user是整套系统的安全底线,后面讲防刷票还会回到它。
2.2 页面骨架:四个页面撑起完整闭环
数据模型定了,页面就顺了。一个能跑通创建到发布的 demo,最少需要四个页面:首页(投票列表)、创建页(填标题选项)、详情页(投票 + 看结果)、我的页(我创建的 / 我投过的)。首页负责入口,创建页负责生产数据,详情页是核心交互,我的页负责管理。
创建页的表单校验要做在前面。标题非空且不超过 100 字,选项至少 2 个、最多 20 个,多选时 max_choose 不能大于选项数。这些校验前端做一遍,后端接口再做一遍,别嫌重复——前端校验是体验,后端校验是安全,两者职责不同。
// 创建页提交前的本地校验,放在 pages/create/create.js function validateForm(form) { if (!form.title || form.title.trim().length === 0) { return { ok: false, msg: '标题不能为空' }; } if (form.title.length > 100) { return { ok: false, msg: '标题不能超过100字' }; } const options = form.options.filter(o => o && o.trim()); if (options.length < 2) { return { ok: false, msg: '至少需要2个选项' }; } if (options.length > 20) { return { ok: false, msg: '选项最多20个' }; } if (form.voteType === 2 && form.maxChoose > options.length) { return { ok: false, msg: '最多可选数不能超过选项数' }; } return { ok: true }; }这段校验逻辑很直白,但参数边界是踩过坑才定下来的:选项上限 20 是因为再多前端渲染会卡,标题 100 字是因为数据库字段就这么宽。voteType === 2判断多选,maxChoose只在多选场景校验。把这些边界写死在函数里,比散落在各个事件回调里好维护得多。
3. 创建与发布:把投票数据真正落到服务端
页面和数据模型都齐了,接下来是让「创建」这个动作真正产生一条可用的投票记录。这一步的核心是接口设计:创建接口负责写入 vote 和 vote_option,发布接口负责把 status 从草稿改成进行中。为什么拆成两个接口而不是一步到位?因为实际使用中,创建者经常是先存草稿、检查一遍再发布,一步到位反而不好用。
3.1 创建接口:一次事务写入主表和选项表
创建投票涉及两张表的写入,必须放在一个事务里。如果 vote 写成功、option 写失败,就会留下一条没有选项的空投票,这种脏数据清理起来很烦。用事务包起来,要么全成功,要么全回滚。
// 服务端创建投票接口(Node.js + mysql2 示例) async function createVote(conn, { title, descText, voteType, maxChoose, options, creator }) { const conn2 = await conn.getConnection(); try { await conn2.beginTransaction(); // 1. 写入投票主表,初始状态为草稿 const [voteRes] = await conn2.execute( 'INSERT INTO vote (title, desc_text, vote_type, max_choose, status, creator) VALUES (?,?,?,?,0,?)', [title, descText, voteType, maxChoose, creator] ); const voteId = voteRes.insertId; // 2. 批量写入选项,sort 按数组顺序递增 const values = options.map((content, i) => [voteId, content, i]); await conn2.query( 'INSERT INTO vote_option (vote_id, content, sort) VALUES ?', [values] ); await conn2.commit(); return { ok: true, voteId }; } catch (e) { await conn2.rollback(); return { ok: false, msg: e.message }; } finally { conn2.release(); } }逻辑上分两步:先插主表拿到自增 voteId,再用这个 id 批量插选项。参数status写死为 0(草稿),发布时再改。批量插入用VALUES ?配合二维数组,比循环单条插入快很多,选项多的时候差距明显。sort用数组下标,保证选项顺序和用户填写顺序一致。事务的rollback放在 catch 里,任何一步失败都不会留下半条数据。
3.2 发布接口与状态流转:别让草稿被误投
发布接口要做的事很简单:校验当前用户是不是创建者,然后把 status 从 0 改成 1,同时写入 start_time。这里有个权限校验不能省——如果不校验 creator,任何人拿到 voteId 都能发布别人的投票,这是个低级但常见的漏洞。
// 发布投票:只有创建者本人能发布 async function publishVote(conn, { voteId, openid }) { const [rows] = await conn.execute( 'SELECT creator, status FROM vote WHERE id = ?', [voteId] ); if (rows.length === 0) return { ok: false, msg: '投票不存在' }; if (rows[0].creator !== openid) return { ok: false, msg: '无权限' }; if (rows[0].status !== 0) return { ok: false, msg: '当前状态不可发布' }; await conn.execute( 'UPDATE vote SET status = 1, start_time = NOW() WHERE id = ?', [voteId] ); return { ok: true }; }状态流转的规则要写清楚:0 草稿只能变 1 进行中,1 进行中只能变 2 已结束,不允许从 2 回到 1。这个约束在接口里判断,比在前端藏按钮可靠。start_time用数据库的NOW()而不是应用层时间,避免服务器时区不一致导致的时间错乱——这个坑我在跨时区部署时踩过一次,血泪经验。
3.3 详情页拉取数据:一次请求拿全投票和选项
详情页需要投票信息 + 选项列表 + 当前用户是否已投。最省事的做法是一个接口全返回,减少小程序端的请求次数。小程序网络请求有并发限制,能合并就合并。
// 详情接口:返回投票、选项、当前用户投票状态 async function getVoteDetail(conn, { voteId, openid }) { const [voteRows] = await conn.execute( 'SELECT id, title, desc_text, vote_type, max_choose, status, end_time FROM vote WHERE id = ?', [voteId] ); if (voteRows.length === 0) return { ok: false, msg: '投票不存在' }; const [optionRows] = await conn.execute( 'SELECT id, content, sort FROM vote_option WHERE vote_id = ? ORDER BY sort ASC', [voteId] ); const [recordRows] = await conn.execute( 'SELECT option_id FROM vote_record WHERE vote_id = ? AND openid = ?', [voteId, openid] ); return { ok: true, vote: voteRows[0], options: optionRows, myChoice: recordRows.map(r => r.option_id), // 已投则非空 hasVoted: recordRows.length > 0 }; }三个查询分别拿投票、选项、我的投票记录。myChoice返回数组是为了兼容多选,单选时数组长度为 1。hasVoted单独给一个布尔值,前端判断是否禁用投票按钮时直接用,不用再去判断数组长度。选项查询带ORDER BY sort,保证展示顺序稳定。
4. 投票提交与防刷票:唯一索引只是第一道门
投票提交是整个系统里最需要小心的地方。用户点一下按钮,背后要做的事包括:校验投票是否在进行中、校验选项是否合法、校验是否重复投票、写入记录、返回最新结果。任何一步漏了,要么数据错乱,要么被人刷票。
4.1 提交接口:把重复投票挡在数据库层
防重复投票最可靠的做法不是先查再插,而是直接插、靠唯一索引兜底。先查再插在并发下有两个请求同时通过查询的情况,唯一索引则从数据库层面保证不会重复。
// 提交投票:依赖唯一索引 uk_vote_user 防重复 async function submitVote(conn, { voteId, openid, optionIds }) { // 1. 校验投票状态和类型 const [voteRows] = await conn.execute( 'SELECT vote_type, max_choose, status, end_time FROM vote WHERE id = ?', [voteId] ); if (voteRows.length === 0) return { ok: false, msg: '投票不存在' }; const vote = voteRows[0]; if (vote.status !== 1) return { ok: false, msg: '投票未开始或已结束' }; if (vote.end_time && new Date(vote.end_time) < new Date()) { return { ok: false, msg: '投票已过期' }; } // 2. 校验选项数量 if (vote.vote_type === 1 && optionIds.length !== 1) { return { ok: false, msg: '单选只能选一项' }; } if (vote.vote_type === 2 && optionIds.length > vote.max_choose) { return { ok: false, msg: '超过最多可选数' }; } // 3. 写入记录,唯一索引冲突即视为重复投票 try { const values = optionIds.map(oid => [voteId, openid, oid]); await conn.query( 'INSERT INTO vote_record (vote_id, openid, option_id) VALUES ?', [values] ); return { ok: true }; } catch (e) { if (e.code === 'ER_DUP_ENTRY') { return { ok: false, msg: '你已经投过票了' }; } throw e; } }关键在第三步:直接插入,捕获ER_DUP_ENTRY错误码,返回友好提示。这样即使两个请求同时到达,数据库也只会成功一条。参数校验里end_time的判断是双保险,因为 status 可能因为没及时更新而滞后。多选时optionIds是数组,单选时长度必须为 1。
4.2 结果统计:实时算还是缓存算
结果统计有两种做法:每次请求都GROUP BY实时算,或者单独维护一个计数表。demo 阶段数据量小,实时算完全够用,代码也简单。
-- 实时统计每个选项的票数 SELECT o.id, o.content, COUNT(r.id) AS cnt FROM vote_option o LEFT JOIN vote_record r ON r.option_id = o.id WHERE o.vote_id = ? GROUP BY o.id, o.content ORDER BY o.sort ASC;用LEFT JOIN而不是INNER JOIN,是为了让零票的选项也能显示出来,否则用户会以为选项丢了。COUNT(r.id)统计记录数,零票时返回 0。数据量上万以后这个查询会变慢,那时候再考虑加计数表或用缓存,demo 阶段没必要提前优化。
4.3 防刷票的三个层次
防刷票不是单一手段能解决的,我一般分三层做。第一层是数据库唯一索引,挡重复提交;第二层是接口频率限制,同一 openid 短时间内多次请求直接拒绝;第三层是业务规则,比如投票必须关注后才能投、投票后不能取消。三层叠加,普通刷票基本没戏。
频率限制可以用简单的内存计数实现,demo 够用:
// 简易频率限制:同一 openid 10 秒内最多提交 3 次 const rateMap = new Map(); function checkRate(openid) { const now = Date.now(); const arr = (rateMap.get(openid) || []).filter(t => now - t < 10000); if (arr.length >= 3) return false; arr.push(now); rateMap.set(openid, arr); return true; }这个实现放在单机内存里,重启就清空,多实例部署也不共享。demo 够用,生产环境要换成 Redis。参数 10000 毫秒和 3 次是拍脑袋定的,实际按业务调整——投票这种低频操作,限制可以更严。
5. 避坑与排查:那些让 demo 跑不起来的细节
前面讲的都是顺路径,但真正让人卡住的往往是些不起眼的细节。这一章列几个我在做投票系统时反复遇到的坑,每条按现象、原因、解决来说,能帮你省下不少调试时间。
5.1 投票按钮点了没反应,控制台也没报错
现象是用户点击投票按钮,界面毫无变化,开发者工具控制台干净得像什么都没发生。原因通常是按钮绑定的方法名和 js 里定义的不一致,或者按钮被一个透明的遮罩层盖住了。小程序不会对这种情况报错,只会静默失败。解决方法是先在方法第一行加console.log('clicked'),确认事件有没有触发;没触发就检查 wxml 里的bindtap名字,触发了就检查后续逻辑。这个坑新手几乎必踩,加日志是最快的定位手段。
5.2 唯一索引建了,重复投票还是能提交
现象是明明建了uk_vote_user唯一索引,测试时同一个账号还是投了两次。原因多半是索引建在了错误的字段组合上,比如只建了openid单列索引,或者建索引时表里已有重复数据导致索引没生效。解决方法是先用SHOW INDEX FROM vote_record确认索引存在且字段正确,再查SELECT vote_id, openid, COUNT(*) FROM vote_record GROUP BY vote_id, openid HAVING COUNT(*) > 1看有没有历史脏数据。有脏数据要先清理再重建索引。
5.3 多选投票提交后只记录了一项
现象是用户多选了三项,提交后数据库里只有一条记录。原因是插入时用了execute而不是query,execute对批量二维数组的支持和query不一样,容易只处理第一组。解决方法是批量插入统一用query,或者改成循环单条插入。这个差异在 mysql2 里比较隐蔽,参数说明里很少提,踩过一次就记住了。
5.4 投票结束后前端还能点
现象是投票已经过了 end_time,前端按钮还是可点状态。原因是前端只判断了 status,没判断 end_time,而 status 可能因为没人触发更新还是 1。解决方法是前端在渲染时同时判断status === 1 && (!endTime || new Date(endTime) > new Date()),后端提交接口也要做同样判断。前后端都判断,才不会出现「前端禁了后端还能提交」或反过来的情况。
5.5 选项顺序每次刷新都不一样
现象是同一个投票,每次进详情页选项顺序都变。原因是查询时没加ORDER BY,数据库返回顺序不保证稳定。解决方法是选项查询固定加ORDER BY sort ASC,并且创建时把 sort 按填写顺序写死。这个坑不影响功能,但很影响体验,用户会以为选项在乱跳。
6. 从 demo 到能用:几个让投票系统更稳的进阶技巧
demo 跑通之后,如果想真正拿去用,还有几处值得加固。这些不是必须,但做了之后系统的可靠性和体验会明显上一个台阶。
第一个技巧是给投票加一个「结果可见性」开关。有些投票希望投完才能看结果,有些希望随时能看。做法是在 vote 表加一个result_visible字段,0 表示投后可见,1 表示随时可见。详情接口根据这个字段和hasVoted决定要不要返回票数。这个改动很小,但能覆盖大部分真实场景。
第二个技巧是结果页的票数用百分比而不是绝对数展示。绝对数在票少的时候会暴露「只有 3 个人投票」的尴尬,百分比更耐看。计算时注意除零,总票数为 0 时统一显示 0%。
// 票数转百分比,注意除零 function toPercent(list) { const total = list.reduce((s, item) => s + item.cnt, 0); return list.map(item => ({ ...item, percent: total === 0 ? 0 : Math.round((item.cnt / total) * 100) })); }Math.round会让百分比之和偶尔不等于 100,这是正常的舍入误差,不用强行修正。真要精确,就用最大余数法分配,但 demo 没必要。
第三个技巧是给创建者一个「复制投票」功能。实际使用中,同一类投票经常要重复发起,比如每周选一次优秀作品。复制功能就是把 vote 和 option 读出来,换个标题重新插入,status 重置为草稿。实现上复用创建接口即可,注意新投票的 creator 要是当前操作人。
| 加固项 | 改动位置 | 收益 | 成本 |
|---|---|---|---|
| 结果可见性开关 | vote 表 + 详情接口 | 适配更多场景 | 低 |
| 百分比展示 | 结果页 | 体验更好 | 低 |
| 复制投票 | 创建接口复用 | 提升复用效率 | 中 |
| 频率限制换 Redis | 提交接口 | 多实例可用 | 中 |
| 计数表缓存 | 统计逻辑 | 大数据量不卡 | 高 |
这张表里,前两项我建议 demo 阶段就加上,成本低收益直接。后三项等真有量了再说,提前做就是过度设计。
最后一个技巧关于验证:上线前一定要用两个不同微信号互投一遍,再用同一个号重复投一次,确认唯一索引生效。我自己的习惯是每次改完投票相关逻辑,都跑一遍「创建 → 发布 → 单选投 → 多选投 → 重复投 → 过期投」这六步,六步都过才算改完。这个习惯帮我挡掉过好几次「改 A 坏 B」的回归问题。投票系统逻辑不复杂,但状态和权限的组合多,靠人脑记不如靠固定流程跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取