Vue一体化答题设计:从数据模型到断网恢复的完整实践
2026/9/14 14:23:22 网站建设 项目流程

简介:一套基于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。

答题快照则是另一个结构:它只记录考生与试卷产生的动态数据,题目内容本身不放进快照,否则一份快照动辄几百 KB,localStorage 很快就接近容量上限。

// 答题快照:只存会变化的东西 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负责倒计时、答题卡、整体布局;题型层SingleChoiceQuestionMultipleChoiceQuestionJudgeQuestionShortAnswerQuestion只负责把一道题渲染成可交互控件;内部子组件最多出现“选项列表”这一类可复用单元,再往下拆就是过度设计了。

组件名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 分工:路由只保会话,答题状态进 store

3.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 class="answer-card"> <div class="card-grid" :style="{ gridTemplateColumns: `repeat(${perRow}, 1fr)` }"> <button v-for="(q, i) in questions" :key="q.id" type="button" :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) } } </script>

cardClass里的优先级是设计重点:is-marked的样式要压在is-answered之上,因为考生标记的题目通常是“已答但不确定”,视觉上必须同时看到已答底纹和标记角标。答题卡不感知题目 type,cardClass不用为多选题、简答题写特殊分支,因为快照层只关心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是否写成相对路径./AnswerCardgridTemplateColumns有没有在低版本浏览器被降级成单列。最后推荐一个验证手法:用 Vue DevTools 的 Pinia 面板观察snapshot.answers的字段变化,然后切到浏览器离线模式刷新,看快照恢复后答题卡是否还原到断网点。再配合 Performance 面板记录一次完整作答的回放性能,把首次交互到把答案写进快照的延迟压到 100ms 以内,这条链路跑通,交付的源码才算真正可用。

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

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

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

立即咨询