去年我在做这个高中信息技术在线学习网站时最大的感受是这类项目不是技术难而是“场景杂”。学生要在线看视频、做练习、参加模拟考试老师要管题库、发试卷、看统计报表网站本身还要支持视频上传、自动判分、错题收集这些不算复杂但很零散的功能。技术选型上我最终敲定了 Vue 3 Node.js Element UI MySQL 的组合前端用 Vue 管理交互后端用 Node.js 提供接口Element UI 负责管理后台页面。整套系统做下来大概花了两个月现在把完整设计思路、实现方案和踩过的坑拆开讲一遍希望能帮到正在做类似毕业设计或者学校项目的朋友。1. 一个高中信息老师的需求为什么最终落在 Vue Node.js ElementUI 上1.1 这个网站要解决的“教”与“学”问题高中信息技术这门课有一个很特别的地方——它是为数不多“用技术教技术”的学科。学生要学数据编码、算法与程序设计、信息系统与社会、人工智能初步这些模块每个模块既需要理论知识讲解也需要实操演示和题库练习。如果只靠课堂 45 分钟老师根本没有足够时间给每个学生讲透如果靠 QQ 群、网盘传资料知识点又是零散的学生的练习情况也没法跟踪。我当时访谈了几个信息老师总结出三个核心痛点教学资源分散视频、课件、习题散落在不同平台老师和学生维护成本高。在线练习缺乏自动判分和错题归集老师组一次卷要手动整理很久。学习进度无法量化哪些学生看了视频、做了练习、掌握了哪个知识点全靠老师印象。所以这个网站的本质不只是“把课程搬到网上”而是要打通“资源学习—在线练习—模拟考试—错题巩固”的完整闭环。这一点直接影响后面我怎么做数据库设计和功能模块拆分。1.2 技术选型时我对比过的方案和取舍逻辑接到这个需求后我其实对比过三套方案。第一套是传统的 JSP Servlet Oracle这种方案在学校老系统中很常见但前后端代码耦合严重前端改一个按钮样式都要重启服务开发效率太低现代前端生态完全用不上。第二套是 Spring Boot Vue这套组合在企业级项目中很稳社区资料也丰富。但考虑到这个网站的用户量级基本是几百个学生同时在线Spring Boot 的很多能力是用不上的而且后端同学如果是刚接触 Java 生态起步成本会比 Node.js 高不少。第三套就是我最终选的 Vue 3 Node.js Express MySQL Element UI。我选它的核心理由有三点前后端语言统一前后端都用 JavaScript/TypeScript学习曲线平滑对独立开发或者小团队开发特别友好。开发效率高Express 写接口很轻量配合 Sequelize 操作 MySQL开发速度比 Spring Boot 快很多Vue 的组件化开发配合 Element UI 的表格、表单、弹窗组件后台管理页面基本是“拼乐高”。部署简单Node.js 服务本身就是一个进程配合 PM2 守护和 Nginx 反向代理就能稳定跑起来学校机房运维条件有限这种轻量方案更现实。对比表格整理如下方案后端开发效率适合规模团队门槛部署复杂度JSP Servlet低前后端耦合小中等高Spring Boot Vue中高中大型高高Node.js Vue高中小型低低当然这套方案也有短板。Node.js 对 CPU 密集型任务的处理能力不如 Java但在在线学习网站这种以 IO 读写、数据库操作为主的场景下这个短板完全不影响实际使用。做技术选型不是追求“最强技术”而是找到“最适合当前场景的技术”。2. 先把业务规则理清楚再谈写代码很多人在拿到这类项目时第一反应是打开 IDE 写代码结果写到一半发现表结构不对、模块职责不清然后推倒重来。我当时的做法是先把业务规则拆明白用几天时间画清楚用户角色、功能列表、数据流转后面写代码才有章法。2.1 三类用户、四条主线的功能拆解整个系统我划分为三类角色每一类角色的功能和页面完全不同学生端课程中心按照必修、选择性必修、选修三个类别浏览课程支持课程内的视频、文档资源在线学习。在线练习按知识点选题练习提交后立刻出分做错题目自动进错题本。模拟考试教师发布的试卷限时答题交卷后自动判分并保存成绩。个人中心查看学习进度、练习记录、考试历史、错题本。教师端资源管理上传视频、课件、文本资源按课程分类组织。题库管理管理单选题、多选题、判断题、填空题支持批量导入。组卷管理从题库中选题组卷支持手动选题和随机抽题。学情查看查看学生考试分数、练习正确率、各知识点掌握情况。管理员端用户管理负责学生和教师账号的开通、禁用和重置密码。系统管理管理课程分类、公告信息、系统基础参数。四条业务主线就是内容学习线、练习判分线、考试测评线、错题闭环线。后续的数据库设计和接口设计都是围绕这四条主线展开的。2.2 数据库表设计五张核心表的字段逻辑数据库我选了 MySQL 8.0字符集 utf8mb4。整套系统一共设计了 12 张表这里挑最核心的五张明细讲理解它们的设计思路其余表就很好推了。user用户表字段类型说明idint主键自增usernamevarchar(50)登录名passwordvarchar(255)bcrypt 加密后的密码real_namevarchar(50)真实姓名roletinyint1 学生 / 2 教师 / 3 管理员class_namevarchar(50)所在班级statustinyint0 禁用 / 1 正常created_atdatetime创建时间这里有两个设计细节值得注意。第一密码不存明文必须用 bcrypt 加盐哈希后存储哪怕数据库被脱库用户密码也不会直接泄露。第二role 字段直接决定了前端路由和后端接口的访问权限后端每个受保护接口都要校验角色不能只靠前端隐藏路由来做权限控制。question习题表字段类型说明idint主键course_idint所属课程knowledge_pointvarchar(100)所属知识点question_typetinyint1 单选 / 2 多选 / 3 判断 / 4 填空question_contenttext题目内容optionsjson选项JSON 格式存储answervarchar(255)标准答案analysistext答案解析difficultytinyint1 容易 / 2 中等 / 3 困难creator_idint录题教师options 字段用 JSON 格式存储比如单选题就是{A:...,B:...,C:...,D:...}。这样设计的好处是不用为不同题型建不同的表扩展新题型时只需要调整 JSON 结构和判分逻辑即可。answer_record答题记录表字段类型说明idint主键student_idint学生 IDquestion_idint题目 IDstudent_answervarchar(255)学生的答案is_correcttinyint0 错误 / 1 正确scoreint本题得分answer_sourcetinyint1 练习 / 2 考试exam_idint关联考试 ID练习可空created_atdatetime答题时间答题记录表是整个系统的数据枢纽。统计练习正确率、生成错题本、分析知识点掌握情况全部依赖这张表的数据。字段 answer_source 用来区分记录来自练习还是考试这样错题本可以统一从这张表里查。paper试卷表字段类型说明idint主键paper_namevarchar(100)试卷名称paper_typetinyint1 模拟考试 / 2 课后作业total_scoreint总分durationint考试时长分钟statustinyint0 草稿 / 1 已发布created_byint创建教师paper_question试卷题目关联表不直接存题目 JSON 到 paper 表而是拆一张关联表原因是同一份试卷需要记录每道题的分值。如果直接存 JSON后续要单独调整某一题分数会很麻烦。2.3 学习进度用状态机思路来控制学习进度这个需求我第一次设计时直接在 course 表里加了一个“进度”字段后来发现完全不合适——进度是“每个学生对每门课”的状态不能挂在课程本身。后来我改成了 course_progress 表以 student_id course_id 作为联合记录。状态的流转也很有讲究。学生访问课程详情页时状态是“未开始”把视频播放到超过 80% 时状态变“学习中”所有必修资源看完并且练习正确率达到 60% 以上变“已完成”。这个逻辑如果在每个页面上分散实现会非常混乱。我做了三个工具方法来统一管理状态进入课程时检查是否存在进度记录不存在则创建。视频播放进度回传时更新最后学习位置和上次学习时间。判断“已完成”是一个聚合函数需要同时检查资源完成情况和练习正确率不能只由前端传一个完成标记。用状态机的思路管理业务状态前端和后端都只维护一条数据流逻辑清晰也好测试。3. 核心功能实现从登录到自动判分的完整链路3.1 JWT 登录鉴权的完整闭环登录模块我采用了 JWTJSON Web Token实现无状态鉴权。为什么不用传统的 Session 方案因为 Session 方案需要在服务端保存会话状态用户在多设备登录或者将来扩展小程序端时会话同步会成为一个麻烦。JWT 把用户身份信息加密后发给前端前端后续请求带上 Token服务器验签即可天然适合前后端分离架构。登录接口的逻辑大致是const jwt require(jsonwebtoken) const bcrypt require(bcryptjs) router.post(/api/user/login, async (req, res) { const { username, password } req.body const user await User.findOne({ where: { username } }) if (!user) { return res.json({ code: 40001, msg: 用户不存在 }) } // bcrypt.compare() 校验密码而不是解密 const isValid bcrypt.compareSync(password, user.password) if (!isValid) { return res.json({ code: 40002, msg: 密码错误 }) } if (user.status ! 1) { return res.json({ code: 40003, msg: 账号已被禁用 }) } const token jwt.sign( { id: user.id, role: user.role }, process.env.JWT_SECRET, // .env 文件存放密钥不进代码库 { expiresIn: 12h } ) res.json({ code: 0, data: { token, userInfo: filterUserInfo(user) } }) })前端拿到 Token 后存储在 localStorage 或者 PiniaVue 的状态管理库中axios 请求拦截器会在每个请求头加Authorization: Bearer token。后端写了一个 verifyToken 中间件来统一校验function verifyToken(req, res, next) { const authHeader req.headers[authorization] if (!authHeader) return res.status(401).json({ code: 40101, msg: 未登录 }) const token authHeader.split( )[1] try { const decoded jwt.verify(token, process.env.JWT_SECRET) req.userId decoded.id req.userRole decoded.role next() } catch (err) { return res.status(401).json({ code: 40102, msg: 登录已过期请重新登录 }) } }另外对于教师端和管理员端的接口我在 verifyToken 的基础上又封装了 checkRole 中间件。接口权限必须这样做双层校验只看前端路由守卫是不够的——因为接口本身暴露在网络中任何有接口地址的人都可以绕过前端直接调用。3.2 在线答题自动判分多选和判断的边界条件自动判分是这个网站最有含金量的模块。如果只做单选判断很简单但真实需求里还要处理多选题和填空题边界条件比想象的多。我定义了一个 scoreService不同题型走不同判分逻辑function judgeQuestion(question, studentAnswer) { // question.answer 是标准答案 // studentAnswer 是学生提交的答案 // 返回 { isCorrect, score } switch (question.question_type) { case 1: // 单选题 case 3: // 判断题 return studentAnswer question.answer ? { isCorrect: true, score: question.score } : { isCorrect: false, score: 0 } case 2: // 多选题 // 多选题处理完全一致才得分 const standard JSON.parse(question.answer).sort() const student JSON.parse(studentAnswer).sort() return JSON.stringify(standard) JSON.stringify(student) ? { isCorrect: true, score: question.score } : { isCorrect: false, score: 0 } case 4: // 填空题 // 支持多个答案空格以 | 分隔 const blanks question.answer.split(|) const answers studentAnswer.split(|) let correctCount 0 blanks.forEach((item, index) { if (item.trim() answers[index]?.trim()) correctCount }) const perScore question.score / blanks.length return { isCorrect: correctCount blanks.length, score: Math.round(correctCount * perScore * 10) / 10 } } }多选题到底要不要“少选漏选也给分”这个规则一定要提前确认。我最初采用的是“完全一致才得分”但老师们反映难度太高后来改成“选对一个正确项得 1 分选错一个干扰项扣 1 分最低 0 分”的规则。不同的规则直接在判分函数中修改即可因为判分逻辑已经集中到了一个方法里。交卷时的另一个细节是必须加 Web 端的倒计时。我在前端启动一个定时器到时间后自动触发交卷接口后端也保存了一个 started_at 时间戳做二次校验防止学生改本地时间逃避限时。3.3 视频与课件资源高中机房环境下的播放方案视频播放这个需求看似简单但在学校场景里很容易踩雷。学校的网络环境、电脑配置参差不齐如果直接丢一个 .mp4 链接让浏览器播放很多老机器和旧版浏览器会卡顿或者无法解码。我当时测试过三种播放方案记录如下传统 HTML5 video 直接播放 mp4实现最简单但文件大、加载慢而且机房网络带宽有限很多学生会抱怨视频半天加载不出来。转码为 HLS 流.m3u8播放首屏加载快支持清晰度切换但需要 Video.js 配合 hls.js 库还要对上传的视频做转码处理。第三方云点播服务体验最好但涉及额外成本。最终我采用了“本地转码 HLS 播放”的方案。上传视频时后端使用 ffmpeg 将视频转码为 H.264 编码的 m3u8 切片ffmpeg -i input.mp4 -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls output.m3u8前端使用 Video.js 展示代码兼容性很好video idmy-player classvideo-js vjs-default-skin controls preloadauto source :srcvideoUrl typeapplication/x-mpegURL /videoimport videojs from video.js import video.js/dist/video-js.css const player videojs(my-player, { controls: true, responsive: true, fluid: true, playbackRates: [0.75, 1, 1.25, 1.5, 2] })播放时通过 video 的 timeupdate 事件每 10 秒向后端回传一次播放位置实现续播功能。学生重新进入课程时可以直接从上次停止的位置继续播放。这个功能对体验提升非常明显毕竟一节 20 分钟的视频很少有人一次看完。3.4 随机组卷按知识点比例抽题的实现思路教师组卷有两种模式一种是完全手动从题库选题另一种是按知识点比例随机抽题。前者用 Element UI 的表格多选即可实现后者需要一点算法逻辑。random question selection 的思路是前端提交组卷参数例如“课程 ID、总分 100、题型配比单选 10 题每题 3 分多选 5 题每题 4 分判断 10 题每题 3 分 各知识点占比”。后端按条件从题库中筛选出可用题目再按照知识点分组每组随机抽取指定数量的题。核心代码大致如下async function generateRandomPaper(paperConfig) { const { courseId, rules } paperConfig const finalQuestions [] for (const rule of rules) { // { knowledgePoint: 算法基础, questionType: 1, count: 4, score: 3 } const pool await Question.findAll({ where: { course_id: courseId, knowledge_point: rule.knowledgePoint, question_type: rule.questionType } }) // 打乱数组取前 N 道 const shuffled pool.sort(() Math.random() - 0.5) const selected shuffled.slice(0, rule.count) selected.forEach(q { finalQuestions.push({ question_id: q.id, score: rule.score }) }) } // 检查抽取数量是否满足要求 if (finalQuestions.length totalNeedCount) { throw new Error(题库数量不足请补充题目或放宽抽题条件) } return finalQuestions }抽取完题目后再把题目标号随机打乱一次确保同一考场的相邻学生看到题目的顺序不同降低作弊概率。考试记录里保存试卷和题目顺序方便后续回放和成绩复核。4. 前后端联调看着不大、卡住半天的细节4.1 axios 封装时最容易忽略的响应拦截项目一开始我在每个组件里直接用 axios 发请求代码非常冗余后来统一封装成了一个 request 模块。封装的核心不是把 baseURL 抽出来配置一下而是把响应拦截器设计好。import axios from axios import { message } from element-plus import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, error Promise.reject(error)) request.interceptors.response.use( response { const res response.data if (res.code 0) { return res.data // 直接返回业务数据组件里不用再 res.data.data } if (res.code 40101 || res.code 40102) { userStore.clearLogin() router.push(/login) message.error(登录状态已过期请重新登录) return Promise.reject(new Error(res.msg)) } message.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, error { message.error(error.message || 网络异常) return Promise.reject(error) } )这里我踩过一个坑后端返回的 JSON 结构在设计时没有统一有时候是{ code: 0, data: ... }有时候又是直接{ success: true, data: ... }导致前端拦截器里判断逻辑写了一堆分支。等到项目中期我统一了所有接口的返回格式代码立刻清爽了很多。前后端联调之前必须先定好接口数据规范哪怕是简单的code msg data结构也能避免大量返工。4.2 前端路由守卫和后端鉴权中间件双保险权限管理如果只做后端或者只做前端都是不完整的。我前端用 Vue Router 的 beforeEach 做了三层判断后端用 verifyToken checkRole 做了两层校验。router.beforeEach((to, from, next) { const userStore useUserStore() const token userStore.token if (to.meta.publicPage) { // 登录、注册等公开页面 if (token to.path /login) { next(/) } else { next() } return } if (!token) { next(/login) return } // 允许访问的页面集合 const allowedPages getAllowPages(userStore.role) if (to.path ! /login !allowedPages.some(p to.path.startsWith(p))) { next(/403) return } next() })这里的核心原则是前端守卫是“体验保障”和“界面隐藏”后端中间件才是真正的安全壁垒。你不能因为前端连菜单入口都没显示就认为没有权限的用户无法调用接口。4.3 跨域配置开发环境用代理生产环境要交给 Nginx前后端分离部署后跨域问题是绕不开的。我在开发环境下使用 Vite 的代理配置指向本地 3000 端口避免浏览器段跨域请求// vite.config.js export default defineConfig({ server: { port: 8080, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } })生产环境的跨域配置放在 Nginx 里让 Nginx 同时托管前端静态资源并反向代理后端接口这样前后端都走同一个域名根本没有跨域问题server { listen 80; server_name study.example.com; root /var/www/html; index index.html; # 前端 history 路由需要 fallback location / { try_files $uri $uri/ /index.html; } # 后端接口代理 location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }有同学问为什么不用后端直接加 CORS 头来解决也可以但我更推荐 Nginx 方案。原因是 Nginx 还可以顺带做静态资源缓存、请求限流、Gzip 压缩这些都是应用层加 CORS 解决不了的。5. 部署上线与经典踩坑记录5.1 Node.js 环境配置和 npm 脚本执行失败的处理拿到一台新服务器或者新电脑要部署这个项目时最常遇到的问题就是 Node.js 环境装好了但是执行 npm 命令直接报错npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这是 PowerShell 的执行策略默认限制导致的。解决方案有两种第一种以管理员身份打开 PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned然后重新打开终端npm 命令就可以正常使用了。这种方式改的是系统全局策略。第二种如果你不想修改全局执行策略可以在项目目录下用 CMD命令提示符执行CMD 不理会 PowerShell 的脚本策略也能正常运行 npm。我在给学校机房配置环境时就遇到了这个报错当时一度以为是 Node.js 安装坏了重装了好几遍才发现是执行策略的问题。另外强调一点Windows 安装 Node.js 时安装路径尽量不要带空格和中文否则后面很多工具链查找路径会出现莫名其妙的问题。安装完 Node.js 后记得检查环境和镜像配置node -v npm -v npm config set registry https://registry.npmmirror.com这个镜像配置对于国内下载依赖来说几乎是必须的不然 npm install 可能会卡在某个包上很长时间。5.2 Element UI 表格固定列的透明闪烁问题项目开发到联调阶段我在页面里用了 el-table 的固定列功能结果在部分电脑上出现一个诡异现象横向滚动表格时固定列的内容会闪一下有时候干脆变透明只有鼠标移过去才恢复。排查后确认这是 Element UI 表格在滚动时给固定列添加了 transform 样式来提升滚动性能但在某些显卡驱动和 Chrome 版本组合下GPU 合成图层触发异常产生渲染闪烁。解决办法有几个层次第一层给固定列所在的 el-table 单独加一个默认背景色防止背景穿透.el-table .el-table__fixed-right { background-color: #fff; }第二层强制关闭表格单元格的 GPU 合成给表格容器加上.el-table { will-change: auto !important; } .el-table__body td, .el-table__body th { backface-visibility: hidden; }第三层如果是 Element Plus 版本可以直接升级到较新的版本官方对固定列的渲染问题有修复。我最后采用“第一层 第二层”的方案。这种渲染层级的 bug 不是每次都稳定复现所以做兼容性处理时要耐心改一点测一遍。5.3 生产环境进程守护与优雅部署Node.js 服务在生产环境不能直接用node app.js跑否则终端一关进程就没了。我用 PM2 来做进程守护它支持自动重启、日志记录、负载均衡模式。部署上线的基本流程# 1. 本地构建前端项目 npm run build # 生成 dist 目录包含编译后的静态文件 # 2. 将 dist 目录上传到服务器 /var/www/html rsync -avz dist/ userserver:/var/www/html/ # 3. 在服务器上启动后端 pm2 start ecosystem.config.js pm2 save pm2 startupecosystem.config.js 的内容module.exports { apps: [{ name: study-api, script: src/app.js, instances: max, // 多核 CPU 可以开启 cluster 模式 exec_mode: cluster, max_memory_restart: 500M, env: { NODE_ENV: production, PORT: 3000 } }] }PM2 的 cluster 模式会根据 CPU 核心数生成多个工作进程Nginx 会自动把请求分发到这些进程上。不过要注意一点如果后端使用了内存变量存储数据cluster 模式下各进程间的内存是不共享的需要额外引入 Redis。我在做这个系统时凡是需要跨请求共享的数据都存到了数据库所以没有引入 Redis 依赖。5.4 数据安全和接口防刷容易被忽视的两个点很多校内系统上线后安全问题往往出在最基础的地方。我盘点了一下有两点最容易忽略第一点是密码安全和登录频率限制。前端登录接口如果没有任何限制用 Burp Suite 这类工具可以无限跑字典爆破。我加了两个防护首先是限制单 IP 每分钟最多请求 10 次使用 express-rate-limit 中间件实现其次是连续 5 次密码错误后锁定账号 15 分钟防止对单个账号的暴力破解。第二点是数据库的数据备份。学校项目的运维一般没有专门的 DBA所以我写了一个简单的脚本每天凌晨 2 点通过 mysqldump 备份数据库到指定目录保留最近 7 天的备份#!/bin/bash BACKUP_DIR/data/backup/mysql DATE$(date %Y%m%d) mysqldump -u root -p --all-databases $BACKUP_DIR/study_$DATE.sql find $BACKUP_DIR -type f -mtime 7 -delete这个脚本配合 cron 定期执行。上线后我一直提醒自己数据不能只留在开发机或者某一个人的电脑上一旦服务器磁盘挂了没有备份就是全量丢失。6. 我踩过几次坑之后的一些体会这套从需求分析到设计再到部署的流程走下来我最大的体会是这类学校信息化系统真正的门槛往往不是在技术上而是在需求梳理、边界约束和稳定性上。比如自动判分时多选题的评分规则如果不在开发前和老师反复确认清楚后面改起来特别痛苦再比如视频播放如果不在真实机房环境测试等学生大面积反馈卡顿时才去复盘既影响教学进度也消耗信心。还有一个小技巧分享给大家就是在做接口设计时尽量把所有返回码统一定义成一个常量文件前端和前端请求层都引用同一套约定。虽然初期看起来麻烦但后期维护和排查问题不知道能省多少时间。项目写完上线只是第一步后续内容的填充、题库的丰富、学生使用反馈的迭代才是让这个系统真正稳定发挥价值的重点。做学校项目是这样的交付永远只是开始。
