ARTICLE DETAIL

资讯详情

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

基于Vue 3搭建科目一模拟考试系统:题库、判分与状态管理全解析

基于Vue 3搭建科目一模拟考试系统:题库、判分与状态管理全解析 “科目一离开的太快就像龙卷风”我会想你的——这句话最近经常出现在驾考相关的讨论里。考试当天紧张地刷完题目交卷电子屏幕上弹出“合格”然后一切就像被风卷走一样迅速结束。这段经历很多人都经历过但站在技术开发者的角度看“科目一”不只是驾考中的一个科目它代表了一类非常典型的产品场景题库管理、随机抽题、在线答题、自动判分、错题回顾。这类场景开发门槛不高但想做得稳定、顺手、抗造并不像简单写一个静态页面那么容易。这篇文章先从一个最小可用的“科目一模拟考试系统”说起使用 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 srchttps://unpkg.com/vue3/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.jswindow.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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title科目一模拟考试系统Vue 3 演示版/title script src./question-bank.js/script script srchttps://unpkg.com/vue3/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 idapp div classexam-box h1科目一模拟考试演示版/h1 div classmeta span已答 {{ answeredCount }} / {{ questions.length }}/span span剩余时间{{ formattedTime }}/span span v-ifcurrentQuestion第 {{ current 1 }} / {{ questions.length }} 题/span /div div v-if!finished currentQuestion div classquestion div classq-text {{ currentQuestion.question }} span v-ifmarked[current] classmark-tip已标记/span /div div classoptions div v-for(opt, index) in currentQuestion.options :keyindex classoption :class{ selected: answers[current] index } clickchooseOption(index) span classoption-mark{{ optionChar(index) }}/span {{ opt }} /div /div /div div classactions button clickprev :disabledcurrent 0上一题/button button clicktoggleMark{{ marked[current] ? 取消标记 : 标记本题 }}/button button clicknext :disabledcurrent questions.length - 1下一题/button button clicksubmit交卷/button /div /div div v-else-iffinished classresult-panel h2最终得分{{ score }} / {{ questions.length }}/h2 p错题数{{ questions.length - score }}/p ul li v-for(q, i) in questions :keyi :class{ wrong: answers[i] ! q.answer } strong{{ i 1 }}. {{ q.question }}/strong div你的答案{{ formatAnswer(answers[i]) }}正确答案{{ formatAnswer(q.answer) }}/div div v-ifanswers[i] ! q.answer classexplain解析{{ q.explain }}/div /li /ul button classrestart-btn clickrestart再考一次/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. 总结与后续学习方向如果你现在重新看开头那句“科目一离开的太快就像龙卷风”会发现“快速离开”其实是表象。用户交卷后系统产生的答题数据并不会离开它被保存、被分析、被用来帮助后续复习。技术系统的价值正是在这种“抽身即逝”的场景里把
返回列表