简介一套基于Vue框架的工人考试系统一体化答题设计源码专为有在线答题需求的企业、培训机构及前端开发者准备。系统采用组件化加单页面应用架构结合TypeScript强类型特性覆盖题目展示、计时、答案反馈、手机端与一体机自适应等完整业务流程可直接部署用于工人职业技能提升考核。压缩包共58个文件包含17个TypeScript逻辑文件、11个Vue组件文件、16个PNG界面图片、2个LESS样式文件以及Babel、Vue CLI、Package等工程化配置整体大小仅1.35MB目录按API、组件、视图、配置等模块拆分层次清楚。当前已有90人学习下载项目既提供了一套可直接运行的前端方案也展示了Vue与TypeScript协同开发、组件复用、响应式布局等实践思路适合用作用户端答题系统的改造基底也是学习现代前端工程化的不错参考。1. 工人考试系统把答题做成一体化页面Vue 是最稳的落地框架工人技能等级认证、安规考试、班组培训考核有个常被忽视的共性答题终端多半是车间退役的旧主机、触控一体机、无网络的瘦客户端。系统做成多页面跳转考生误触刷新、断网重连、离座后页面过期根因都在状态没在同一视图内收敛。一体化答题设计正是把题目区、答题卡、倒计时、交卷动作放进同一个路由下的同屏结构从开考到交卷只经历一次页面生命周期。Vue 在这里的价值有三点渐进式框架可以先带答题卡裸跑起来后续接 Pinia、Router 都顺理成章响应式数据适合“已答、未答、标记、倒计时”这类强联动界面单文件组件让按题型拆分的代码边界清晰培训部门二次维护不需要重新理解整个工程。2. 一体化答题设计先立数据模型再拆 Vue 组件树2.1 题目数据结构一次定清楚答题快照才不用回炉很多 Vue 在线答题项目是从组件往数据方向反推的先写了选择题组件再补多选题组件最后发现单选题、多选题、判断题要各自维护一套 answer 格式答题卡统计已答数量要写三个分支。正确顺序反一反先把一条题目记录和一份答题快照的数据结构定下来再让所有题型组件去适配它。我一般的做法是把一道题的运行态模型固定成这样// 题型组件消费的核心数据模型 const questionRecord { id: q_sec_023, // 题目全局唯一 id paperIndex: 3, // 试卷内顺序 type: single, // single | multiple | judge | short bankId: bk_2024_security, // 所属题库用于后续统计分析 content: 高处作业时安全带应遵循什么原则, options: [ { key: A, text: 高挂低用 }, { key: B, text: 低挂高用 }, { key: C, text: 平挂平用 } ], answer: A, // 考生作答初始化 null isMarked: false, // 标记存疑交卷前再次确认 timeCost: 0 // 本题耗时毫秒 }这段结构里有三个字段是工人考试场景特有的paperIndex解决题库抽题后题目乱序展示的需求isMarked对应考生不确定、先跳过回头再看的真实操作习惯timeCost在交卷后随答题快照一并上报培训管理员可以统计出哪些题平均耗时异常。type的取值建议做成枚举而不是自由字符串否则后面做题型组件分发时又要写一坨 if。答题快照则是另一个结构它只记录考生与试卷产生的动态数据题目内容本身不放进快照否则一份快照动辄几百 KBlocalStorage 很快就接近容量上限。// 答题快照只存会变化的东西 const examSnapshot { examId: exam_2024_017, sessionId: sess_8f3a2c1e, version: 1, startAt: 1735689600000, endTime: 1735693200000, // 开考时间 考试时长 answers: { q_sec_023: { value: A, isMarked: true, timeCost: 42000, updatedAt: 1735690020000 } }, currentIndex: 3, status: ongoing // ongoing | submitted | expired }考生每作一次答只更新answers里对应该题目 id 的记录snapshot.version递增一。交卷后上报服务端时后台只要比对version就能判断客户端是否拿了一份过期的快照在重复提交。2.2 按题型拆子组件时props/emit 的边界要足够窄数据结构定好后组件树可以拆到三层容器层ExamView负责倒计时、答题卡、整体布局题型层SingleChoiceQuestion、MultipleChoiceQuestion、JudgeQuestion、ShortAnswerQuestion只负责把一道题渲染成可交互控件内部子组件最多出现“选项列表”这一类可复用单元再往下拆就是过度设计了。组件名props 入参对外 emit 事件内部状态SingleChoiceQuestionquestion, valuechange无MultipleChoiceQuestionquestion, valuechange临时选中未确认前JudgeQuestionquestion, valuechange无ShortAnswerQuestionquestion, valuechange, input输入防抖计时OptionListoptions, selectedKeysselect无props/emit 的边界要遵守一条原则子组件不能调用任何 store 方法也不能感知自己当前装在哪个试卷里。它收到的value是父级从快照中解析出的已答值要改动时只派发change事件由容器层统一写回 store。这样四种题型组件全部是纯受控组件换试卷、换题库、甚至同时挂两个考试会话组件都不用改。多选题在交互上和单选题有一个关键差异单选题点选即确认多选题常见做法是点选后进入“已选中”高亮态换题时未确认的临时选择要丢弃而不是写进快照。这个逻辑如果放在子组件内部换题重挂组件后临时态会丢得干净如果放容器层则要在watch里手动清理容易留下脏数据。2.3 已答数量、进度百分比、未答列表全部用 computed 派生一体化页面最频繁的 UI 联动是答题卡上的状态点答题数、剩余题数、标记数。这些值不应该在 store 里维护因为它们是answers的派生数据。常见错误是在保存答案后手动维护answeredCount字段多选改单选、撤销答案、导入历史快照时必然出现统计不一致。import { computed } from vue import { useExamStore } from /stores/exam const examStore useExamStore() const answeredCount computed(() { const snapshot examStore.currentSnapshot return Object.values(snapshot.answers) .filter(a a.value ! null String(a.value).length 0) .length }) const progressPercent computed(() { return Math.round((answeredCount.value / examStore.questionTotal) * 100) }) const markedList computed(() { const ids [] for (const [qid, answer] of Object.entries(examStore.currentSnapshot.answers)) { if (answer.isMarked) ids.push(qid) } return ids })三个 computed 全部以examStore.currentSnapshot为唯一数据源。判断题的value如果存数字 0/1直接用if (a.value)判断会把 0 误判成未答所以统一用String(a.value).length 0兜底。答题卡头部显示“已答 23 / 50剩余 27 题”这里的 27 不要自己写50 - 23再派生一个remainedCount computed(() examStore.questionTotal - answeredCount.value)防止题库抽题总数与试卷展示总数不一致时进度条溢出。3. Vue Router 与 Pinia 分工路由只保会话答题状态进 store3.1 路由设计把题目排除在 URL 之外深链只到会话级一体化答题设计下路由不应该承载题目维度。在 Vue Router 里为每一道题单独配/exam/1/question/3这样的路径刷新后还要按路由参数去恢复 store属于把服务端多页应用的思维搬到了 SPA 里增加恢复逻辑的出错面。正确做法是路由只区分考生入口和考试会话两个层面。// router/index.js const routes [ { path: /, name: home, component: () import(/views/HomeView.vue), meta: { requiresAuth: false } }, { path: /exam/:examId/session/:sessionId, name: exam, component: () import(/views/ExamView.vue), meta: { requiresAuth: true } }, { path: /result/:sessionId, name: result, component: () import(/views/ResultView.vue) } ]/exam/:examId/session/:sessionId是一体化页面的路由底线。examId是试卷规格sessionId是本次考试的唯一会话。组件内按sessionId去服务端拉取该会话的答题快照断网重连、误刷新后都是先恢复快照再渲染整页而不是重走开考逻辑。路由守卫在这里拦一种情况考生已交卷但 URL 仍停留在/exam/...守卫里查 store 的status submitted后直接 redirect 到/result/:sessionId避免交卷后还能通过回退按钮回到答题页继续作答。3.2 Pinia 里考试 store 只做三件事快照存取、答案写入、会话生命周期项目里我会把所有答题状态收敛到一个 Pinia store命名空间职责锁定在“考试会话”。不要拆成 examStore、answerStore、timerStore 三个考试会话的状态迁移是单链的跨 store 同步反而要维护更多事件。// stores/exam.js import { defineStore } from pinia import { loadExamPaper, submitSnapshot } from /api/exam import { restoreFromLocal, clearLocal, persistSnapshot } from /utils/persist export const useExamStore defineStore(exam, { state: () ({ examId: null, sessionId: null, questionList: [], // 试卷题目 snapshot: null, // 答题快照 currentIndex: 0, // 当前题号索引 timeRemaining: 0, status: idle // idle | loading | ongoing | submitted }), actions: { async initSession(examId, sessionId) { this.examId examId this.sessionId sessionId this.status loading const { data } await loadExamPaper(examId) this.questionList data.questions this.snapshot restoreFromLocal(sessionId) || this.defaultSnapshot(examId, sessionId) this.status ongoing this.timeRemaining this.snapshot.endTime - Date.now() }, saveAnswer(questionId, value, isMarked false) { const exists this.questionList.some(q q.id questionId) if (!exists) return this.snapshot.answers[questionId] { value, isMarked, timeCost: this.snapshot.answers[questionId]?.timeCost ?? 0, updatedAt: Date.now() } this.snapshot.version 1 persistSnapshot(this.sessionId, this.snapshot) }, jumpTo(index) { if (index 0 index this.questionList.length) { this.currentIndex index } }, async submitExam() { if (this.status submitted) return this.status submitted await submitSnapshot(this.examId, this.sessionId, this.snapshot) clearLocal(this.sessionId) } } })每个 action 的职责边界刻意收敛initSession负责会话级恢复优先读本地快照而不是后端数据断网点恢复直接决定考生能不能续答saveAnswer内检查questionId是否存在于questionList防止答题卡快速连点时把不存在的题目写进快照submitExam在状态置为submitted之后才发请求UI 层先锁死交卷按钮避免重复提交。3.3 答题快照持久化sessionStorage 为主IndexedDB 兜底工人考试场景的断网率比其他在线考试高一个量级答题快照不能只躺在内存里。三种存储方案的选型逻辑用表可以看得很清楚存储方案容量同步/异步会话失效时机适合用途sessionStorage约 5MB同步标签页关闭当前一场考试的临时恢复localStorage约 5MB同步手动清理考试参数、样式偏好等静态配置IndexedDB数百 MB异步手动清理历史快照、离线同步队列一体化答题页面采纳的组合是sessionStorage 存“正在进行的考试快照”localStorage 存“历史场次列表”的元信息不包含答题内容。这样考生在同一浏览器开多个标签页分别考不同科目快照不会互相覆盖。已有历史场次需要保留时再用 IndexedDB 做离线队列考试提交失败时把快照塞进去网络恢复后逐条上传。// utils/persist.js import { debounce } from lodash-es const sessionKey (sessionId) exam_session_${sessionId} export const persistSnapshot debounce((sessionId, snapshot) { try { sessionStorage.setItem(sessionKey(sessionId), JSON.stringify(snapshot)) } catch (err) { console.warn([persist] 快照写入失败可能是容量超限, err) } }, 300)不要每次点击选项都同步写 sessionStorage高频点击下JSON.stringify一次可能几毫秒累计起来会卡 UI。用 300ms 防抖包裹写入操作电量丢失的最坏时间窗口在 300ms 内UI 上不会产生可感知的卡顿。恢复读出的快照要记得做version校验读到旧版本结构时直接丢弃并重建快照否则answers字段缺失会在答题卡渲染时报 TypeError。4. 答题卡、倒计时与交卷校验的一体化联动4.1 答题卡组件用计算属性收敛状态样式不搞全局事件答题卡是一体化页面上最需要和 store 保持单向数据流的组件。它接受题目列表和一个“判断第 i 题状态”的输入逐格渲染自身不持有“当前选中题号”的状态高亮逻辑一律通过currentIndex i比较得来。这样答题卡可以做到换题库、换试卷都不换组件。template div classanswer-card div classcard-grid :style{ gridTemplateColumns: repeat(${perRow}, 1fr) } button v-for(q, i) in questions :keyq.id typebutton :class[card-item, cardClass(q, i)] click$emit(jump, i) {{ i 1 }} /button /div /div /template script setup import { computed } from vue const props defineProps({ questions: { type: Array, required: true }, snapshot: { type: Object, required: true }, currentIndex: { type: Number, default: 0 }, perRow: { type: Number, default: 5 } }) const emit defineEmits([jump]) const answeredIds computed(() { return new Set( Object.entries(props.snapshot.answers) .filter(([, a]) a.value ! null a.value ! undefined) .map(([qid]) qid) ) }) const markedIds computed(() { return new Set( Object.entries(props.snapshot.answers) .filter(([, a]) a.isMarked) .map(([qid]) qid) ) }) function cardClass(q, index) { return { is-current: index props.currentIndex, is-answered: answeredIds.value.has(q.id), is-marked: markedIds.value.has(q.id) } } /scriptcardClass里的优先级是设计重点is-marked的样式要压在is-answered之上因为考生标记的题目通常是“已答但不确定”视觉上必须同时看到已答底纹和标记角标。答题卡不感知题目 typecardClass不用为多选题、简答题写特殊分支因为快照层只关心value是否填充。答题格的点击事件统一派发jump容器层负责调用examStore.jumpTo(i)组件间不再有跨层通信。4.2 倒计时用 setInterval 漂移校正交卷边界条件要锁两层倒计时的第一直觉是每秒减一但浏览器标签页挂起时setInterval回调会被冻结恢复后计时器一次性补偿执行倒计时瞬间跳变。正确做法是以“墙上时间”为准每个刷新周期算一次“目标结束时间 - 当前时间”不依赖计数器累减。let phaseTimer null function startCountdown(endTimestamp) { stopCountdown() const tick () { const remain endTimestamp - Date.now() examStore.timeRemaining remain 0 ? Math.floor(remain / 1000) : 0 if (remain 0) { stopCountdown() autoSubmit() } } tick() phaseTimer setInterval(tick, 250) } function stopCountdown() { if (phaseTimer) { clearInterval(phaseTimer) phaseTimer null } } onMounted(() startCountdown(examStore.snapshot.endTime)) onBeforeUnmount(() stopCountdown())计时周期取 250ms 而不是 1000ms因为setInterval本身有任务队列堆积一秒周期下恢复后可能一次跳过两秒250ms 周期能将显示误差压到一个刷新帧内。交卷边界锁两层第一层是timeRemaining 0时不再允许作答第二层是提交接口校验服务端时间防止考生把本机系统时间回拨来延长考试。锁第二层要求提交接口入参带上endTime和当前时间戳服务端用它和开考时间做三方比对慢一两分钟内的偏差用宽限值处理超过直接拒绝。4.3 交卷校验在快照层拦截未答题清单必须能点交卷前的校验常见做法是写在submitExam方法里先检查未答和标记题目再弹确认。这里容易漏掉的是“校验通过后才允许提交”的反馈链路——一次点击里校验、弹窗、发请求、跳转全做完用户并不知道自己哪些题没答。我把校验拆成返回结构化结果的独立函数UI 层拿到结果后再决定展示什么。// utils/validator.js export function findUnanswered(snapshot, questions) { const answeredIds new Set(Object.keys(snapshot.answers)) return questions .filter(q !answeredIds.has(q.id)) .map(q ({ paperIndex: q.paperIndex, type: q.type, contentPreview: q.content.slice(0, 30) })) } export function findMarked(snapshot, questions) { return Object.entries(snapshot.answers) .filter(([, record]) record.isMarked) .map(([qid, record]) { const q questions.find(item item.id qid) return q ? { paperIndex: q.paperIndex, contentPreview: q.content.slice(0, 30) } : null }) .filter(Boolean) }交卷对话框展示“有 3 题未答、2 题标记存疑”时清单里的paperIndex让考生可以直接点击跳回对应位置补答。弹窗里渲染findUnanswered的返回值点击行调用examStore.jumpTo(paperIndex)并关闭弹窗。这里有个易混点paperIndex来自试卷排序的展示序号jumpTo接收的却是数组下标两者在乱序抽题时不一定相等所以跳转要用questions.findIndex(q q.paperIndex idx)转换一次再调用。5. 源码交付时的组织方式、构建优化和状态回放验证工人考试系统交付时“源码”不是把整个工程压个包扔给对方就行。可维护的交付形态至少要保证新人接到工程后能从目录命名直接判断某段逻辑属于哪一层api只管请求stores只管状态views只管页面组装components里只放与业务解耦的答题控件。src/ ├── api/ │ └── exam.js # 试卷拉取、快照上报、成绩查询 ├── stores/ │ └── exam.js # 考试会话 store ├── components/ │ ├── exam/ │ │ ├── SingleChoiceQuestion.vue │ │ ├── MultipleChoiceQuestion.vue │ │ ├── JudgeQuestion.vue │ │ ├── ShortAnswerQuestion.vue │ │ └── AnswerCard.vue │ └── common/ │ └── ConfirmDialog.vue ├── views/ │ ├── HomeView.vue │ ├── ExamView.vue │ └── ResultView.vue ├── utils/ │ ├── persist.js # 快照持久化与恢复 │ └── validator.js # 未答题、标记题校验 └── router/ └── index.js构建阶段有一个坑常常出现在内网部署、带宽有限的场景默认打包把所有依赖打进一个index.js首屏超过 1MB触控一体机的老浏览器加载完还要解析半天。用 Vite 的build.rollupOptions.output.manualChunks按依赖分块第一屏只加载框架和业务主包。// vite.config.js export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vue: [vue, vue-router, pinia], vendor: [lodash-es, axios] } } } } })把 vue 全家桶单独拆成vuechunk 之后浏览器缓存会让第二次开考不再重复下载框架。换到旧浏览器上如果出现打包后布局异常优先检查三处CSS 有没有被 postcss 插件降级、publicPath是否写成相对路径./、AnswerCard的gridTemplateColumns有没有在低版本浏览器被降级成单列。最后推荐一个验证手法用 Vue DevTools 的 Pinia 面板观察snapshot.answers的字段变化然后切到浏览器离线模式刷新看快照恢复后答题卡是否还原到断网点。再配合 Performance 面板记录一次完整作答的回放性能把首次交互到把答案写进快照的延迟压到 100ms 以内这条链路跑通交付的源码才算真正可用。本文还有配套的精品资源点击获取
