资讯动态

Vue+TS双端答题系统源码拆解:工程结构与组件化实践

发布时间:2026/9/15 0:38:54 来源:尧图企业网站定制
简介这是一份基于Vue框架的工人考试系统一体化答题前端源码面向企业技能培训、职业院校考核等在线答题场景适合有一定前端基础、希望上手完整项目的开发者使用。压缩包共58个文件、约1.35MB以17个TypeScript文件和11个Vue组件为核心搭配3个JSON配置、2个LESS样式和16张PNG图片等资源构成可运行的工程结构。系统支持WiFi手机与一体机在线答题包含题库展示、答题计时、选项反馈、结果统计等基础环节目录按api、components、router、views、config等模块划分并配有babel、vue.config、package.json等构建配置便于二次开发与部署。目前已有90人浏览学习对想理解Vue组件化开发、TypeScript类型约束及移动端适配的开发者是一份有参考价值的实践样例。1. 同一次考试手机和一体机同时答题这套 Vue 源码为什么值得拆同一场工人技能考核有人掏手机连 WiFi 扫码进页面有人站在考场一体机前直接点屏幕。如果工程里只有一套页面这两类终端总有一边会出事手机端按钮挤成一团一体机上字号小得看不清。这套源码的思路是在同一个 Vue TypeScript 工程里把views/phone和views/pc拆成两条视图链路核心答题逻辑全部沉淀到components层再靠interface/api.ts和mock目录把接口数据流打通。它适合正在做考试、培训、问卷类系统的前端直接参照抄结构也适合想从 JavaScript 迁移到 TypeScript 的团队看一套保留vue.config.js和 Babel 体系的工程如何引入类型约束而不至于重写。2. 先拆工程骨架双端路由、接口层与 mock 的边界划分2.1 目录结构里的分层信号打开压缩包先看src的顶层划分这套源码的分层约定很直白src ├── api / interface # 接口定义与请求封装 ├── components # 跨端复用组件Button、Toast、XButton ├── router # 路由入口按 phone / pc 拆模块 ├── views / phone # 手机 WiFi 答题页面 ├── views / pc # 一体机答题页面 ├── assets # less 变量、图片资源 ├── mock # 本地模拟接口数据 └── shims-vue.d.ts # TS 对 .vue 文件的类型声明依赖方向是单向的views依赖components与apicomponents不反向依赖页面mock只在开发期被vue.config.js加载。这种分层的好处是后续如果要接入真实后端只需要替换http.ts里的 baseURL页面和组件一行都不用动。shims-vue.d.ts的存在说明工程里已经为.vue模块补了类型声明否则 TypeScript 编译器会直接报“找不到模块”这也是多数 Vue TS 工程起步时第一个要处理的坑。2.2 接口层把请求收敛到 api.ts 一个文件src/interface/api.ts在源码里是接口定义的核心文件。常见做法是把它写成“每个接口对应一个带泛型的函数”让页面层感知不到请求细节// src/interface/api.ts import request from ./http; // 题目结构type 1 表示单选2 表示多选 export interface QuestionItem { id: number; type: 1 | 2; title: string; options: Array{ key: string; text: string }; } // 提交结果score 为得分isPass 是否通过 export interface PaperResult { score: number; isPass: boolean; } // 拉取试卷 export function fetchQuestions(paperId: string) { return request.getQuestionItem[](/exam/questions, { params: { paperId }, }); } // 提交答案 export function submitPaper(paperId: string, answers: Recordnumber, string[]) { return request.postPaperResult(/exam/submit, { paperId, answers, }); }这里有几个参数设计的细节。QuestionItem.type用1 | 2字面量联合类型而不是number是因为业务里只存在单选和多选两种形态字面量类型能在编译期拦截“填了 3 或其他非法值”的情况。submitPaper的answers用Recordnumber, string[]以题目 id 为 key值为数组——单选也存数组长度是 1多选存多个值。这样提交时不需要再区分单选还是多选后端拿到数组长度自己判断即可前端格式化逻辑更简单。http.ts里通常会把 axios 实例的拦截器封装好统一处理 token、超时和错误码。比如请求拦截器里从 localStorage 取 workerId 放进 header响应拦截器里对 code ! 0 的响应统一弹 Toast页面层只关心业务数据。2.3 mock 的接入方式不依赖后端也能跑通答题流程这套源码自带mock目录但没接真实服务开发期要让接口能通最简单的手段是在vue.config.js里用devServer.before注册中间件// vue.config.js module.exports { devServer: { port: 8080, before(app) { // 模拟拉取试卷接口 app.get(/api/exam/questions, (req, res) { res.json(require(./mock/questions.json)); }); // 模拟提交试卷接口固定返回通过 app.post(/api/exam/submit, (req, res) { res.json({ score: 95, isPass: true }); }); }, }, };devServer.before是 webpack-dev-server 在内部所有中间件之前注册钩子路径命中后直接返回本地 JSON不会发到外网。res.json(require(...))这种写法要求 mock 文件是 CommonJS 导出的对象如果mock/questions.json里是数组或带{ code, data }包裹结构注意看http.ts里的响应拦截器是怎么解包的。如果拦截器固定取response.data.data那么 mock 返回的 JSON 必须也带一层data包裹否则页面里拿到的会是 undefined这是 mock 与生产环境最容易出现行为不一致的地方。进入生产后把before整段删除即可接口请求自动走http.ts里配置的真实 baseURL不需要改任何业务代码。2.4 phone 与 pc 的共性与差异维度views/phoneviews/pc目标设备手机连接 WiFi 浏览器访问考场一体机触摸屏交互方式单指点击向上滑动浏览题目触摸点击按钮热区需要放大视觉密度单列选项一屏尽量只放一题大字号选项间距更大适配横向屏幕复用内容共用 components 下的答题面板、倒计时、提交按钮同左两套视图如果差异实在大可以各自维护页面布局但不要让同样的题目渲染逻辑在两处各写一遍。正确做法是把“题目 选项 选中状态”封装成QuestionPanelphone 用它时传小间距参数pc 用它时传大热区参数组件内部用 props 控制样式差异。3. 答题核心流程计时、选项交互与结果反馈的组件化实现3.1 组件拆分Button、Toast、XButton 各自的边界源码components下有Button、Toast、XButton等组件这三个在答题场景里的职责要分清楚。Button是通用按钮负责展示和点击反馈Toast是轻提示负责“提交成功”“答题卡未完成”这类非阻断反馈XButton从命名上看是带图标或更强调视觉状态的按钮适合用在“上一题 / 下一题”这类需要明确方向感的操作上。组件拆分的原则是一个组件只管理一种状态。Button管 loading 与 disabledToast管 show 与 messageXButton在Button之上扩展图标位。不要在页面里写一堆v-if控制提示展示也不要在按钮组件里混入倒计时逻辑。3.2 题目渲染与选项状态管理答题页核心组件可以设计成这样用props接收题目数据用v-model向外同步选中结果!-- components/QuestionPanel.vue -- template div classquestion-panel :class{ question-panel--pc: isPc } h3 classquestion-title{{ question.title }}/h3 div v-foropt in question.options :keyopt.key classoption-item :class{ option-item--checked: checkedKeys.includes(opt.key) } clicktoggleOption(opt.key) span classoption-icon{{ opt.key }}/span span classoption-text{{ opt.text }}/span /div /div /template script langts import { Component, Prop, Vue } from vue-property-decorator; Component export default class QuestionPanel extends Vue { Prop(Object) question!: { id: number; type: 1 | 2; title: string; options: Array{ key: string; text: string }; }; Prop(Boolean) isPc!: boolean; checkedKeys: string[] []; toggleOption(key: string) { if (this.question.type 1) { // 单选直接替换整个数组 this.checkedKeys [key]; } else { // 多选已选则移除未选则追加 const index this.checkedKeys.indexOf(key); if (index -1) { this.checkedKeys.splice(index, 1); } else { this.checkedKeys.push(key); } } this.$emit(change, this.question.id, this.checkedKeys); } } /script这里使用 Vue 2.x 规范配合vue-property-decorator的Prop写法与源码里shims-vue.d.ts及 TS 文件分布是吻合的。checkedKeys作为内部选中态由toggleOption统一修改并通过$emit抛给父层避免父页面直接改子组件内部数据造成数据流混乱。单选场景直接替换数组多选场景先查索引再决定是移除还是追加这是最常见的选项处理逻辑。isPcprop 控制的是选项热区大小PC 一体机上把padding加大到20px 24px字号提升到22px手机上保持紧凑布局。样式差异收敛在组件内部通过类名切换不在两个视图里各写一套。3.3 倒计时生命周期里最容易泄漏的 setInterval答题系统必须有倒计时到点自动交卷。这个组件最容易出问题的地方是路由切换后定时器还在跑或者页面销毁时没有清理导致多次提交。!-- components/CountdownTimer.vue -- script langts import { Component, Prop, Watch, Vue } from vue-property-decorator; Component export default class CountdownTimer extends Vue { Prop(Number) totalSeconds!: number; // 考试总时长单位秒 Prop(Boolean) paused!: boolean; // 是否暂停 remain 0; timer: number | null null; created() { this.remain this.totalSeconds; this.start(); } start() { if (this.timer) return; // 每秒钟递减一次剩余时间 this.timer window.setInterval(() { this.remain - 1; if (this.remain 0) { this.stop(); this.$emit(timeout); // 触发自动交卷 } }, 1000); } stop() { if (this.timer) { window.clearInterval(this.timer); this.timer null; } } Watch(paused) onPausedChange(val: boolean) { val ? this.stop() : this.start(); } beforeDestroy() { this.stop(); } } /script关键点是timer用window.setInterval并保存句柄stop里clearInterval之后必须把timer置回null否则start里的if (this.timer) return会导致无法重启。Watch(paused)用于考试中途暂停场景比如切到后台自动暂停倒计时。beforeDestroy里调用stop保证路由跳走时定时器被清理这是防止内存泄漏和重复提交的底线。实际项目里还有一层细节倒计时误差会随运行时间累积因为setInterval每 1000ms 回调一次但每次回调实际执行可能有几毫秒偏差。考试系统对时间要求严格时我一般会记录endTime Date.now() totalSeconds * 1000每次回调用Math.ceil((endTime - Date.now()) / 1000)计算剩余这样无论 JS 线程被阻塞多久显示的时间都不会漂移。3.4 结果反馈图片资源与答题状态的映射源码里有icon-checked.png、icon-uncheck.png、round_wrong.png、round_right.png这组图片资源它们是答题结果的视觉反馈。命名约定非常明确资源文件名使用场景状态含义icon-checked.png选项被选中时的图标已勾选icon-uncheck.png选项未选中时的图标未勾选round_right.png交卷后回看回答正确的题目正确round_wrong.png交卷后回看回答错误的题目错误选项选中状态和交卷后结果状态是两套体系不要混用。答题过程中选项右侧显示icon-checked与icon-uncheck交卷后结果页里每题状态用round_right和round_wrong区分。把这组图片放在assets/images下组件里通过相对路径引用配合v-if或类名切换即可。4. 数据流与状态组织从选中答案到提交试卷的完整链路4.1 答题状态应该放在页面级还是全局这套源码没有直接引入 Vuex 一类的全局状态库答题场景的状态放在页面级是合理选择。原因是答题数据生命周期很短进入考试页面创建交卷后立即消费不需要跨页面共享。如果强行放进全局 Store反而要在组件销毁时手动清理稍不注意就会出现“上个考生的答案带入下个考生”的严重 bug。页面级组织方式是在views/phone/exam.vue和views/pc/exam.vue里各自维护一个answerMap// 以题目 id 为 key值为选中选项的 key 数组 answerMap: Recordnumber, string[] {};QuestionPanel的change事件触发时页面层只需做一件事onAnswerChange(questionId: number, keys: string[]) { this.answerMap[questionId] keys; }不复制、不深拷贝、不额外维护一个answeredCount。需要计算已答题目数时直接Object.keys(this.answerMap).length即可。组件内部的状态变更通过事件冒泡到页面层页面层是唯一数据源。4.2 提交链路答案组装、校验与异常处理提交动作通常发生在两个时机考生主动点击“交卷”以及倒计时归零触发timeout事件。两个入口最终汇聚到同一个handleSubmit方法async handleSubmit(isForce false) { // 未答完时弹确认框强制交卷时不提示 const total this.questions.length; const answered Object.keys(this.answerMap).length; if (!isForce answered total) { const ok await this.$confirm(还有 ${total - answered} 题未作答确认交卷); if (!ok) return; } try { this.submitting true; // 组装提交数据结构 const payload: Recordnumber, string[] {}; for (const item of this.questions) { payload[item.id] this.answerMap[item.id] || []; } const result await submitPaper(this.paperId, payload); this.$router.replace({ path: /result, query: { score: String(result.score) }, }); } catch (err) { this.$toast(提交失败请检查网络后重试); } finally { this.submitting false; } }组装 payload 时用this.questions遍历而不是直接拿answerMap是为了保证每题都在提交数据里存在未答的题补空数组。后端拿到数据后按数组长度判断对错逻辑更干净也方便记录漏答题。isForce参数区分主动交卷和自动交卷自动交卷时不再弹确认框直接提交。try / catch / finally三段的职责要分清try里只放业务逻辑catch里给用户明确反馈finally里复位提交按钮状态。这里用$toast而不是$alert因为“提交失败”是频发但可重试的提示弹窗会阻断操作Toast 更合适。4.3 WiFi 手机与一体机的网络差异化参数手机通过 WiFi 访问和一体机访问网络稳定性差别很大。一体机接线稳定手机 WiFi 可能出现弱信号、漫游切换。这套源码里 phone 场景需要在http.ts里配置更长的超时时间并做好请求重试// src/interface/http.ts 中的部分配置 const http axios.create({ baseURL: /api, timeout: 15000, // 弱网环境下放宽到 15s retry: 2, // 失败重试次数 retryDelay: 1000, // 重试间隔 1s });timeout15 秒对答题系统足够因为题目数据量小正常网络下接口响应都在 500ms 以内。如果给到 30 秒以上考生在弱网环境下会误以为系统卡死而反复点击交卷反而造成重复提交。重试要配合接口幂等设计也就是说后端要能识别同一考生同一试卷的重复提交请求通常用workerId paperId做唯一键。5. 从源码到可运行工程依赖安装、构建配置与打包排错5.1 安装依赖与本地启动拿到源码后先在根目录执行依赖安装项目根目录有package.json、package-lock.json和vue.config.js说明这是一个标准的 Vue CLI 工程# 安装依赖使用 package-lock.json 锁定版本 npm install # 本地开发默认端口 8080 npm run servepackage-lock.json存在就优先用npm ci而不是npm installnpm ci会严格按照锁文件安装避免依赖版本漂移导致的行为差异。启动后如果端口被占用在vue.config.js里修改devServer.port即可或者启动时加--port 9090参数。5.2 打包后布局异常的排查技巧很多 Vue 工程开发环境正常npm run build部署后就出现图片路径 404、样式错乱这类问题大多出在publicPath配置上。部署到服务器子目录时需要调整vue.config.jsmodule.exports { // 静态资源路径按部署目录配置 publicPath: ./, outputDir: dist, };publicPath: ./会让构建产物里的资源引用变成相对路径适合部署在任意子路径下。但如果你的应用使用vue-router的history模式相对路径会导致刷新页面 404这时应改用hash模式或把publicPath设为绝对路径。这两个配置是一对改一个就要同步检查另一个。布局异常的另一类原因是 less 变量的全局注入。源码里有assets/var.less存放颜色、圆角、间距变量如果某些组件里直接写死了颜色值而全局主题通过vue.config.js的css.loaderOptions.less注入变量两边就会出现色差。排查方法是全局搜索代码里的硬编码颜色#前缀统一替换为var.less中声明的变量。这样一体机大屏和手机端的视觉风格才完全一致。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价