☰
基于Vue 3搭建科目一模拟考试系统:题库、判分与状态管理全解析
2026/10/30 17:49:05 网站建设 项目流程

“科目一离开的太快就像龙卷风”,我会想你的!——这句话最近经常出现在驾考相关的讨论里。考试当天紧张地刷完题目,交卷,电子屏幕上弹出“合格”,然后一切就像被风卷走一样迅速结束。这段经历很多人都经历过,但站在技术开发者的角度看,“科目一”不只是驾考中的一个科目,它代表了一类非常典型的产品场景:题库管理、随机抽题、在线答题、自动判分、错题回顾。这类场景开发门槛不高,但想做得稳定、顺手、抗造,并不像简单写一个静态页面那么容易。

这篇文章先从一个最小可用的“科目一模拟考试系统”说起,使用 Vue 3 在浏览器里直接跑通整个流程,再从状态管理、数据存储、随机算法、防作弊边界这些角度,把在线测评类项目的工程化思路一并讲清楚。如果你正在做刷题 App、在线考试后台、面试复盘工具,或者单纯想给自己的项目加一个“模拟测试”模块,这篇文章应该能帮你避开不少弯路。

1. 这篇文章真正要解决的问题

很多开发者在接到“做一个在线答题/刷题功能”的需求时,第一反应是:这有什么难的?不就是展示题目、点击选项、最后算个分。

实际做起来会发现,真正的问题远不止这些。一次完整的在线测评,至少要回答下面几个问题:

  • 题库题目从哪里加载?前端写死,还是后端接口下发?
  • 每次进入考试的题目顺序是否一致?如果不一致,用什么方式打乱?
  • 用户答题中途刷新页面会不会丢进度?要不要自动保存?
  • 交卷后成绩存在哪里?错题记录如何保留?
  • 如果同时有很多人考试,怎么避免重复提交和成绩异常?
  • 前端直接暴露题库,怎么防止有人抓包把答案爬走?

这些看起来是“细节”的东西,恰恰决定了这个模块能不能上线、能不能维护、能不能扛住并发。

回到标题那句话:“科目一离开的太快就像龙卷风”。从产品体验来说,考试结束意味着一次练习的结束;但从技术系统角度来看,用户刚才产生的答题行为、时间消耗、错题数据、成绩记录,都应该被沉淀下来。哪怕界面瞬间关闭,数据依然有迹可循。这也正是“在线测评系统”和“一个普通表单页面”的本质区别:前者关注状态流转和数据留存,后者只关心当下提交。

这篇文章适合三类读者:

  • 刚入门的前端或全栈开发者,想了解答题类交互的完整实现路径。
  • 正在维护刷题类产品的开发者,想梳理出更好的架构和排查问题的方法。
  • 希望把“半成品Demo”升级成“可上线模块”的技术负责人。

接下来我们先用最小成本把系统跑起来,再逐步深入。

2. 基础概念与核心原理

在写代码之前,先理清几个在线考试系统里的基础概念。这些概念不限于“科目一模拟考试”,所有答题、测评、问卷类产品都会遇到。

2.1 题库(Question Bank)

题库是所有题目的集合。通常一条题目包含题干、选项、正确答案、解析、题型、难度等字段。在真实项目中,题库一般存储在数据库里,通过接口按需加载。但在演示阶段,可以把题库放在前端 JS 文件中,方便快速验证。

题库设计的关键不是“能放多少题”,而是“字段是否可扩展”。比如以后要增加多选、判断题、图片题、音频题,当初只设计了“答案”一个字段的项目,就要大改表结构。

2.2 抽题与乱序(Sampling and Shuffle)

模拟考试为了降低重复率,通常会做两件事:

  • 打乱题目顺序。
  • 有可能打乱选项顺序。

实现上最常用的是洗牌算法(Fisher-Yates Shuffle)。它的时间复杂度是 O(n),而且能保证每个排列出现的概率相等。

这里需要注意一个容易出错的地方:如果同时打乱题目和选项,那么“答案字段”必须同步跟着选项移动,否则会出现“选的是对的,但判分逻辑对不上”的问题。

2.3 判分(Scoring)

判分有两种常见方式:

  • 前端判分:简单直接,适合演示、局域网考试、低并发场景。
  • 后端判分:生产环境更推荐。用户的答案提交到服务端之后,由后端统一计算得分,避免前端代码被篡改导致分数异常。

前端判分并不是不能用,而是要清楚它的边界:它只适合“非监管型”场景。如果考试结果有公信力要求,后端判分是底线。

2.4 答题状态(Session State)

答题状态包括当前做到第几题、每一题选了哪个选项、哪些题被标记了、剩余时间是多少。这些状态可以是内存变量,也可以是持久化数据。

如果用户刷新页面,内存变量会全部丢失。为了体验更好,需要把“未完成的考试进度”保存到 localStorage 或者服务端 Session 中。

2.5 答题记录与错题本(Record and Wrong Question)

交卷之后,用户通常会看到成绩页,并且期望以后能重新查看错题。所以系统需要把“本次考试的答题快照”保存下来,而不是只保存一个总分。只保存总分的话,用户以后想复盘错题,什么都看不到。

下表总结了不同模块在演示版和生产版中的常见做法:

模块演示版做法生产版做法
题库存储前端 JS 文件数据库 + 接口缓存
抽题逻辑前端洗牌后端抽题 + 前端控制顺序
判分逻辑前端计算后端计算 + 审计日志
答题进度localStorage服务端 Session / Redis
成绩保存localStorage数据库 + 防重复提交
错题回顾保存题目快照错题表关联原题

这个对比清晰地说明了:一个“能用”的考试系统,和一个“能上线”的考试系统,差别不在 UI 多好看,而在数据安全、状态稳定和结果可信。

3. 环境准备与前置条件

为了让文章可以跟着操作,这里选择一套最轻量的环境。

3.1 运行环境

  • 操作系统:Windows 10/11、macOS、Linux 均可。
  • 浏览器:Chrome、Edge、Firefox 等现代浏览器。
  • 网络:需要能够访问 Vue 3 的 CDN 地址。

如果你的电脑上没有安装 Node.js,也不影响,因为我们的示例使用 Vue 3 的浏览器全局构建版本,直接在 HTML 文件中引入即可。

如果你希望使用 Vite 做工程化,可以提前装好 Node.js 18+,命令如下:

node -v npm -v

只要能输出版本号,就说明环境可用。但这不是本示例的前置条件。

3.2 项目文件结构

我们只需要两个文件:

question-one-exam/ ├── index.html └── question-bank.js

其中:

  • index.html:页面结构、样式、Vue 应用逻辑。
  • question-bank.js:模拟题库。

这样的结构足够简单,适合先跑通流程。生产项目再根据实际情况拆分目录。

3.3 引入方式说明

在index.html中,会先引入question-bank.js,让题库挂载到window.QUESTION_BANK,然后引入 Vue 3 的全局包:

<script src="./question-bank.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

这里要注意:question-bank.js必须放在 Vue 引入之前,否则应用初始化时题库还不存在。

4. 核心流程拆解

在线考试系统的业务流程并不复杂,拆开后其实只有六步。把这六步理顺,后面写代码就不会乱。

4.1 加载题库

应用启动时,读取window.QUESTION_BANK,得到题目数组。为了演示效果,我们会准备几条和“安全驾驶常识”相关的题目。题目本身故意选择通用性较强的表述,不作为科目一备考依据。

这一步容易出现的问题是:题库文件路径写错,或者引入顺序反了,导致页面提示“题目为空”。

4.2 初始化考试会话

点击“开始考试”后,系统需要创建一个新的考试会话。会话包含:

  • 打乱后的题目数组。
  • 当前题号。
  • 每道题的用户答案。
  • 标记数组,标记哪些题用户不确定。
  • 剩余时间。

会话初始化完成后,再进入答题界面。

4.3 渲染题目与选项

根据当前题号,从题目数组中取出对应的题目,渲染到页面。用户点击某个选项后,把该选项索引保存到答案数组。如果用户切换题目再切回来,答案应该还在。

这里的关键是:答案数组要和打乱后的题目索引一一对应,不能按原题号存储。

4.4 切换题目与标记

提供“上一题”“下一题”“标记本题”三个操作。标记功能看起来小,但对用户体验影响很大:用户可以在不确定的题目上打个记号,交卷前集中检查。

4.5 交卷与判分

用户点击“交卷”后,系统遍历所有题目,比较用户答案和正确答案,得出总分和错题列表。此时页面切换到结果视图,展示得分和逐题解析。

在生产系统中,交卷动作必须考虑“重复提交”问题。按钮点击后应立即禁用,同时通过接口幂等机制兜底。

4.6 保存记录与错题回顾

交卷后,把本次考试的成绩、答错题目、答题时间保存到 localStorage。以后用户可以查看历史成绩,也可以重新进入错题回顾。

如果只是用一个总分变量保存,刷新页面后历史记录就没了,这是很多初级实现容易忽略的点。

5. 完整示例与代码实现

下面直接给出完整示例。为了降低运行门槛,我使用“原生 HTML + Vue 3 CDN”的写法,文件结构简单,复制到本地就能运行。

5.1 题库文件

文件路径:question-one-exam/question-bank.js

window.QUESTION_BANK = [ { id: 1, type: "single", question: "驾驶过程中使用手机,以下哪种做法是正确的?", options: [ "查看导航并快速操作", "佩戴蓝牙耳机接打电话", "停在安全地点后再使用", "让副驾帮忙拿着,自己继续开车" ], answer: 2, explain: "驾驶中使用手机会分散注意力,必须停到安全地点后再使用。" }, { id: 2, type: "single", question: "车辆起步前,最合理的动作是什么?", options: [ "直接挂挡起步", "调整座椅、系好安全带、检查后视镜", "先按喇叭再起步", "踩油门再观察" ], answer: 1, explain: "起步前应先检查座椅、安全带和后视镜,这是安全驾驶的基本习惯。" }, { id: 3, type: "single", question: "车辆发生轻微剐蹭事故后,更稳妥的第一步做法是?", options: [ "让车辆保持原位,等待交警", "下车与对方争执", "拍照固定现场后尽快撤离并按规定处理", "直接驾车离开" ], answer: 2, explain: "轻微事故应先拍照固定现场,再按流程处理,避免引发交通拥堵。具体处理方式以官方规则为准。" }, { id: 4, type: "single", question: "雨天行车,最容易导致的风险是?", options: [ "制动距离变长", "发动机温度过低", "轮胎磨损加快", "油耗明显降低" ], answer: 0, explain: "雨天路面湿滑,轮胎抓地力下降,制动距离会变长,应降低车速并拉大跟车距离。" }, { id: 5, type: "single", question: "连续驾驶较长时间后,最正确的做法是?", options: [ "坚持开到目的地再休息", "进入服务区休息并适当活动", "喝大量咖啡继续行驶", "打开车窗继续驾驶" ], answer: 1, explain: "疲劳驾驶风险很高,正确做法是进入服务区休息,靠咖啡只能短暂提神,不能替代休息。" }, { id: 6, type: "single", question: "夜间行驶时,遇到对向车辆一直开着远光灯,怎么办?", options: [ "也打开远光灯回击", "减速并交替变换灯光提示对方", "靠边停车等对方通过", "加速超越前方车辆" ], answer: 1, explain: "夜间会车遇到对方未切换近光,应减速并交替变化灯光提示,不要斗气开远光。" }, { id: 7, type: "single", question: "驾驶机动车行经人行横道时,遇到行人正在通过,应该怎么做?", options: [ "鸣喇叭提醒行人让路", "减速让行", "从行人身后绕行", "加速通过" ], answer: 1, explain: "行经人行横道遇行人正在通过时,应当停车让行,这是安全原则。" }, { id: 8, type: "single", question: "关于安全带,下列说法正确的是?", options: [ "只有在高速公路上需要系", "后排乘客不需要系", "所有乘员都应按规定系好安全带", "市区低速行驶可以不系" ], answer: 2, explain: "无论前排后排,无论在什么道路行驶,都应按规定系好安全带。" } ];

这里特意把题目控制在 8 条,方便演示。实际项目中,题库应该由接口下发,并且配置一个合理的题量,比如 100 道题中随机抽取 50 道。

5.2 HTML 页面与 Vue 应用逻辑

文件路径:question-one-exam/index.html

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>科目一模拟考试系统(Vue 3 演示版)</title> <script src="./question-bank.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <style> * { box-sizing: border-box; } body { margin: 0; font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; background-color: #f3f5f9; color: #333; } .exam-box { max-width: 860px; margin: 32px auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); padding: 28px; } .exam-box h1 { margin-top: 0; font-size: 24px; text-align: center; } .meta { display: flex; justify-content: space-between; flex-wrap: wrap; padding: 12px 0; border-top: 1px solid #eee; border-bottom: 1px solid #eee; font-size: 14px; color: #666; } .question { margin: 24px 0; } .q-text { font-size: 18px; font-weight: 600; line-height: 1.6; } .options { margin-top: 16px; } .option { border: 1px solid #e1e4e8; border-radius: 8px; padding: 14px 16px; margin-bottom: 10px; cursor: pointer; transition: all 0.2s ease; } .option:hover { border-color: #4f8cff; background: #f6f9ff; } .option.selected { border-color: #4f8cff; background: #e8f0ff; color: #165dff; font-weight: 600; } .option-mark { display: inline-block; width: 24px; height: 24px; border-radius: 50%; background: #f0f2f5; text-align: center; line-height: 24px; font-size: 13px; color: #444; margin-right: 8px; } .option.selected .option-mark { background: #165dff; color: #fff; } .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } .actions button { border: 1px solid #ccd3dc; border-radius: 6px; padding: 8px 18px; background: #fff; cursor: pointer; font-size: 14px; } .actions button:hover { border-color: #165dff; color: #165dff; } .actions button:disabled { color: #aaa; cursor: not-allowed; border-color: #e1e4e8; } .result-panel { margin-top: 20px; } .result-panel h2 { text-align: center; color: #165dff; } .result-panel ul { list-style: none; padding: 0; } .result-panel li { border: 1px solid #e1e4e8; border-radius: 8px; padding: 14px; margin-bottom: 12px; } .result-panel li.wrong { border-color: #ff6b6b; background: #fff8f8; } .result-panel .explain { margin-top: 8px; color: #b54708; font-size: 14px; } .restart-btn { display: block; margin: 16px auto 0; border: none; border-radius: 6px; padding: 10px 32px; background: #165dff; color: #fff; font-size: 16px; cursor: pointer; } .mark-tip { color: #b54708; font-size: 13px; margin-left: 8px; } </style> </head> <body> <div id="app"> <div class="exam-box"> <h1>科目一模拟考试(演示版)</h1> <div class="meta"> <span>已答 {{ answeredCount }} / {{ questions.length }}</span> <span>剩余时间:{{ formattedTime }}</span> <span v-if="currentQuestion">第 {{ current + 1 }} / {{ questions.length }} 题</span> </div> <div v-if="!finished && currentQuestion"> <div class="question"> <div class="q-text"> {{ currentQuestion.question }} <span v-if="marked[current]" class="mark-tip">已标记</span> </div> <div class="options"> <div v-for="(opt, index) in currentQuestion.options" :key="index" class="option" :class="{ selected: answers[current] === index }" @click="chooseOption(index)" > <span class="option-mark">{{ optionChar(index) }}</span> {{ opt }} </div> </div> </div> <div class="actions"> <button @click="prev" :disabled="current === 0">上一题</button> <button @click="toggleMark">{{ marked[current] ? '取消标记' : '标记本题' }}</button> <button @click="next" :disabled="current === questions.length - 1">下一题</button> <button @click="submit">交卷</button> </div> </div> <div v-else-if="finished" class="result-panel"> <h2>最终得分:{{ score }} / {{ questions.length }}</h2> <p>错题数:{{ questions.length - score }}</p> <ul> <li v-for="(q, i) in questions" :key="i" :class="{ wrong: answers[i] !== q.answer }" > <strong>{{ i + 1 }}. {{ q.question }}</strong> <div>你的答案:{{ formatAnswer(answers[i]) }},正确答案:{{ formatAnswer(q.answer) }}</div> <div v-if="answers[i] !== q.answer" class="explain">解析:{{ q.explain }}</div> </li> </ul> <button class="restart-btn" @click="restart">再考一次</button> </div> <div v-else> <p>题库为空,请先检查 question-bank.js 文件。</p> </div> </div> </div> <script> const { createApp, ref, reactive, computed, onMounted, onUnmounted } = Vue; const STORAGE_KEY = "question-one-records"; const EXAM_SECONDS = 45 * 60; function shuffle(source) { const arr = source.slice(); for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } const app = createApp({ setup() { const questions = ref(shuffle(window.QUESTION_BANK || [])); const current = ref(0); const answers = ref([]); const marked = ref([]); const timeLeft = ref(EXAM_SECONDS); const finished = ref(false); const score = ref(0); let timer = null; const currentQuestion = computed(() => questions.value[current.value]); const answeredCount = computed(() => { return answers.value.filter((item) => item !== undefined).length; }); const formattedTime = computed(() => { const minutes = Math.floor(timeLeft.value / 60); const seconds = timeLeft.value % 60; return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; }); function optionChar(index) { return String.fromCharCode(65 + index); } function chooseOption(index) { if (finished.value) return; answers.value[current.value] = index; } function next() { if (current.value < questions.value.length - 1) { current.value++; } } function prev() { if (current.value > 0) { current.value--; } } function toggleMark() { marked.value[current.value] = !marked.value[current.value]; } function formatAnswer(answerIndex) { if (answerIndex === undefined || answerIndex === null) { return "未作答"; } const item = currentQuestion.value || questions.value[0]; return item.options[answerIndex]; } function calcScore() { let s = 0; questions.value.forEach((q, index) => { if (answers.value[index] === q.answer) { s++; } }); return s; } function saveRecord() { const wrongQuestions = questions.value .map((q, index) => ({ q, index })) .filter(({ index }) => answers.value[index] !== q.answer) .map(({ q, index }) => ({ question: q.question, options: q.options, userAnswer: answers.value[index], correctAnswer: q.answer, explain: q.explain })); const record = { time: new Date().toISOString(), score: score.value, total: questions.value.length, wrongCount: wrongQuestions.length, wrongQuestions: wrongQuestions }; const records = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); records.push(record); localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); } function submit() { if (finished.value) return; finished.value = true; score.value = calcScore(); saveRecord(); } function restart() { current.value = 0; answers.value = []; marked.value = []; finished.value = false; score.value = 0; timeLeft.value = EXAM_SECONDS; questions.value = shuffle(window.QUESTION_BANK || []); } onMounted(() => { timer = setInterval(() => { if (finished.value) return; if (timeLeft.value > 0) { timeLeft.value--; } else { submit(); } }, 1000); }); onUnmounted(() => { if (timer) { clearInterval(timer); } }); return { questions, current, answers, marked, timeLeft, finished, score, currentQuestion, answeredCount, formattedTime, optionChar, chooseOption, next, prev, toggleMark, formatAnswer, submit, restart }; } }); app.mount("#app"); </script> </body> </html>

这段代码里需要注意几个细节:

第一,formatAnswer函数在结果页渲染时,需要通过题目对象获取选项文本。但在我的实现里,formatAnswer使用了currentQuestion或questions.value[0]来取选项列表,这在结果页切换到不同题目时会取错。正确的做法是让formatAnswer接收题目和答案索引两个参数。

下面是修正后的写法:

function formatAnswer(question, answerIndex) { if (answerIndex === undefined || answerIndex === null) { return "未作答"; } return question.options[answerIndex]; }

模板中对应的位置也要改:

<div>你的答案:{{ formatAnswer(q, answers[i]) }},正确答案:{{ formatAnswer(q, q.answer) }}</div>

这是一个典型的“看起来顺眼但实际会取错上下文”的低级 bug。评审代码时尤其要留意这类函数,它们依赖了隐式的上下文状态,很容易在列表渲染中踩坑。

第二,question-bank.js使用window.QUESTION_BANK挂载全局变量。这样做的好处是简单,但只适合演示。真实项目中不要污染全局作用域,应该使用 ES Module 导入。

第三,页面上的计时器是 45 分钟倒计时。倒计时归零后自动交卷,这个逻辑本身没问题,但要小心定时器在页面隐藏时仍然运行导致的误差。真实系统应该以服务端时间或倒计时结束时间戳为准。

6. 运行结果与效果验证

完成以上两个文件后,在question-one-exam目录下启动任意静态服务器,或者在浏览器中直接双击index.html即可看到效果。如果直接在本地打开,没有任何跨域问题。

建议使用静态服务器方式,命令如下:

cd question-one-exam python3 -m http.server 8080

然后在浏览器访问:

http://localhost:8080

页面会显示“科目一模拟考试(演示版)”,左侧是答题进度,右侧是倒计时。点击选项后,选项会高亮。再点击“下一题”,可以继续作答。交卷后,页面显示总分、错题数,以及每道题你的答案和正确答案。

验证是否成功可以从三个层面确认:

6.1 功能层面

  • 所有题目可以正常展示。
  • 选择答案后,切换再切回来,答案仍然保留。
  • 交卷后得分正确。

6.2 数据层面

打开浏览器开发者工具,在 Console 中执行:

const records = JSON.parse(localStorage.getItem("question-one-records") || "[]"); console.log(records);

如果交卷过,这里可以看到一条完整记录,包含考试时间、得分、总题数、错题详情。

执行下面这条命令,可以确认记录结构是否完整:

const record = JSON.parse(localStorage.getItem("question-one-records") || "[]")[0]; console.log(record && record.score !== undefined && Array.isArray(record.wrongQuestions));

输出为true,说明记录已经被正确保存。

6.3 边界层面

  • 倒计时归零后是否自动交卷。
  • 最后一题作答后直接点交卷,会不会有未答题目。
  • 刷新页面后,已完成的记录是否还在。

如果刷新后未完成的考试进度丢失,这是预期内的,因为演示版本没有做暂存处理。如果你想保留未完成进度,可以在chooseOption中把answers同步写进 localStorage。

7. 常见问题与排查思路

不管多简单的示例,运行过程中都可能遇到问题。下面把最常见的几种情况列出来,方便对照排查。

问题现象可能原因排查方式解决方案
页面显示“题库为空”question-bank.js 文件路径错误,或者引入顺序反了打开 Network 面板,确认 question-bank.js 是否加载成功把<script src="./question-bank.js"></script>放在 Vue 之前
点击选项没有高亮Vue 未正常加载查看 Console 是否有 JS 报错检查 Vue CDN 地址是否可访问
交卷后错题列表里“你的答案”显示错乱formatAnswer 取错了题目上下文在 Console 中打印q和answers[i]把formatAnswer改成接收题目和答案两个参数
刷新后历史成绩丢失未做 localStorage 持久化在 Console 中执行 localStorage.getItem在交卷时保存记录,并在加载时读取本地记录
倒计时结束后没有自动交卷定时器被浏览器节流或者代码逻辑中断检查是否有多个定时器相互影响使用结束时间戳,通过setInterval统一计算剩余时间
题目顺序每次都不一样这是洗牌逻辑生效的表现,不是 bug对比相邻两次题目顺序如果希望固定试卷,可以不洗牌或使用随机种子

其中最容易踩坑的是“格式对但取错了对象”。很多同学写formatAnswer时,习惯把当前题目作为隐式全局,结果在结果页列表循环中,所有题目都显示第一题的选项。这种问题不会直接报错,非常难发现,最好的办法是写单元测试覆盖关键函数。

8. 最佳实践与工程建议

从演示版走向生产环境,下面这些建议可以直接减少返工。

8.1 题库不要写死在前端

演示版本为了快速跑通,把题库放在了 JS 文件里。生产环境如果这样做,有两个问题:

  • 前端包体积大,加载慢。
  • 所有答案都暴露在浏览器里,用户抓包就能拿到全部答案。

生产环境下,题库应存储在服务端数据库中,前端只请求当次考试需要的题目。即使题目本身属于公开题库,也不建议把所有答案一次性下发到客户端。

8.2 判分逻辑必须服务端完成

前端的判分只能作为即时反馈的辅助手段。如果考试结果需要作为凭证,务必将考生的最终答案、考试开始时间、结束时间、设备指纹等信息提交到服务端,由服务端完成判分和落库。前端禁用按钮只是第一层防护,接口层需要用全局唯一请求 ID 做幂等控制。

8.3 答题进度要有持久化方案

用户很可能在答题中途切走、刷新甚至关闭浏览器。为了让用户体验更好,可以把未完成考试进度保存到 localStorage 或服务端。localStorage 适合单设备,服务端 Session 适合多设备场景。休息后重新登录,还能继续上次没做完的题。

8.4 随机抽题要关注种子和可复现性

如果考试需要保证随机性,但又希望“同一场考试同一份试卷”,后端可以生成一个随机种子,根据种子生成同一套试卷。这样即使前端刷新,也能恢复同一份试卷;同时后端可以审计用户本次考试到底用了哪些题,结果才具有可解释性。

8.5 防作弊要放在架构层面

前端只能限制普通人不误点,真正的防作弊靠后端:

  • 服务端记录考试开始时间和交卷时间,判断用时是否异常。
  • 服务端下发题目时,记录第几秒显示了哪道题。
  • 检测页面可见性变化次数,记录切屏次数作为风控信号。
  • 用户答案提交后,校验答案长度、答案格式、提交频率是否合理。

8.6 数据记录要包含“快照”而不是“引用”

错题列表保存时,不要只保存题目 ID,否则题库更新后,历史错题的题干和解析就会对不上。应该把题目内容、选项、答案、解析整体保存为快照,让历史记录永久可回看。

8.7 代码拆分谨慎一点

演示版把数据和逻辑都放在 HTML 里,只是为了方便。真实项目应该至少拆成三个模块:

  • 数据层:仓库类或 API 封装。
  • 业务层:生成试卷、判分、记录管理。
  • 展示层:答题组件、进度组件、结果组件。

这样后续加题型、加排行榜、加多科目都不会把代码搅成一团。你可以先从 Vue 单文件组件开始拆分,把question-bank.js改成src/data/question-bank.ts,再把setup里的逻辑按 Composition API 的useExamSession抽出来。

8.8 保留必要日志

在线考试系统要记录关键流程日志,包括:

  • 用户进入考试。
  • 用户加载题目。
  • 用户交卷。
  • 用户重试。

日志的用途不只是排查 bug,更是还原用户行为、校验考试有效性的重要证据。

9. 总结与后续学习方向

如果你现在重新看开头那句“科目一离开的太快就像龙卷风”,会发现“快速离开”其实是表象。用户交卷后,系统产生的答题数据并不会离开,它被保存、被分析、被用来帮助后续复习。技术系统的价值,正是在这种“抽身即逝”的场景里把

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询