去年帮某高校教研室做的高校在线考试系统技术栈就是标题里的 Vue Node.js ElementUI从数据库到前端页面再到部署上线整个链路走完后我最大的感受是在线考试系统的难点从来不是“能考试”而是“考得准、交得稳、判得对”。这套系统做下来覆盖了管理员、教师、学生三种角色包含题库管理、自动组卷、在线答题、倒计时交卷、自动阅卷、成绩统计这些模块。如果你正准备做同类项目或者在公司里需要快速搭建一套内部考试/培训考核平台这篇文章应该能帮你少走不少弯路。我不打算只列功能清单而是按真实开发顺序把每一步的设计理由和踩坑过程讲清楚尽量做到看完就能照着自己的项目复现。1. 项目背景与方案选型在线考试系统到底要解决哪些问题1.1 一场线上考试的真实业务链路刚开始和某高校的对接老师聊需求时对方给的需求文档写得很简单“能发布试卷、学生能在线作答、自动算出成绩”。但真正坐到一起梳理业务流程才发现问题远没有那么简单。一场考试从创建到出成绩至少要经历 7 个环节教师录入题库、按规则组卷、设置考试时间与时长、发布考试、学生登录考试页答题、系统自动判客观题、教师人工批主观题最后还要支持成绩导出和统计分析。其中最容易在设计阶段被忽略的是“同卷不同序”和“考试中途异常离开”这两个场景。前者要求每个学生看到的题目顺序、选项顺序可以不同目的是降低抄袭概率后者要求学生在断网、刷新、误关页面后重新回来还能回到原来的答题进度而不是重新考一遍。这两个需求直接影响数据库设计和前端状态管理方案。我在项目初期就是因为在文档里没把这些场景写全导致后端表结构返工了一次后面会详细说。1.2 技术选型为什么是 Vue Node.js ElementUI这套技术栈在今天看来不算新潮但放在高校内部系统的场景里非常合适理由很实在第一团队语言栈统一。前后端都用 JavaScript后端用 Node.js 做接口服务前端用 Vue 做页面中间不需要切换语言上下文。对一个小型开发团队来说这能把协作成本压到最低连接口数据结构都能直接在代码里共享。第二ElementUI 的后台管理组件太成熟了。考试系统有大量的后台管理页面——试题列表、试卷配置、考试记录、成绩表格这些页面高度依赖表格、表单、弹窗、分页、级联选择等组件ElementUI 基本全是现成的不需要自己造轮子。虽然界面风格偏“中后台”但高校项目反而吃这一套朴素、稳定、功能一目了然。第三Vue 的单页应用特性适合考试流程。考试过程是一个典型的“填表 状态切换”交互从进入考试页到答题卡标记、倒计时更新、交卷确认基本都在同一个页面视图里完成不需要频繁切换路由。Vue 的响应式状态管理配合 Vuex处理这种高交互场景非常顺手。对比一下其他方案Java Spring Boot 是高校老系统的常客但在这个项目里没必要为了“传统”而引入更重的框架因为业务量级就是几百人同时在线直接用 jQuery 后端模板渲染也能做但考试页面的实时状态同步和组件复用会别扭很多。综合下来Vue Node.js ElementUI 是投入产出比最高的组合。1.3 功能模块与角色权限划分系统按角色拆分成三个端管理员端管理院系、班级、教师账号负责系统基础数据维护查看全局考试统计数据。教师端核心使用方。管理题库单选、多选、判断、简答四种题型、手动组卷或随机组卷、发布考试、查看考试成绩、人工批改主观题、导出成绩单。学生端参加考试。查看待考列表、进入考试、答题、交卷、查看成绩和答案解析。权限模型我用的是最经典的三张表用户表、角色表、用户角色关联表。细粒度权限没有做得太过因为高校内部系统的角色是固定的三种不需要像互联网系统那样做菜单粒度权限。但接口层面必须做角色鉴权不能只在菜单上做隐藏。前端路由用 Vue Router 的全局守卫做了登录态校验和角色页面隔离后端接口用 JWT 中间件校验身份每个接口显式声明允许的角色。2. 数据库建模把“一张试卷”拆成哪几张表才合理2.1 用户、角色与考试组织的表结构设计先把基础表列出来这套结构基本可以平移到任何内部考试系统users 表用户id、username、passwordbcrypt 加密后的哈希、real_name、roleadmin/teacher/student、department_id、class_id、created_at。departments 表院系id、name。classes 表班级id、department_id、name。这两个表主要是为了按班级群发考试以及后续按院系维度做成绩统计。角色我一开始用独立表rolesuser_roles关联表后来想想这个系统短期内不可能出现动态角色配置就直接在 users 表里加了role字段。做这类内部管理系统时过度设计是最大的敌人简简单单用一个字段解决问题更实用。如果后续要扩展角色再拆表也来得及数据迁移成本并不大。2.2 题库、试卷与答题流水的关系建模这是整个系统里最值得花心思的部分。我把它拆成四张核心表questions 表题目表id、typesingle/multiple/judge/short、content题干、optionsJSON 数组如[A.xxx,B.xxx]、answer、difficulty、score、course_id。这里有一个关键点选项用 JSON 字段存储而不是单独建一张选项表。一开始我纠结过要不要把选项拆成子表后来想明白了考试系统的题目一旦被引用进某份试卷就要求选项和答案是固定快照不允许后面题目被修改而影响历史考试。所以题目的options和answer在组卷时会复制一份到试卷题目表而不是实时关联。papers 表试卷表id、name、course_id、total_score、creator_id、create_time。这只是试卷的壳具体题目关系在下一张表。paper_questions 表试卷题目表id、paper_id、question_id、score、sort_order、options组卷时复制的题目选项快照、answer复制的答案快照。这张表是整张试卷的核心因为它同时记录了题目顺序、分值和过期快照。随机组卷时就是向这张表批量插入题目。exams 表考试记录表id、paper_id、title、start_time、end_time、duration分钟、status0未发布/1进行中/2已结束、created_by。一场考试对应一份试卷考试和试卷分离是因为同一份试卷可能在不同时间被多个班级考。2.3 答题记录与成绩表的幂等设计学生作答记录是整个系统数据量最大、并发最高的表设计时主要考虑“怎么保证第一次交卷就是唯一一次”。answer_records 表答题流水表id、exam_id、student_id、paper_question_id、answer_content、score、is_correct、submit_time。每个学生每道题一条记录主观题的分数字段先留着等教师批改后回填。exam_results 表考试成绩表id、exam_id、student_id、paper_snapshot选择题答案快照JSON、objective_score客观题得分、subjective_score主观题得分、total_score、submit_time、status0未交/1已交/2待批改。这里做了两个冗余设计一是paper_snapshot存整个试卷的题目顺序和选项序因为同一场考试可能有不同的乱序版本成绩复核时必须知道学生当时看到的是哪一版卷子二是成绩表里同时冗余了objective_score和subjective_score避免每次查成绩都要临时聚合答题流水表。不做冗余的话一旦答题记录因为某种原因被变更历史成绩就没法看了。“交卷幂等”是在exam_results表上建了exam_id student_id唯一索引后端交卷接口先查后插走到插入那步再用唯一索引兜底具体逻辑第三章讲。3. Node.js 后端核心考试倒计时、自动阅卷与防重复交卷3.1 Express 接口分层与 JWT 鉴权中间件后端我用的是 Express没有上 NestJS因为系统以简单 REST 接口为主没必要用重框架增加认知负担。目录结构长这样server/ routes/ // 路由定义 controllers/ // 控制器取参、校验、调用 service services/ // 业务逻辑层 models/ // 数据库操作 middlewares/ // auth、error handler路由层只做 URL 分发controller 只做参数校验和结果包装真正的业务逻辑全部下沉到 service。这样做的最大收益是自动阅卷这种复杂逻辑可以单独写成 service 函数交给定时任务调用和接口调用都不冲突。鉴权中间件是每个接口绕不开的一环。登录成功后发 JWT前端存 localStorage每次请求带Authorization: Bearer token。中间件里解析 token、校验角色然后挂到req.user上// server/middlewares/auth.js const jwt require(jsonwebtoken); module.exports function auth(roles []) { return (req, res, next) { const token req.headers[authorization]?.split( )[1]; if (!token) return res.status(401).json({ code: 401, msg: 未登录 }); try { const payload jwt.verify(token, process.env.JWT_SECRET); if (roles.length !roles.includes(payload.role)) { return res.status(403).json({ code: 403, msg: 无权限 }); } req.user payload; next(); } catch (e) { return res.status(401).json({ code: 401, msg: 登录已过期 }); } }; }使用时就特别简单比如只有教师和管理员能访问试卷列表接口router.get(/papers, auth([admin, teacher]), paperController.list);3.2 考试倒计时的三种方案我最终选了哪一种考试倒计时是前端必做、后端更要管的功能。我对比过三种方案各有各的坑方案一前端计算倒计时。进入考试页时从接口拿到end_time然后前端用setInterval每秒减 1。这个方案实现最简单但致命问题是前端时间可以被用户篡改——改系统时间、改浏览器时间倒计时立刻失真。高校考试虽然不至于有人恶意作弊但监考老师有硬性要求所有考生必须以服务器时间为准。方案二后端存截止时间前端定时校准。进入考试时下发end_time前端倒计时显示用本地计时器驱动但每 5 秒调一次接口校准服务器时间差值超过阈值就强制刷新本地倒计时。这个方案能防住大部分时间作弊但接口请求太频繁几万人考试时会对服务造成无意义压力。方案三最终采用后端时间 前端本地预估。进入考试时后端一次性下发server_time和end_time前端算出时间差offset end_time - server_time倒计时展示就用offset - 已过本地时间。整个考试过程中不额外请求时间校准接口交卷时后端必须校验当前服务器时间是否超过end_time超过直接拒绝并强制交卷。为什么最终选方案三因为高校考试场景里学生电脑时间不对是常态但截断网络把浏览器时间改到考试结束前才交卷的极端场景很少。我们靠交卷时的服务端校验兜底考试过程中的倒计时即使有微小偏差也不会影响判定结果。实现代码大致是这样的// 进入考试时接口返回 { serverTime: 1699999999999, // 后端当前时间戳 endTime: 1700000000000 // 考试结束时间戳 } // 前端计算 const remainMs endTime - serverTime;3.3 自动阅卷的判分逻辑最少要处理这四种题型客观题自动判分是逻辑最直接但也最容易出细节问题的地方我把它拆成四个分支处理单选题答案存的就是单个选项字符如A判分时直接比对answer_content answer。判断题答案存T或F判分同样是一对一比对逻辑和单选题一样。多选题这里有个很容易踩的坑——多选答案在数据库里存的是[A,C,D]数组但如果存成 JSON 字符串比对时用绝对会出问题因为[A,C]和[A,C]在 JS 里是引用比较必须拆成数组然后排序后 join 再比对。还有评分策略分成“漏选得一半分”和“多选不得分”两种需要在试卷里配置一个score_rule字段否则不同老师对同一道多选判分要求不一样逻辑就写死了。我们最终实现的判分核心函数长这样// server/services/scoreService.js function scoreQuestion(question, answerContent) { const type question.type; const correctAnswer JSON.parse(question.answer); // 已存为数组或字符串 if (type single || type judge) { return answerContent correctAnswer ? question.score : 0; } if (type multiple) { const userArr JSON.parse(answerContent).sort(); const correctArr correctAnswer.sort(); if (userArr.join(,) correctArr.join(,)) return question.score; // 漏选给一半分用户选中的每个选项都是正确答案的一部分 const allCorrect userArr.every(opt correctArr.includes(opt)); if (allCorrect) return Math.floor(question.score / 2); return 0; } // 简答题返回 0等待人工批改 return 0; }自动阅卷的触发时机与人工批改流程自动阅卷不能放在交卷接口的同步流程里做因为多道题判分虽然快但在高并发交卷时会拖慢交卷响应时间。我的做法是交卷接口只做一件事——把答题记录和试卷快照写入数据库标记状态为“待判分”然后立即返回。判分操作由一个延迟任务或者队列消费端去执行。管理员在教师后台能看到两类成绩状态客观题 0 分主观题待批改。教师进入批改页面后左侧是客观题得分预览右侧按简答题逐题人工打分打分完成后保存系统自动汇总objective_score subjective_score。这里有个细节即使简答题答案为空也必须生成一条答题记录否则教师在批改页面会漏看那些一个字没写的学生。3.4 防重复交卷唯一索引 Redis SetNX 双重保险交卷接口是并发风险最高的时候。学生点交卷之后前端按钮会立刻置灰但这只能防住正常用户防不住恶意请求重复 POST。我的策略是两重防护第一重exam_results表建唯一索引uk_exam_student(exam_id, student_id)第二次交卷时插入直接报 duplicate key 错误第二重在交卷接口开头用 Redis 的SETNX key value做分布式锁key 为submit:examId:studentId成功拿到锁才继续处理否则直接返回“已交卷”。// server/services/examService.js const lockKey submit:${examId}:${studentId}; const lockAcquired await redis.set(lockKey, 1, EX, 120, NX); if (!lockAcquired) { return { code: 400, msg: 试卷已提交请勿重复操作 }; } try { // 检查考试是否已结束、是否已提交 // 写入 answer_records、更新 exam_results } finally { await redis.del(lockKey); }这套方案实测下来同时 500 人交卷没有出现一条重复记录。3.5 突然断网、刷新、关页面的恢复机制在线考试最怕学生考到一半断网刷新后看到空白页。我在后端设计了“考试状态恢复”接口进入考试页时调一次GET /api/exams/:id/status后端根据exam_results的状态返回0未开始考试前端跳到考试介绍页1已在考试中返回已提交的答题记录、剩余时间、试卷题目数据前端恢复到上次状态2已交卷但仍在考试时间内返回已交卷状态前端提示“你已完成考试”。这个接口同时解决了“刷新丢进度”和“考试中途换设备不丢进度”的问题理论上只要后端答题流水还在前端随时可以重建试卷状态。4. Vue ElementUI 前端重点考试页面和管理后台的实现套路4.1 前端路由守卫与考试状态恢复前端路由用 Vue Router登录页和考试页要特殊处理。全局守卫里做两件事先判断是否登录未登录一律跳到登录页再判断路由的meta.role是否包含当前用户角色不匹配就跳 403 页。考试页面/exam/:examId的守卫逻辑特殊一点进入前先调后端状态接口拿回考试是否还在有效期内如果考试已结束直接跳成绩页如果正在考试就恢复答题记录这一步必须放在路由守卫里做否则用户刷新后答题状态会先闪一下空白体验很糟糕。// router/index.js router.beforeEach(async (to, from, next) { const user store.state.user; if (!user) { if (to.path /login) return next(); return next(/login); } if (to.meta.role !to.meta.role.includes(user.role)) { return next(/403); } next(); });4.2 考试页答题卡设计为什么不用每道题都发请求考试页的交互核心是答题卡。左侧是题目列表点击切换题目右侧显示试卷信息、倒计时、交卷按钮中间是当前题目的题干和选项。我用了 Vuex 存“当前试卷状态”结构大概是currentQuestionIndex当前定位到第几题answers对象key 为paperQuestionIdvalue 为选中的答案answeredCount已答题目数驱动顶部进度条。为什么用 Vuex 而不是直接在组件里存因为考试页里存在大量跨组件状态传递顶部倒计时组件要影响交卷按钮的可用状态答题卡侧边栏要实时显示已答题目列表题目切换组件要同时更新 Vuex 的索引。如果用组件自己的 data 去管理这套联动逻辑会变得特别混乱。用户每点一个选项答案只更新 Vuex不发请求。只有交卷时才把所有答案一次性提交。这样做一是减少接口调用量二是避免用户切题时答案闪烁。防断网的策略是浏览器beforeunload事件里把 Vuex 里的answers序列化进 localStorage用户刷新后从 localStorage 恢复。答题卡交互还有一个细节多选题用 checkbox单选题和判断题用 radio简答题用 textarea。这几个表单元素在 ElementUI 里全部现成但需要统一封装一个QuestionCard.vue组件根据question.type渲染不同控件组件内部只负责展示和触发store.commit(setAnswer, ...)不关心业务逻辑。4.3 ElementUI 在后台管理页面的组合玩法后台管理页面大量依赖 ElementUI真正效率拉满的地方是这几个组件的组合el-table el-pagination el-select教师题库列表页就是标准的三组件组合。表格展示题干和题型分页器控制翻页顶部的类型筛选器用 select 绑定查询参数。这个组合要注意的是筛选条件变化时必须把当前页重置为 1否则筛选后还在第 5 页界面会显得很奇怪。el-dialog el-form新增和编辑题目共用一个弹窗表单通过dialogType区分新增还是编辑。vue 的响应式特性在这里有个坑打开编辑弹窗时表单回填直接赋值form row会导致修改原表格数据必须用深拷贝JSON.parse(JSON.stringify(row))。el-upload el-table批量导入题库用的是 el-upload 上传 Excel 文件后端解析后返回成功与失败列表前端用 el-table 展示导入结果。这里建议在上传成功后再调一次列表接口刷新数据否则用户会一直停留在旧数据界面。4.4 axios 拦截器统一处理登录态与错误提示接口请求统一封装在utils/request.jsaxios 实例挂上请求拦截器和响应拦截器。请求拦截器做两件事从 localStorage 取 token 加到请求头如果是交卷这种关键请求给按钮加 loading 状态防止用户重复点击。响应拦截器统一处理三件事返回码为 200 时直接返回data字段返回 401 时清除本地登录态并跳转登录页其他错误码用 ElementUI 的Message.error弹出后端返回的报错信息。// utils/request.js service.interceptors.response.use( (response) { const res response.data; if (res.code 200) return res.data; if (res.code 401) { store.dispatch(logout); router.push(/login); return Promise.reject(new Error(未登录)); } Message.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); }, (error) { Message.error(网络异常请稍后重试); return Promise.reject(error); } );这套防御在考试页特别重要交卷时如果网络断了用户至少能看到一个明确的“网络异常”提示而不是在毫无反馈的情况下看着页面卡死。5. 上线前后踩过的坑时钟、多选题与并发交卷5.1 学生电脑时间不准引发的倒计时错误这个问题上线第一天就暴露了。当时有一批学生的电脑系统时间比北京时间快了 3 分钟导致考试页显示的剩余时间比实际少了 3 分钟学生还没答完就显示考试结束全场炸锅。排查后确认问题出在前端计算倒计时的公式上。最初我是这么写的// 错误写法 const remainMs new Date(endTime).getTime() - Date.now();Date.now()读的是本地系统时间一旦学生电脑时间不准就出错。后来改成以服务器时间戳为基准// 正确写法 const offset new Date(endTime).getTime() - serverTime; // 服务器剩余毫秒 const remainMs offset - (Date.now() - localStartTime); // 减去本地已过时间这里localStartTime是进入页面时本地记录的时间戳。因为学生在考试期间电脑时间发生变化的情况极少所以只需保证进来那一刻的基准是对的即可。如果你要做得更严谨可以在倒计时只剩最后 5 分钟时额外调一次时间校准接口把服务器时间拉下来重算 offset。这个坑给我们的教训是一切时间判断都必须以服务器时间为准前端能显示倒计时但永远不要相信前端时间。5.2 多选题判分JSON 数组的顺序陷阱自动阅卷上线前测试时我们造了几套模拟数据测出来十道多选题错了一半。查了半天才发现是判分函数里数组比较出了问题。我第一版写的多选判分是if (JSON.parse(userAnswer).join() JSON.parse(correctAnswer).join()) { return score; }看起来没问题但 JSON.parse 出来的是数组数组的顺序在存储和读取过程中并不保证一致。如果后台录入答案时存的顺序是[A,C,D]前端传上来的用户答案恰好是[A,D,C]字符串 compare 就失败了。修复方案就是前面提到的先排序再拼接const u JSON.parse(userAnswer).sort().join(,); const c JSON.parse(correctAnswer).sort().join(,); if (u c) return score;同一个坑还会出现在后台批量导入题目的场景Excel 里多选题答案写的是ACD而不是A,C,D导入解析时就得统一转成数组再排序存储。建议在题库导入导出模块做一次答案格式规范别让脏数据流进正式库。5.3 高并发交卷时答案记录写入顺序不一致有次模拟 1000 人同时交卷出现了一个奇怪的现象部分学生的客观题成绩比预想的少了几分。查日志发现这些学生的答题记录有一部分在“判分任务”执行时才刚插入另一部分已经插完判分逻辑读取的数据自然不全。问题根因是交卷接口为了追求响应速度使用了“先写一句话记录再异步判分”的模式。但数据库写入并不是原子的多条记录被拆成了多个 INSERT异步判分读库时只能读到一部分。解决方法是把整个交卷写库操作放在一个事务里所有答题记录一次性写入再标记成绩表的待判分状态// server/services/submitService.js const transaction await db.sequelize.transaction(); try { await AnswerRecord.bulkCreate(recordList, { transaction }); await ExamResult.update( { status: pending, submit_time: new Date() }, { where: { exam_id, student_id }, transaction } ); await transaction.commit(); } catch (e) { await transaction.rollback(); throw e; }事务提交完成后判分任务再来读答题记录就是完整的了。这个改动之后模拟压测 1500 人交卷客观题判分都稳定准确。5.4 部署与反向代理静态资源分离和 HTTPS部署环境是一台 4 核 8G 的云服务器前端打包成静态文件交给 NginxNode.js 后端用 PM2 守护进程监听 3000 端口Nginx 做反向代理把/api路径转发到本地 3000。# nginx.conf 关键配置 server { listen 80; server_name exam.example.com; root /var/www/exam-frontend/dist; location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }前端打包时要特别注意 Vite 的base配置如果部署在子目录就要把base改成对应路径否则静态资源全部 404。还有开发环境和线上环境的接口地址不要写死在代码里用环境变量做区分开发环境.env.development指向本地接口生产环境.env.production指向 Nginx 反代地址。防作弊方面我做了三层一是切屏检测考试页面监听visibilitychange事件切出 5 秒以上记录并提示一次超过三次标记为风险二是选项顺序随机组卷时同一道题的选项顺序在paper_questions表里存一份乱序快照三是题目顺序随机同一份试卷在生成考试时按不同分发给不同学生。这套组合拳下来虽然没有完全杜绝作弊但至少挡住了大多数靠截图和传答案的低成本手段。5.5 移动端适配和浏览器兼容高校学生现在大量用手机考试这个需求最初被忽略了。ElementUI 默认是为桌面端设计的表格和弹窗在手机上的体验很一般所以考试页单独做了一套移动端样式答题选项的 padding 加大、字号调大、交卷按钮固定在底部、顶部倒计时缩小。用 CSS 媒体查询配合触发断点不需要引入额外的移动端 UI 库。浏览器兼容主要盯住两个旧版本的坑一是旧版 Safari 不支持Date.now()之外的某些时间格式化写法倒计时字符串解析时容易被坑二是 IE 已经完全不用管但 Chrome 和 Edge 的老版本要注意Array.prototype.sort在数组元素为字符串时是字典序排序这在多选题判分时会引发问题所以判断用了显式的localeCompare兜底。写在最后做在线考试系统最容易被低估的是流程完整度这整套系统从设计到上线大约用了三周工作量最大的不是代码本身而是把“考试”这件线下很自然的事情翻译成线上系统能处理的确定性逻辑。倒计时以谁为准、多选题怎么算分、断网后怎么恢复、交卷怎么保证唯一——这些才是项目真正的复杂度所在。我个人真实的体会是如果你现在准备做类似的系统建议先别急着写代码找一位带过真实考试的教师聊一小时把你设计的流程从头到尾给他讲一遍他大概率会告诉你三件你没想到的事学生没看到考试通知怎么办、有人在考试中途换了电脑怎么办、试卷题目有错别字需要临时修改怎么办。这三个问题的答案才是你系统真正区别于“Demo”的分界线。技术上 Vue Node.js ElementUI 这套组合足够承担这样的业务团队小、迭代快、后台管理页面省心。后续如果想扩展更多能力比如人脸识别监考、AI 查重、基于大数据的学情分析也都是在现有架构上做增量不会推倒重来。但不管加什么先把考试流程里那些“异常情况”处理好系统才算立得住。