基于Vue3的一体化答题系统设计:状态管理与离线答题实战
2026/9/14 18:26:40 网站建设 项目流程

简介:这是一套基于Vue框架的工人考试系统一体化答题设计源码,面向企业培训、职业技能鉴定以及需要搭建在线答题场景的前端开发者,可在WiFi手机或一体机上运行,用于完成工人岗前考核、安全知识竞赛等日常技能测评。整套资源共58个文件,压缩包仅1.35MB,以17个TypeScript、11个Vue组件和16个PNG图片为主,另含3个JSON配置、2个LESS样式及JavaScript、HTML、CSS等文件,覆盖组件化页面、接口与工具封装、样式定制、构建配置与静态资源管理。当前已有90人学习下载。通过学习这套源码,可以快速搭建包含答题、计时、结果反馈、异常选项提示等功能的考试系统原型,理解Vue单页应用与TypeScript类型系统在实际项目中的协作方式;同时其按组件、页面、接口、配置分层的目录结构,便于后续替换题库、调整移动端样式和二次扩展。对希望掌握工程化前端项目组织方式的中高级开发者而言,这套源码既是可运行的实战模板,也是研究组件拆分与类型约束的参考案例。

1. 从工人考试系统到Vue一体化答题设计:为什么这类项目被反复重写

工人考试系统和普通在线考试系统最大的区别,不在于题目多难,而在于使用场景和用户画像非常特殊:大部分考场在车间旁、项目部或培训教室里,网络时好时坏,考试终端可能是老旧电脑、平板甚至触屏一体机,考生年龄跨度大、对键盘操作不熟练。如果你只是在网上找一个开源的考试系统改改,多半会在真实考场上翻车——网络一抖,答题卡全丢;考生误触刷新,整卷作废;管理员导出成绩还要手动对Excel。

一体化答题设计的核心,是把“发卷、答题、倒计时、交卷、评阅、留痕”这六件事在同一个前端流程里闭环处理。基于Vue框架来做,是因为Vue的双向绑定和组件化特别适合这类“状态多、交互密、校验杂”的表单密集型场景:一道题是一个组件,一组题是一个状态切片,倒计时的时钟和自动交卷可以完全解耦到独立模块。这套设计跑通之后,源码里真正值钱的不是界面长什么样,而是答题状态的管理方式、异常恢复策略和评阅数据的组织方式。适合谁看?适合要用Vue从零搭一个考试前端、或者在现成脚手架里塞入答题模块的开发者,也适合需要把离线答题、断点续答、自动评分做成标准方案的技术负责人。

2. 答题系统的Vue选型与项目架构设计

2.1 渐进式框架的边界:什么场景用Vue而不是React或原生

选Vue框架做考试系统,不是因为Vue比React好,而是因为考试系统对“渐进式嵌入”有天然需求。很多工人考试项目不是绿地开发,而是要在已有的培训管理系统里插入一个考试模块。Vue渐进式框架的定位允许你只在考页面引入Vue构建答题器,也可以直接用Vite搭建完整工程,然后在老系统里通过iframe或微前端挂载,这种平滑接入的体验是React和Angular做不到的。

不过要注意,Vue 2到Vue 3的选型在2025年已经不是选择题了。新开发的考试系统建议直接上Vue 3 + Composition API + Vite,原因有两点:一是组合式函数(composables)对答题倒计时、题目状态恢复这类有副作用且需要复用的逻辑非常友好;二是Vue 3的Proxy响应式系统在处理深层嵌套的答题数据结构时性能更好。你的工人考试系统如果还在用Vue 2的Options API写大对象加watch,到后期改交卷逻辑和异常恢复时,光是找出哪个watcher在改form数据就够你查一天。

// Vue 3 中把一道题的处理抽成组合式函数 export function useQuestionOption(questionId, questionType) { const selected = ref([]) // 单选存字符串,多选存数组 const answered = computed(() => selected.value.length > 0) function toggleOption(optionValue) { if (questionType === 'single') { selected.value = [optionValue] // 单选覆盖 } else { // 多选时先判断是否存在再决定推入或剔除 const index = selected.value.indexOf(optionValue) index > -1 ? selected.value.splice(index, 1) : selected.value.push(optionValue) } persistAnswer(questionId, selected.value) } return { selected, answered, toggleOption } }

这段代码解决的问题是:答题这个动作和具体组件解耦。useQuestionOption被单选题组件调用时,它的questionTypesingle;被多选题组件调用时传multiple。难点在persistAnswer这个持久化函数,它决定了答题记录是存内存、本地存储还是后端接口,这直接关系到一体化答题系统的“不断线”能力。参数上要注意splicepush涉及Vue 3的响应式追踪,数组索引变化能被响应式系统监听到,但如果你是Vue 2用户,这里就必须要Vue.set来触发更新。

2.2 一体化答题设计的目录结构与路由规划

一体化设计首先要解决的,是路由散乱导致的“答着答着找不到北”。很多初学者把考试系统做成single-choicemultiple-choicejudge三个独立路由,同一份试卷答单选题时跳到/exam/single,答多选时又跳到/exam/multiple,一次考试用户要在路由间跳五六次。这是典型的把系统按题型组织而不是按考试流程组织。一体化的思路是:一次考试就一个路由,题型通过组件内部按questionType分发渲染。

路由路径页面职责关键参数
/exam/:examId答题主页面,承载题目渲染、倒计时、答题卡examId考试实例ID
/exam/:examId/record交卷后的成绩单与答题解析recordId答卷记录ID
/manage/exam后台试卷配置页面

这样一个路由的设计,让整个答题过程都集中在exam/:examId这一棵组件树里。考试倒计时、答题进度、题目跳转这些全局状态放在父级页面,每道题只负责自己的渲染,切题时只切题目组件,不切路由,这样做的最大好处是:刷新页面时,Vue Router不会把你踢到第一题,而是能从答题记录中恢复到当前答题位置。

const router = createRouter({ history: createWebHistory(), routes: [ { path: '/exam/:examId', name: 'ExamRunner', component: () => import('@/views/exam/ExamRunner.vue'), props: true } ], scrollBehavior(to, from, savedPosition) { // 切换到下一题时保持顶部定位,避免视觉跳动 return savedPosition || { top: 0 } } })

这个路由设计里的关键参数是examId,它不只是考试ID,还关联着当前考生的唯一考试记录。ExamRunner.vue挂载时会调用startExam(examId)这个action,如果该考生已有未提交的答题记录,就恢复到记录中的题目索引和已答数据。你在实际项目中还要给这个路由加上beforeEach守卫,检查考生是否已经交卷,如果已交卷则直接重定向到成绩单路由,防止考生通过浏览器的后退按钮重返考场。

2.3 数据模型先行:题库、试卷、答题记录的字段设计

一体化答题设计最容易被忽略但坑最多的部分,是数据结构。我见过太多项目先写组件后建表,结果到交卷评阅的时候发现:单选题存string、多选题存数组、判断题存boolean,三种题型三种存储格式,评阅时要把三种格式各自写一遍解析逻辑。正确做法是统一答题存储格式——不管什么题型,都存一个数组。

// 统一的答题记录结构 const answerRecord = { examId: 20240601, userId: 3456, startTime: '2025-06-01 09:00:00', submitTime: '', questionCount: 80, answerSheet: [ { questionId: 'single_001', questionType: 'single', userAnswer: ['A'], // 统一为数组,单选也存数组 isCorrect: true, // 是否需要前端预判?后端评阅更可靠 score: 1, duration: 38 // 单题用时,计入防作弊参考 } ] }

这里的userAnswer统一为数组,带来一个直接的开发效率提升:单选题组件出答案时userAnswer = ['A'],多选题userAnswer = ['A', 'C', 'E'],判断题userAnswer = ['true']。评阅服务收到数据后只需要写一套比较逻辑:数组长度一致、元素逐个匹配即得分。而不需要针对每种题型写单独的评分分支。还有一个实际问题是isCorrect字段——前端预判对与错,可以让考生交卷后立即看到解析,但正式考试的标准做法是后端重新评阅后再返回成绩,前端预判的isCorrect只作为UI上的临时提示,不能被信任。

3. 用Vue实现一体化答题器的核心代码

3.1 题目渲染组件:单选题、多选题、判断题的通用schema

题目渲染是考试系统最核心的展示逻辑。使用Vue框架的好处是,可以用一个QuestionCard组件通过动态组件或v-if分支来渲染三种题型,而不需要为单选题、多选题、判断题分别维护三个几乎一模一样的页面。用一个schema描述题目:题目文本、选项列表、正确选项、解析,前端根据questionType决定用户交互方式。

<template> <div class="question-card"> <div class="question-stem">{{ currentIndex + 1 }}. {{ question.stem }}</div> <div v-if="question.type === 'single'" class="option-list"> <div v-for="(opt, idx) in question.options" :key="opt.key" class="option-item" :class="{ active: selectedAnswer.includes(opt.key) }" @click="onSingleSelect(opt.key)" > <span class="option-label">{{ String.fromCharCode(65 + idx) }}</span> <span class="option-text">{{ opt.text }}</span> </div> </div> <div v-else-if="question.type === 'multiple'" class="option-list"> <div v-for="(opt, idx) in question.options" :key="opt.key" class="option-item" :class="{ active: selectedAnswer.includes(opt.key) }" @click="onMultipleToggle(opt.key)" > <span class="option-label">{{ String.fromCharCode(65 + idx) }}</span> <span class="option-text">{{ opt.text }}</span> </div> </div> <div v-else class="judge-options"> <button :class="{ active: selectedAnswer.includes('true') }" @click="onJudgeSelect('true')">正确</button> <button :class="{ active: selectedAnswer.includes('false') }" @click="onJudgeSelect('false')">错误</button> </div> </div> </template>

这段模板逻辑并不复杂,关键在于事件处理器里的状态管理:onSingleSelect覆盖答案,onMultipleToggle推入或剔除答案,onJudgeSelect同样以数组形式存储。三套交互逻辑共享同一个selectedAnswer数组,这个数组是经由propsExamRunner页面传入的,配合前面提到的useQuestionOption组合式函数,组件的渲染和状态就完全隔离了。至于为什么不用v-model直接绑数据——因为考试场景里,题目切换后的状态回退、交卷锁定、查看解析时的只读态,都需要对变量做额外的控制逻辑,直接v-model会让这些控制变得很难加。

3.2 答题状态管理:pinia store中的实时保存

当你在考场上遇到考生答了70道题然后突然断电,重启后打开系统发现答题记录一条没丢时,你会感谢当初在pinia里做了实时保存。一体化答题设计的“一体化”重点,就是答题状态要被统一管理、随时可持久化、刷新可恢复。Pinia是Vue生态里目前最合理的答案,它比Vuex更轻,而且对TypeScript的支持更好——工人考试系统如果涉及多套试卷、不同题型数量配置,TypeScript能帮你提前发现字段类型不匹配的问题。

export const useExamStore = defineStore('exam', { state: () => ({ currentExam: null, currentIndex: 0, answerSheet: [], timeRemaining: 3600, examStatus: 'in_progress' // in_progress | submitted | expired }), actions: { async initExam(examId) { // 从接口或本地缓存获取考试数据 this.currentExam = await examApi.fetchExamDetail(examId) await this.loadLocalRecord(examId) }, setAnswer(questionId, answerArray) { const found = this.answerSheet.find(item => item.questionId === questionId) if (found) { found.userAnswer = answerArray } else { this.answerSheet.push({ questionId, userAnswer: answerArray }) } // 每次同步到localStorage,防宕机丢数据 this.persistLocalRecord() }, async submitExam() { this.examStatus = 'submitted' await examApi.submitAnswer(this.currentExam.id, this.answerSheet) this.clearLocalRecord() } } })

persistLocalRecord是这个store的命脉。它把当前的答题记录、题目索引剩余时间序列化为JSON存入localStorage,key设计为exam_{examId}_user_{userId},这样即使用户参加多次考试,不同考试间的缓存也不会互相覆盖。这里有一个关键细节:答题记录存入localStorage时,要同时把currentIndex存进去,这样断电重启后考生能回到断电前正在看的那道题。而examStatus字段也很关键——如果submitted状态被写入了localStorage,说明这场考试已经交卷,即便用户刷新页面,也应看到成绩单而不是答题页。

3.3 倒计时与交卷逻辑:避免常见的定时器泄漏

考试系统的倒计时是个看似简单实则翻车率最高的功能。很多Vue开发者喜欢在setInterval中每秒修改剩余时间,结果组件销毁时忘记清理定时器,导致同一个页面打开了两次,两个定时器同时跑,倒计时的速度变成双倍。更糟的情况是在路由跳转后定时器没有销毁,页面已经离开考场,倒计时还在后台跑,到点后自动触发了交卷接口。

let countdownTimer = null function startCountdown(duration) { store.timeRemaining = duration countdownTimer = setInterval(() => { if (store.timeRemaining <= 0) { clearInterval(countdownTimer) store.submitExam() // 时间到,自动交卷 return } store.timeRemaining-- }, 1000) } onMounted(() => { startCountdown(store.timeRemaining) }) onUnmounted(() => { if (countdownTimer) { clearInterval(countdownTimer) countdownTimer = null } })

该做的是在onUnmounted中清理定时器。另外建议启动倒计时时先读取localStorage中存储的timeRemaininglastHeartbeat时间戳,用公式timeRemaining = storedTime - (Date.now() - lastHeartbeat) / 1000来计算剩余时间。为什么?因为setInterval在浏览器标签页被切到后台时会自动节流,最小间隔可能从1秒变成1分钟,定时器回调次数骤降,倒计时就会有误差。用绝对时间戳计算,不管定时器被节流到什么程度,剩余时间都能算准。最后再把lastHeartbeat更新为当前时间戳,写入localStorage——这样即使浏览器崩溃,重新打开后也能继续从正确的剩余时间恢复。

4. 一体化设计落地:接口约定与本地持久化

4.1 API封装与axios拦截器:把token和错误码兜住

前端框架只负责界面和交互,真正的一体化还需要和后端接口约定一个稳定的契约。在工人考试系统里,最常见的接口场景是:登录拿token、拉取试卷、提交答卷、获取成绩。用axios封装统一请求模块,在拦截器里做两件事:自动携带token、统一处理HTTP错误码和业务错误码。

const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('exam_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data // 业务码约定:code=0成功,code=4011表示token过期 if (res.code !== 0) { if (res.code === 4011) { localStorage.removeItem('exam_token') router.push('/login') } return Promise.reject(new Error(res.message || '请求失败')) } return res }, error => { // 网络错误时,返回可识别的错误标记给调用方做离线处理 return Promise.reject({ isNetworkError: true }) } )

成功拦截器的两个参数要注意:一是res.code,这是后端约定的业务状态码,和HTTP状态码无关,即使HTTP状态是200,业务code也可能表示业务失败;二是超时时间设为15秒,因为考场网络不稳定,时间太短会频繁失败,太长又会拖垮整个交卷操作。网络错误的处理逻辑返回了isNetworkError标记,这样在答题页里捕获到错误后可以提示考生“当前网络不可用,答题进度已保存”,而不是弹出一个让人摸不着头脑的“系统异常”。

4.2 断网与断点续答:localStorage + indexedDB的双层策略

一体化答题设计最硬的骨头是离线答题。工人考试系统使用的场景中,二三十人同时在一个车间改造后的临时考场里用手机或平板考试,4G信号可能不稳定。此时不能让考生因为断网而中断考试,更不能丢答题记录。常见的做法是localStorage与indexedDB双管齐下:localStorage存小数据(题目索引、剩余时间、状态标记),indexedDB存大数据(答题明细、题目快照)。

存储载体用途容量限制异步特性
localStorage考试状态、当前索引、最近填写的一条答案约5MB同步
indexedDB完整答题记录、试卷快照数百MB异步
Vuex/pinia内存态数据,供组件实时绑定同步

答题时优先写入内存store,同时在watch到答题变化后防抖2秒写入localStorage;每完成5道题,将整个answerSheet以事务方式写入indexedDB。断网时axios请求直接抛出自定义的网络错误,调用方捕获后不弹错误框,只把同步队列里的API请求缓存起来。联网后通过navigator.onLine事件触发一个flush操作,把缓存的答卷提交到服务器。这里最容易踩的坑是:断网后修改了localStorage的答案,但indexedDB里的旧数据还保留着,等交卷时直接读了indexedDB的旧快照,导致部分答案丢失。解决方案是先读localStorage判断有没有更新的状态标记,如果有则用localStorage的数据覆盖快照再提交。

4.3 解析对照与成绩单生成:交卷后的最后一步

交卷后的一体化流程,要能立即展示客观题得分、答案解析和合格判定。这一步如果做得好,考务人员当场就能给出成绩单,工人也能马上看到错题解析,对安全意识培训的效果直接体现在流程里。通常的做法是提交后先读本地答案,同时向服务端请求标准答案,如接口允许,一步取回总分和每一题的判分结果。

async function generateResultSheet(recordId) { const detail = await examApi.fetchResult(recordId) // 解析一个标准的结果结构 const sheet = { totalScore: detail.totalScore, passMark: detail.passMark, passed: detail.totalScore >= detail.passMark, questionResults: detail.questionResults.map(q => ({ questionId: q.questionId, userAnswer: q.userAnswer, correctAnswer: q.correctAnswer, isCorrect: q.isCorrect, analysis: q.analysis })) } return sheet }

这里的passMark要从服务端下发,而不是前端写死60分,因为不同工种的合格线可能不一样,比如特种作业操作证考试通常是80分合格。通过generateResultSheet返回的结构,前端在一个只读的ResultSheet组件中渲染:上部显示总分和大大的“合格/不合格”印章,下方是逐题的对错列表,点击展开可查看解析。注意不要在交卷后就把localStorage中的考试数据清空——至少保留一份本次考试的答题记录用于本地留痕,直到成绩单成功获取后再执行清理。

5. 考场实战中的5个小技巧:从调试到打包

5.1 用Vue DevTools快速定位答题状态异常

答题器在考场上出问题时,通常是状态错乱:考生选了B但界面上高亮的是A,或者交卷后score字段变成0。这时先打开Vue DevTools插件,在Components面板中选中出错的题目组件,检查它的props传入的selectedAnswer和组件的data是否一致。如果props中有两个不同来源的状态,比如一个来自pinia,一个来自组件的本地data,那八成是你在交卷时读了组件本地状态而不是store中的全局状态。记住一个原则:题目渲染组件的内部永远不要维护一个独立副本,所有数据都从store的answerSheet读取,组件里只允许$emit事件,不允许直接改数据。

5.2 打包后布局异常的排查路径

考场的终端分辨率千奇百怪,有1024x768的旧电脑,也有1920x1080的宽屏一体机。一体化答题器的布局在开发环境下正常,npm run build后部署到nginx出现布局错乱,最常见的原因是CSS中使用了相对单位的组件库没有按比例压缩。遇到这种情况,不要一上来就改代码,先用chrome的device toolbar模拟800x600、1024x768、1366x768几种分辨率,逐个对比画面。考试系统的布局通常分为左侧题目区、右侧答题卡两个区域,在打包后如果是左右区域重叠了,检查一下是不是某个弹性布局的flex: 1设置被压缩掉了。另外检查打包后的index.html中css和js的引用路径是否绝对路径——base配置错误会导致静态资源加载失败,样式文件根本没生效,布局自然全乱。

5.3 快速验证倒计时时长的两个工具函数

验证倒计时的准确性不能真等一小时,建议在开发模式下给倒计时加一个系数参数。在store的state中设置一个timeScale,默认值为1,开发时设为60,模拟1分钟当1小时过。

const scale = import.meta.env.DEV ? 60 : 1 setInterval(() => { store.timeRemaining = Math.max(0, store.timeRemaining - scale) }, 1000)

这样你可以用真实的1分钟模拟1小时的考试,验证自动交卷逻辑、时间到时的弹窗、最终的提交动作。这个方法没有什么成本,但在考前部署的验收中能帮你省下不少等待时间。另外一个验证点是清除localStorage后重新进入页面,看题目序号是否从第1题开始、剩余时间是否被重置为试卷总时长。

5.4 考前压力测试的压测脚本要点

考试系统上线前,最好用脚本模拟并发交卷。不需要真实浏览器,用简单的Node脚本直接调用你的后端交卷接口,重点关注两个指标:交卷接口的响应延迟和服务器内存占用。常见做法是准备一份200人的模拟考试数据,在30秒内全部提交,观察有没有超时或丢包。前端侧也很简单——打开20个浏览器标签页同时进入同一场考试,看localStorage的读写是否产生冲突。

5.5 历史考试数据的导出与迁移

工人考试系统的数据往往需要保留至少三年备查。交卷之后,数据不只存在于后端数据库,在前端localStorage和indexedDB中也留有缓存。日常维护时在管理端加一个“本地存档导出”的功能,定期把本地缓存打包成JSON或CSV文件,跨浏览器迁移时直接导入存档文件,就能恢复包括每道题用时、修改痕迹在内的完整考试数据。这样一来,即便后端服务意外丢失数据,本地留存的一体化答题记录也能作为完整的考务凭证。

本文还有配套的精品资源,点击获取

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

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

立即咨询