简介:这套在线考试系统小程序是一份可直接运行的高分项目源码包,配套数据库脚本,定位为毕业设计、期末大作业与课程设计的参考实现,适合具备小程序或 Java 基础的学习者模仿、改造与二次扩展。压缩包约12.69MB,含1009个文件,覆盖 wxml/wxss 小程序端页面、vue 后台管理界面、java 服务端逻辑、js 交互脚本及 sql 数据库结构,并辅以 png/svg/jpg 等图标与素材,便于按层理解前后端协作。目前已有285人浏览学习,项目经本地编译验证可运行,评审分达98分,整体难度适中,内容经助教审定,适合教学场景。资源内还提供安装、运行与打包脚本,可降低环境配置门槛;后台管理部分包含常见布局与功能模块,能帮助读者快速掌握小程序在线考试、题库管理、成绩处理等核心流程。
1. 从“高分项目”说起:这套在线考试小程序到底能拿来干什么
培训机构要组织模拟考、老师要发单元测试、学生要做毕业设计——这三类场景里最常见的需求都是同一件事:给出一套题,让用户在微信小程序里答题,再把成绩存进数据库。微信小程序-在线考试系统(源码+数据库)这类项目解决的正是这个闭环:小程序端负责出题和交互,后端接口负责判分和存储,数据库表结构负责把题库、试卷、成绩沉淀成可查询的数据。它不是个纯展示页,而是一个能真跑起来、能被部署到服务器上做交付的业务系统。适合谁?想用最短路径跑通完整业务链路的开发者,以及手上拿到源码但不知道怎么改成自己项目的从业者。我下面按页面、数据库、链路和踩坑的顺序把整套方案拆开讲。
2. 搭小程序端结构:考试列表、答题页与结果页的三层骨架
2.1 页面划分与跳转关系:三层页面各自只干一件事
在线考试的小程序端一般拆成三个核心页面:考试列表页、答题页、结果页。考试列表页展示有哪些可用试卷,答题页逐题作答,结果页在提交后展示得分、错题数和用时。为什么一定要分三个页面而不是单页切换?因为微信小程序页面栈的天然能力就是管理独立页面,答题过程中用户按左上角返回,结果页的onLoad会重新执行,数据重新加载,不会出现单页用v-if切来切去导致的状态残留问题。
三个页面注册在app.json的pages数组里,数组第一项就是启动页。页面之间用URL参数传递数据,比如从列表页跳到答题页要带上paperId:
goExam(e) { const paperId = e.currentTarget.dataset.id; wx.navigateTo({ url: `/pages/exam/exam?paperId=${paperId}` }); }navigateTo会往页面栈里压一个新页面,用户可以用返回键回到列表页。注意不要用wx.redirectTo,它会替换当前页面,导致用户返回时直接退出小程序。答题页在onLoad里通过options.paperId拿到试卷编号,再发请求加载题目。这里常见的误用是:把paperId塞到全局变量或Storage里,页面一多全局变量互相覆盖,多个试卷来回切换时就拿错题。URL传参是官方推荐的标准做法,页面生命周期清晰,调试也好查。
2.2 顶部导航栏高度:别写死,要按机型动态计算
搜索“微信小程序顶部导航栏高度”会看到大量帖子,凡是做自定义导航栏的项目都要经历一遍这个计算。导航栏高度不是一个固定值,iPhone 14 Pro和一台老安卓机的状态栏高度差十几像素,写死会出现自定义导航把内容顶出屏幕外的现象。我一般用wx.getWindowInfo读取状态栏高度,再根据胶囊按钮的坐标算出导航栏内容区高度,动态设置占位视图的高度。
const windowInfo = wx.getWindowInfo(); const capsule = wx.getMenuButtonBoundingClientRect(); const navBarHeight = (capsule.top - windowInfo.statusBarHeight) * 2 + capsule.height; this.setData({ statusBarHeight: windowInfo.statusBarHeight, navBarHeight: navBarHeight });这段代码的关键在两处:statusBarHeight是状态栏高度,胶囊按钮的top值减去它是导航栏距顶部的间距,乘2再加胶囊自身高度,就是导航栏总高度。这样算出来的值在主流机型上都能对上。要注意wx.getWindowInfo是从基础库2.20.1开始推荐使用的接口,替代老的getSystemInfoSync;老接口在部分新机型上会返回缓存的状态栏值,偶尔偏几像素。
导航栏占位视图用style="height: {{statusBarHeight + navBarHeight}}px"动态撑高,下面内容区就不会被刘海屏遮住。单位这块有个容易混淆的地方:小程序默认推荐rpx做适配,但状态栏高度和胶囊坐标返回的数值本身就是px,所以导航栏高度直接用px设置更稳,特意换算成rpx反而会引入机型误差。
2.3 答题交互:单选框组件与选项数据的绑定
单选是考试系统最基础的题型,小程序里对应radio-group和radio组件。一个容易忽略的细节是:radio组件默认选中样式是圆形加边框,如果产品要求选项前面是字母圈,光改color不够,还要覆盖原生样式。真机上radio的样式兼容性一般,很多项目干脆用view模拟选项,点击整行触发选中,视觉统一,也不吃radio的默认样式。标准做法还是用radio-group,代码注意checked状态要绑到数据上:
<radio-group bindchange="onChoiceChange">onChoiceChange(e) { const idx = e.currentTarget.dataset.idx; const selectedKey = e.detail.value; this.setData({ [`questions[${idx}].selectedKey`]: selectedKey, [`questions[${idx}].options`]: this.data.questions[idx].options.map(opt => ({ ...opt, checked: opt.key === selectedKey })) }); }用map重建options而不是原地改,是因为setData需要浅拷贝来触发视图层更新;直接写options[0].checked = true去改数组元素,视图不会重绘。data-idx取自当前题目在页面数组里的下标,不能用题目数据库里的id,因为答题页展示的题目顺序经过排序,id和展示序号完全对不上。多选题的交互区别只是把组件换成checkbox-group,data里从selectedKey换成selectedKeys数组,提交后端时用逗号拼接。
2.4 题目列表的加载:分页与“加载更多”的坑
考试列表和题目列表都存在加载问题:一次把几百道题塞进setData,渲染层会明显卡顿,真机上的表现比开发者工具明显得多。微信小程序页面滚动到底部时触发onReachBottom,这是做“页面列表加载更多”的标准入口。
onReachBottom() { if (this.data.page >= this.data.totalPages) return; this.loadPapers(this.data.page + 1); }, loadPapers(page) { wx.request({ url: `${apiBase}/paper/list`, data: { page: page, pageSize: 10 }, success: (res) => { this.setData({ papers: this.data.papers.concat(res.data.records), page: page, totalPages: res.data.totalPages }); } }); }page从1开始,触底加1,直到等于totalPages时停止请求。这里用concat而不是覆盖赋值,是为了保留已加载的数据;直接用res.data.records赋值会把上一页丢光。pageSize参数在考试列表取10,题目列表建议取5,因为每道题包含题干、选项文本、图片URL,数据体积大,单次setData传输有性能瓶颈,超过1MB在低端机上会出现明显掉帧。还有一个容易漏的边界:接口失败时res.data.totalPages是undefined,onReachBottom里的判断this.data.page >= undefined不成立,会反复发起请求。正确做法是在success回调里先检查totalPages是否存在,不存在就默认1,请求失败时给用户一个toast提示,而不是静默重试。
3. 数据库设计:题库、答题记录与成绩统计的建表逻辑
3.1 为什么不能只建一张大表:改结构翻车后的血泪经验
很多拿这套源码改项目的人,第一反应是把题目、选项、答案、用户、成绩全塞进一张表里,因为查询最省事。但等做到成绩统计的时候就会翻车:一张表里既有字符串型的题干,又有数字型的得分,分组统计时字段类型互相干扰;改一个字段长度还会影响整张表的查询性能。血泪经验是:题库、答题记录、成绩统计拆成独立表,各表之间用ID关联,查询时Join,而不是冗余存储。
拆表的另一个理由是权限边界。题库表只有管理员能改,用户答案表只有本人和老师可查,如果所有业务字段堆在一张表,写权限判断的逻辑就会变成“这个角色能不能改这一行的某个列”,复杂度远超“这张表由哪个服务负责”。从我经手过的几个教育类项目看,三张核心表加一张用户表是最小可行结构:exam_question存题,exam_paper存试卷配置,exam_result存成绩,sys_user存用户身份。下面按这个结构展开建表。
3.2 三张核心表的建表语句:直接拿到MySQL里执行
这套SQL在MySQL 5.7以上版本可以直接执行。exam_paper表存试卷标题、时长、总分,exam_question表存题目本身,exam_result表存用户每次考试的成绩记录。paper和question是1对多,user和result是1对多,result里同时带paper_id又可以免去一次联查。
CREATE TABLE `exam_paper` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '试卷标题', `duration` int(6) NOT NULL DEFAULT 60 COMMENT '考试时长,分钟', `total_score` decimal(6,2) NOT NULL DEFAULT 0 COMMENT '试卷总分', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0草稿 1发布 2下架', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷表'; CREATE TABLE `exam_question` ( `id` int(11) NOT NULL AUTO_INCREMENT, `paper_id` int(11) NOT NULL COMMENT '试卷ID', `type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1单选 2多选 3判断', `content` text NOT NULL COMMENT '题干', `options` json DEFAULT NULL COMMENT '选项,格式[{"key":"A","text":"内容"}]', `answer` varchar(50) NOT NULL COMMENT '正确答案,多选用逗号分隔', `score` int(3) NOT NULL DEFAULT 5 COMMENT '单题分值', `sort_no` int(4) NOT NULL DEFAULT 0 COMMENT '题目排序', PRIMARY KEY (`id`), KEY `idx_paper_id` (`paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题库表'; CREATE TABLE `exam_result` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '用户ID', `paper_id` int(11) NOT NULL COMMENT '试卷ID', `total_score` decimal(6,2) NOT NULL DEFAULT 0 COMMENT '总分', `correct_count` int(6) NOT NULL DEFAULT 0, `wrong_count` int(6) NOT NULL DEFAULT 0, `duration_seconds` int(10) NOT NULL DEFAULT 0 COMMENT '答题用时', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1已交卷 2未交卷 3判分中', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_paper` (`user_id`, `paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试结果表';三个细节值得注意。第一,options用json类型比varchar拼接字符串好在哪里:在MySQL里能用JSON_EXTRACT取出某个选项文本,排序和过滤不依赖字符串匹配。第二,status字段留三个值,不要只做0和1:用户答题中途退出,成绩记录标记为未交卷,统计时排除,不然半途而废的答题也会被算进正确率里。第三,exam_result表建了(user_id, paper_id)联合索引,这是后面联查和FOR UPDATE行锁的前提,没有这个索引,事务锁会退化成全表锁,并发一高就把考试接口拖死。
3.3 成绩统计的联查:一次拿全正确率、错题分布和平均用时
成绩统计是考试系统里被问得最多的需求。“数据库增删改查”里,查是最考验设计的。成绩统计需要的信息横跨exam_result、exam_question、sys_user至少三张表:exam_result存分数和正误数量,exam_question存题型,sys_user存用户身份。一条联查SQL把它们串起来:
SELECT u.nickname, r.total_score, r.correct_count, r.wrong_count, ROUND(r.correct_count / (r.correct_count + r.wrong_count) * 100, 2) AS accuracy_rate FROM exam_result r LEFT JOIN sys_user u ON r.user_id = u.id LEFT JOIN exam_paper p ON r.paper_id = p.id WHERE r.paper_id = 1 AND r.status = 1 ORDER BY r.total_score DESC LIMIT 100;LEFT JOIN把用户昵称和试卷信息带进来,ROUND算出保留两位小数的正确率,WHERE过滤status=1排除未交卷,ORDER BY按总分降序排榜。LIMIT 100必须加:在线考试系统跑一个学期后会有上万条结果记录,全量返回会把后端接口拖垮。实际项目里LIMIT还要配合时间范围,比如只查当周成绩,避免LIMIT变成简单截断导致统计偏差。LEFT JOIN和INNER JOIN在这里的区别是:LEFT JOIN会保留那些用户已被删除的历史成绩记录,方便审计与追溯。
3.4 数据库增删改查的边界:批量导入题库的写法
建好表之后第一件事是灌题库,手写INSERT效率极低。常见做法是后端提供批量导入接口,前端粘贴JSON或Excel,后端解析后循环插入。批量插入的SQL在写法上和单条插入有一个关键差异:
INSERT INTO exam_question (paper_id, type, content, options, answer, score, sort_no) VALUES (1, 1, 'HTTP 默认端口是?', '[{"key":"A","text":"21"},{"key":"B","text":"80"}]', 'B', 5, 1), (1, 2, '以下属于传输层协议的有?', '[{"key":"A","text":"TCP"},{"key":"B","text":"UDP"},{"key":"C","text":"HTTP"}]', 'A,B', 10, 2), (1, 3, 'TCP 是面向连接的协议', '[{"key":"T","text":"对"},{"key":"F","text":"错"}]', 'T', 5, 3) ON DUPLICATE KEY UPDATE content = VALUES(content);ON DUPLICATE KEY UPDATE的作用是:重复导入同一份题库时,按唯一键命中后更新题干而不是直接报错,不产生脏数据。这里有一个版本坑:VALUES(content)这种写法在MySQL 8.0.20之后被标记为废弃,用新版本数据库执行老SQL会告警,需要改成别名语法。批量导入和单条插入还有一个差别:单条插入失败回滚一条就行,批量插入建议包在一个事务里,任何一个题目解析失败,整批回滚,避免半截题库变成事故现场。
4. 把链路跑通:登录鉴权、答题判分、成绩回写的最小闭环
4.1 登录鉴权:openid换token,别把openid丢给前端
微信小程序的登录流程是固定的:前端调wx.login拿到临时code,把code发给后端,后端用code向微信接口换openid和session_key,再用openid去用户表里查或建用户,最后签发一个自定义token返回给前端。常见错误是直接把openid当凭证给前端,后续接口都用openid做鉴权。openid是长期不变的,一旦泄露,别人可以伪造用户身份。我一般在服务端把openid映射成自增user_id,再生成一个带过期时间的token返回。
// 小程序端:登录换取token wx.login({ success: async (res) => { const { code } = res; const loginRes = await new Promise((resolve, reject) => { wx.request({ url: `${apiBase}/auth/login`, method: 'POST', data: { code }, success: resolve, fail: reject }); }); const data = loginRes.data.data; wx.setStorageSync('token', data.token); wx.setStorageSync('userId', data.userId); } });这段代码用Promise包了一层wx.request,原因是wx.request原生是回调风格,直接await拿不到结果。data里只传code,openid的获取完全在后端完成,前端连这个字段都不应该看到。token必须设过期时间,考试场景里考生会长时间挂机,token过期太早会导致交卷时被弹登录,所以过期时间至少大于一场考试的最长时长,常见做法是2小时。登录态还要统一封装:每个接口请求都在header里带Authorization: token,后端解析失败返回401时,前端统一清掉Storage里的token并跳转登录页。这个逻辑不要散落在每个页面里,封装一次wx.request,在回调里拦截401即可;考试页的onLoad要提前判断token是否存在,不存在直接redirect,避免用户从分享链接进来直接看到题目列表。
4.2 答题判分逻辑:为什么最终判分必须放后端
答题时前端可以校验格式,但判分务必放后端。原因很简单:前端判分的结果,用户用开发者工具改一下内存就能刷分。我早期版本把答案放在前端配置里,后端不校验,结果抓包看到正确答案全在返回报文里,一次考试就废了。正确做法是前端提交用户答案数组,后端根据数据库里的正确答案逐题比对。
// 后端判分伪代码 submitExam(examResult) { const questions = await db.query( 'SELECT id, type, answer, score FROM exam_question WHERE paper_id = ?', [examResult.paperId] ); let totalScore = 0, correctCount = 0; for (let i = 0; i < questions.length; i++) { const q = questions[i]; const userAnswer = examResult.answers[i]; if (this.compareAnswer(q.type, q.answer, userAnswer)) { totalScore += q.score; correctCount++; } } return { totalScore, correctCount, wrongCount: questions.length - correctCount }; }compareAnswer按题型区分:单选题直接字符串相等;多选题要先把用户答案和正确答案排序再比较,因为用户可能选B、C而库里存的是C、B;判断题把“对/错”归一化成T/F后再比。排序比较可以用数组的sort方法,但这里要和数据库返回的多选答案格式保持一致,我的习惯是数据库里统一存排序后的格式,前端提交前也排序,后端只做一次字符串比较,性能最好。有一个很多人漏掉的细节:遍历题目和比对答案要在同一个循环里,不要在循环外再查一次答案——题量大时两次查询会让响应时间翻倍。如果试卷包含主观题,系统一般留两个处理口子:客观题自动判分,主观题原样保存,成绩表status标记为判分中,老师在后台人工阅卷后再把分数补录进去。
4.3 成绩回写:事务与幂等键缺一不可
成绩回写是把判分结果写进exam_result表。这个步骤最容易出bug:用户点提交后网络闪断,前端收到失败提示,用户再点一次,成绩写了两遍。成绩回写的接口我习惯统一返回{ code: 0, data: { resultId: 123 } },code非0时前端提示失败。前端拿到resultId后跳转结果页并带上resultId,结果页按resultId拉取展示明细。提交和查询解耦之后,用户刷新结果页或把成绩分享给朋友,也能拉出同一份数据。
START TRANSACTION; SELECT id FROM exam_result WHERE user_id = 1 AND paper_id = 3 AND status = 1 FOR UPDATE; -- 若上述查询已返回记录,业务代码直接中止本次提交 INSERT INTO exam_result (user_id, paper_id, total_score, correct_count, wrong_count, duration_seconds, status) VALUES (1, 3, 85, 17, 3, 600, 1); COMMIT;FOR UPDATE把命中的行锁住,同一时刻第二个提交事务必须等第一个事务提交后才能执行,从数据库层面减少重复成绩行。注意使用前提是exam_result表有(user_id, paper_id)联合索引,否则InnoDB会升级成全表锁。但要说明边界:如果两个事务同时都查不到已存在的记录,FOR UPDATE锁的是空集合,挡不住并发下的重复插入。要彻底解决,更好的做法是引入幂等键——前端在开始考试时向后端申请一个recordId,提交时带上这个recordId,后端把它做成唯一键,重复提交只更新不新增。很多线上考试系统把幂等键当成必选项,就是这个原因。
5. 避坑清单:真机请求、数据库改表与页面状态的四类翻车记录
5.1 真机请求报错 code 10002:域名白名单与开发工具差异
有同事当着客户面演示系统,开发者工具里一切正常,一上真机所有接口全部请求失败,报错信息里带errno 10002。原因不是代码,是小程序真机环境强制要求request的url是HTTPS且域名已配置在微信公众平台的request合法域名里。开发者工具有“不校验合法域名”的开关,默认关闭,所以本地调试没问题;真机上没有这个开关,域名没备案、没配白名单、证书不完整,一律拦截。
解决方法分三步。先在公众平台把接口域名加进request合法域名,注意是协议加域名全量配置,不带路径;再把开发工具“详情-本地设置-不校验合法域名”开关关掉跑一遍生产环境,确认本地能过;最后检查证书链是否完整,用宝塔面板申请的Let's Encrypt证书中间证书没配全时,PC浏览器可能不报错,但小程序握手会失败,报错也可能带10002。
提示:开发工具里的“不校验合法域名”开关只对当前项目文件生效,换电脑收了代码要重新确认它处于关闭状态,否则会变成“工具一切正常、真机全挂”的经典翻车现场。
5.2 答题过程中切后台导致倒计时失效:页面生命周期钩子没接管
计时器在小程序里是setInterval实现的,考生切到微信其他会话再回来,setInterval回调有概率被系统挂起,倒计时不走,时间到了还在答题。这不是玄学,是移动操作系统省电策略在管束后台定时器。处理办法是在onHide里记录剩余时间并清除定时器,在onShow里重新启动倒计时。
onHide() { clearInterval(this.interval); wx.setStorageSync('exam_remaining_' + this.data.recordId, this.remainingSeconds); }, onShow() { const saved = wx.getStorageSync('exam_remaining_' + this.data.recordId); if (saved && saved < this.remainingSeconds) { this.remainingSeconds = saved; this.startTimer(); } }saved要拿小于当前剩余时间的那一个值,原因是一个考号可能在两个设备上登录同一份卷子,后登录的会话存储被覆盖后,先回来的人如果直接恢复会被带偏。把recordId作为Storage key的一部分,避免不同考试互相覆盖。这里还有一个细节:onHide触发时this.interval正在执行,先clear再存值,顺序反了的话,清除定时器后最后一次减秒数没执行,会存出一个多一秒的剩余时间,倒计时走到0却还显示着“00:01”。
5.3 修改MySQL表结构导致查询变慢:备份、执行、回滚三步别跳
拿到这套项目后,按自己需求改表结构是迟早的事。我见过有人线上直接ALTER TABLE加字段,type从tinyint改int的过程中表锁住,考试接口超时一分钟的血案。MySQL 5.7的ALTER TABLE大部分操作是copy table策略,表越大耗时越长。操作前必须备份,操作时挑业务低峰期。
mysqldump -u root -p exam_db > exam_db_backup_$(date +%Y%m%d).sql备份文件就是你改表操作的后悔药,改坏了直接把表DROP掉再导入备份扛回来。改表之前我还会先跑一条SHOW TABLE STATUS LIKE 'exam_question',看行数和数据长度,如果单表超过500MB,建议直接走新表RENAME的流程,别赌ALTER能在低峰期快速完成。字符集也是改表时最容易漏掉的参数,老表如果是utf8,题目里插emoji会报错,顺手在改表时统一成utf8mb4。改完之后还要跑一遍常用查询的EXPLAIN,确认新索引被用上,而不是依赖旧的SQL硬跑。
5.4 多选判分结果对不上:答案存储格式不一致
多选判分错是高频报障。现象是用户选了A和B,正确答案也是A和B,系统还是判错。原因多半是答案存储格式不一致:数据库里存的是“B,A”,前端提交的是“A,B”,字符串比较直接不相等。解决思路只有一个:提交和判分时统一排序。我在4.2说了后端判分时把多选题答案排序比对,那是对新数据。老项目里已经积累的历史数据也要洗一遍,否则同一张卷子新题对、老题错。
清洗历史数据要谨慎,不要一上来就全表UPDATE。先把可疑记录捞出来人工确认:
SELECT id, answer FROM exam_question WHERE type = 2 AND answer NOT IN ('A,B','A,C','A,D','B,C','B,D','C,D');这条SELECT只适用于两个选项的多选题场景。如果库里存在三选、四选的多选题,排序规则不一样,直接跑全表UPDATE很容易把本来准确的答案改乱。我的习惯是清洗前先统计命中条数,确认影响范围,再用事务逐条更新并count核对总数。数据库增删改查里,改永远比查危险,批量改尤其要有一份改完能对比的SELECT语句。
6. 给这套系统加三个实用扩展:错题本、倒计时与成绩导出
6.1 错题本:只记录错误答案,不复制整份卷子
错题本最常见的错误做法是:把错题整行复制到一张新表里。这样做会让题库数据翻倍,后期改一道题要改两处。正确做法是错题表只存user_id、question_id、user_answer,展示时联查exam_question取题干,题库改一个字,错题本自动同步。
CREATE TABLE `wrong_book` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `question_id` int(11) NOT NULL, `user_answer` varchar(50) NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_question` (`user_id`, `question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;UNIQUE KEY的作用是同一道错题只记录一次,重复做错只更新user_answer,不会新增一行,错题本不会越积越重。
6.2 倒计时提醒:考试结束前5分钟的交互处理
倒计时不只是显示剩余时间,更重要的是结束前的软提醒和强制交卷。我习惯在剩余5分钟时做一次浅提示,剩余0分钟时弹窗“时间到,自动交卷”,用户点确认才提交。这样可以避免最后一秒输入答案时突然清空答题页引发投诉。实现就是一个setInterval每秒减1,减到0时清空定时器并执行提交逻辑。注意页面卸载onUnload里也要clearInterval,否则页面销毁后定时器还在跑,造成内存泄漏。
6.3 成绩导出:后端生成CSV比小程序端导出更省力
成绩导出不建议在小程序端做,小程序没有好用的本地文件保存API,数据量大时setData还会卡死。常见做法是后端生成CSV文件返回下载URL,小程序端用wx.downloadFile加wx.openDocument打开。CSV用GBK编码而不是UTF-8,否则Excel打开中文会乱码,这是很隐蔽但一定会遇到的坑。
我自己的习惯是在交付这套系统前,拿一批样本成绩把导出逻辑跑一遍,确认Excel打开无乱码、题号顺序和试卷一致、多选答案以顿号分隔。这套系统做完,剩下维护工作里题库和成绩的导入导出占了大头——把这两块做稳,比多写两个花哨页面实在得多。希望帮到你。
本文还有配套的精品资源,点击获取