
前阵子刚把手头这套 vue 教学辅助系统从零搭到上线前后大概三周。系统的目标很直接让老师能在线管课程、发作业、批改、发布成绩让学生能看课件、交作业、在线考试、查成绩管理员则在后台统一维护用户和课程数据。整体前端全部用 Vue 实现配合了一套轻量的后端接口服务做成前后端分离的项目。这文章我尽量把设计思路、代码结构、核心功能的实现方式都讲透也会把开发过程中踩过的坑一起列出来给要做类似教务类系统的朋友一个参考。1. 系统整体设计三个角色三条线先讲需求拆解。做教学辅助系统最大的坑就是一开始把功能堆得太散。老师要管课程、学生要交作业、管理员要维护系统这三类人的操作习惯和权限范围完全不一样如果不在设计阶段把边界划清楚等代码写到中后期再调整前后端都要大改。1.1 角色边界与功能划分我按用户角色把系统拆成了三条线。学生端的功能核心是“查”和“交”查看已选课程、查看课程下的课件资料、提交作业、参加在线考试、查看自己的成绩和评语、查看系统公告。学生不碰任何管理操作所以界面入口只保留和自己相关的部分。教师端的功能核心是“管”创建课程、维护课程资料、发布和批改作业、创建考试并设置题目、录入或导入学习成绩、发布班级公告。老师的操作密度最高界面需要有工作台概念比如“待批改作业数”这种提示能明显提升使用体验。管理员端的职责更偏向系统维护管理教师和学生账号、处理用户选课关系、查看系统运行情况和操作日志。这套系统里管理员不直接干涉教学业务只负责“搭台子”让老师和学生在台上唱戏。功能边界确定之后前端路由和菜单结构就跟着定了下来每个角色登录后只加载自己的路由表菜单也只渲染有权限的项。这个设计在后端接口层面也能做对应校验前端只是第一道控制不是唯一防线。1.2 技术选型为什么最终选了这套组合技术栈选型上前端部分我定了 Vue 3 Vite Pinia Vue Router 4 Element Plus Axios后端用一个简单的 Node 服务提供接口。为什么选 Vue 3这个项目需要用到组合式 API 来组织代码特别是考试计时、表单联动这类逻辑用 Composition API 写起来比 Options API 清晰很多Vue 3 的响应式系统对数据量较大的表格渲染也更友好。选 Vite 而不是 Webpack主要是开发体验上的差距启动速度快、热更新及时对迭代频繁的项目来说省下来的时间非常可观。状态管理选了 Pinia。Vuex 4 虽然也能配合 Vue 3 用但 Pinia 更轻、类型友好而且它的 store 写法更接近普通函数新同学上手快。UI 库直接用 Element Plus教学系统里大量用到表格、表单、日期选择器、上传组件这些都用 Element Plus 现成的没必要自己造轮子。图表展示选了 ECharts成绩分布、选课人数统计这些可视化需求用它做最省力。配套的后端接口不算复杂用 Node 的轻量框架搭了 RESTful API前端通过 Axios 统一请求。数据库用的是 MySQL课程、用户、作业、成绩这几张核心表外键关系清晰适合这种结构化数据场景。2. 工程化落地目录结构与请求层设计技术选型定了之后就要把工程结构搭起来。我见过很多前端项目刚开始文件少随便放等组件多了之后找文件全靠搜索改一处可能要翻好几个目录。这个项目一开始就按功能模块来组织目录后期扩展没有乱掉。2.1 目录规划按功能模块切分而不是按技术类型切分初期规划目录时我特意没有采用那种“所有组件放一起、所有页面放一起”的简单分工而是按业务模块来划分。大概结构如下src/ api/ # 接口定义按模块拆文件 assets/ # 静态资源 components/ # 公共组件 hooks/ # 组合式函数 layouts/ # 布局组件 router/ # 路由配置 stores/ # Pinia store utils/ # 工具函数 views/ # 页面级组件 teacher/ student/ admin/views 下面按角色分目录每个角色内部再按业务功能建子目录比如views/teacher/course、views/teacher/exam。这样找页面的时候路径非常直观不会出现一个页面藏在某个神秘目录里的情况。api 目录我也做了同样的拆分。以课程接口为例src/api/ course.js exam.js assignment.js user.js dashboard.js每个api文件只导出自己模块的接口函数页面组件里只引入需要的部分。这样做的好处是后端接口一旦调整只需要改一个文件搜索范围很小。2.2 Axios 封装与接口管理Axios 封装是每个前端项目都绕不开的事。我这里的封装主要解决三个问题统一配置请求头、统一处理 token、统一处理错误。import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( (response) { const res response.data // 约定后端返回 { code, message, data } 结构 if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default service这里有个容易被忽略的细节响应拦截器返回的是res.data而不是整个 response 对象。这样业务代码里接接口返回值的时候拿到的是干净的数据部分不需要每一层都.data.data这么套着写。baseURL 我用的是环境变量VITE_API_BASE_URL这样本地开发环境指向 mock 服务部署到测试或生产环境时只需要改环境配置文件不用动代码。这个习惯建议所有项目都保持哪怕是个人项目也省得换环境时到处改字符串。2.3 状态管理用 Pinia 还是 Vuex这个项目用 Pinia 主要是因为两个 store 的场景用户信息和全局配置。用户信息 store 保存当前登录用户的基本信息、角色、权限点列表。登录成功后一次性拉取存到 store 里各页面直接从 store 读取不用重复请求。退出登录时清空 store 和 localStorage并跳转到登录页。全局配置 store 用来保存系统名称、主题色这类业务无关的配置。这类数据放 Pinia 里其实有点“大材小用”但比在 localStorage 里散着存更容易维护。至于考试答题数据我一开始想过放 store后来发现反而更复杂。考试页面的答题状态是页面局部的组件内部维护足够一旦刷新页面store 也保护不了数据还是得靠本地缓存恢复。这个场景在后面“踩坑实录”部分会细说。3. 核心功能模块实现细节前端框架搭好接下来就是实打实的功能模块。我挑几个最典型的说一下实现方式和当时的考虑。3.1 权限控制路由守卫与按钮级指令权限控制分为两个层级页面级的控制靠路由守卫按钮级的控制靠自定义指令。页面级权限的常规做法是登录后从后端拿到当前用户的角色和权限点然后在前端动态生成路由表。我的做法是写一份全量路由表其中部分路由带有meta.roles标记然后在路由守卫里做拦截router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) { if (to.path /login) return next() return next(/login) } const userStore useUserStore() if (!userStore.roles.length) { // 尝试拉取用户信息 userStore.fetchUserInfo().then(() { if (hasPermission(to, userStore)) { next() } else { next(/403) } }) return } if (hasPermission(to, userStore)) { next() } else { next(/403) } })这里有一个非常关键的细节刷新页面时用户信息是空的不能直接放行必须先拉取用户信息再判断权限。如果不做这一步刷新之后路由守卫会误判为无权限把用户踢回首页或 403 页面。按钮级权限我用了一个自定义指令const vPermission { mounted(el, binding) { const userStore useUserStore() const requiredPerm binding.value if (!userStore.permissions.includes(requiredPerm)) { el.parentNode?.removeChild(el) } } }模板里这样用el-button v-permissioncourse:create新建课程/el-button指令方式对开发体验很友好不用在每个页面里写显式的条件判断。需要注意的是这种指令只在元素初次挂载时执行一次如果权限列表是异步拉取的必须确保按钮渲染时用户权限已经加载完成否则会把有权限的按钮也删掉。3.2 在线考试模块计时与防切屏设计在线考试是这套系统里最复杂的模块没有之一。它涉及计时、提交、异常处理三个难点。计时这块最直接的方案是用setInterval每秒更新剩余时间。但这里有两个坑一是页面被切到后台时浏览器的定时器会降频甚至暂停等用户回来时剩余时间可能已经不准二是用户如果刷新页面内存里的计时器就断了。我的做法是把考试开始时间和结束时间存到 localStorage 里用时间差实时计算剩余时间而不是简单依赖setInterval累加。const getRemainingTime () { const endTime Number(localStorage.getItem(exam_end_time)) if (!endTime) return 0 const remain Math.floor((endTime - Date.now()) / 1000) return Math.max(remain, 0) }setInterval只负责每秒触发一次状态更新真正的时间计算基于时间戳这样即使浏览器卡几秒也不会出现时间错乱。防切屏的设计上我实现了两个基础动作监听visibilitychange事件页面隐藏时记录一次切换到桌面的事件但这里要注意不能做得太激进否则正常查看弹窗提示也会被误判为切屏。我的策略是只记录事件不直接禁止考试等考试结束后由教师判断是否参考异常记录。关于自动交卷我的逻辑是在剩余时间为 0 时自动调用一次提交接口并把本地缓存的答题数据一起传过去防止定时器被浏览器挂起导致交卷动作延误。这个兜底逻辑在实测中很有用出现过几次定时器被挂了但数据还在的情况。3.3 成绩统计与可视化教师端需要看成绩分布图、课程选课人数趋势、作业提交率。这些可视化我用 ECharts 实现图表组件封装成一个通用容器。封装思路是做一个BaseChart.vue组件接收option作为 prop内部在 watch 到 option 变化时调用 setOption 更新图表同时处理窗口 resize 的重新渲染。template div refchartRef classchart-container/div /template script setup import * as echarts from echarts import { ref, onMounted, onBeforeUnmount, watch } from vue const props defineProps({ option: { type: Object, required: true } }) const chartRef ref(null) let chartInstance null onMounted(() { chartInstance echarts.init(chartRef.value) chartInstance.setOption(props.option) }) watch(() props.option, (newOption) { chartInstance?.setOption(newOption) }, { deep: true }) onBeforeUnmount(() { chartInstance?.dispose() chartInstance null }) /script这里有个细节如果直接用setOption更新数据旧数据会残留图表里经常出现叠加层。需要先clear()再setOption或者保证 option 里的系列数据是完整替换的。我习惯在更新时先chartInstance.clear()再setOption省得排查那些诡异的重叠问题。图表数据来源是后端聚合接口。比如成绩分布后端返回每个分数段的学生人数数组前端直接映射成柱状图数据。不要把聚合逻辑写在前端否则接口数据量一大页面渲染时计算卡顿体验很差。3.4 通知公告与个人中心的一些细节除了核心的课程、考试、作业模块系统里还有通知公告和个人中心这类“边角功能”。这些功能虽然代码量不大但影响使用体验的地方不少。通知公告模块我做成列表 详情页的形式。列表页展示公告标题、发布时间、发布人详情页展示正文。有个细节是公告如果有置顶功能后端排序时要把置顶字段作为第一排序条件前端不需要做额外处理。编辑公告时要注意发布时间的显示时区问题我们的后端统一存的是时间戳前端展示时要格式化成服务器所在时区不然老师和学生看到的时间对不上这种问题很隐蔽。个人中心里最常见的需求是修改密码和更新头像。修改密码时前端要做两次密码一致性校验并且不能明文传到后端。头像上传用 Element Plus 的 Upload 组件上传成功后需要把接口返回的 URL 回填到表单中而不是直接使用本地临时路径。4. 开发中踩过的坑与排查实录这部分我整理一下自己开发过程中真实踩过的坑有些问题查了很久才定位到原因直接列出来希望能帮大家省点时间。4.1 动态路由刷新后 404 的问题这是权限路由方案中最经典的问题。路由表是登录后动态生成的但刷新页面时动态路由还没来得及 addRoute用户访问的路径就不在路由表中结果被/404兜底捕获。我当时的处理方式是在路由守卫里做判断如果当前路径不是 404 页且用户已登录但动态路由还没添加就重新拉取用户信息和路由表next({ ...to, replace: true })重新进入一次路由解析。if (!router.hasRoute(to.name)) { await userStore.fetchUserInfo() await generateRoutes() next({ ...to, replace: true }) } else { next() }核心思路是动态路由添加之后再让路由跳转重新走一遍保证目标路由已经存在。这个方案实测有效但要注意避免在路由守卫里造成死循环做判断时要确保每次进入都有明确的处理分支。4.2 考试倒计时的多重陷阱考试倒计时我在开发中反复改了好几版主要原因就是浏览器对定时器处理太“诡异”了。第一次用setInterval每秒往数据里减 1结果用户把浏览器切到后台回来之后时间慢了半分钟。改成时间戳计算后又发现一个问题用户手动修改本地时间剩余时间也会变化。这没法彻底解决我只能把后端服务器时间戳在下发试卷时带入前端每次计算都以那个时间戳为基准尽力绕过本地时间不可信的问题。另一个坑是自动交卷和定时器同时触发。剩余时间归零时如果用户恰好手动点击了交卷按钮两个提交请求会同时发出后端可能收到重复数据。我的处理是在提交接口处加一个防重复标记第一次提交请求发出后把按钮设为 loading 状态并加一个submitted标志后续点击直接忽略。4.3 Element Plus 表单校验不生效的坑这个坑特别有代表性。我用el-form的rules做表单校验有个字段是动态生成的比如上传文件列表。我明明定义了 rules 规则提交时却始终不触发校验。排查发现el-form-item的prop字段必须能直接对应到model里的数据路径。我那个动态上传字段是数组结构但prop写成了fileList[0].url这种嵌套路径在 Element Plus 表单校验中经常解析失败。最后改成只在提交时单独校验这个字段用自定义校验函数处理const validateFileList (rule, value, callback) { if (!value || value.length 0) { callback(new Error(请先上传附件)) } else { callback() } }把这个校验函数放进 rules 中对应字段的validator上问题解决。4.4 常见问题速查表我把开发过程中整理出来的高频问题做成一个速查表方便大家对照排查。现象可能原因解决办法刷新页面后菜单空白动态路由未在刷新时重新生成路由守卫中判断router.hasRoute未生成则重新拉取并添加表格数据有的正常有的缺失接口返回字段和前端字段名不一致在前端 api 层做字段映射归一或让后端统一返回 camelCase上传组件回显失败组件需要数组结构后端返回单个对象在赋值前把数据统一包装成数组修改密码成功后还能访问旧页面退出登录后旧路由仍在本地清除 localStorage 的 token 和用户信息后强制跳转登录页按角色隐藏菜单刷新后乱显示菜单在路由生成之前渲染等路由生成后再渲染菜单或使用v-if控制菜单区域挂载时间显示差 8 小时前端没做时区转换统一使用时间戳前端格式化时指定时区排查思路其实就一条先看数据对不对再看渲染逻辑对不对最后看是不是异步时序问题。90% 的诡异 bug 都能按这个顺序找出来。这套系统开发到后期我最大的感受是做一个教学辅助系统真正费时间的不是某个单一技术难点而是如何把考试、作业、课程、成绩这些业务逻辑理顺再转化成好用的界面交互。像考试倒计时、动态路由刷新这类问题看起来都是“小技术点”但每解决一个系统稳定性和用户体验都会明显上一个台阶。最后再分享一个小体会开发这类系统时多和后端确认接口字段约定特别是时间格式、分页参数、错误码这些基础约定越早统一越省心。前端这边别偷懒接口请求层一定要封装好后期新增功能会轻松很多。