去年给某高校信息中心做教师继续教育平台的时候对方一开始给我看了一份需求文档里面赫然写着“参考某某学习App的学生端”。我当场就有点警觉——教师在线学习和学生在线学习表面上是同一件事实际上完全是两种产品逻辑。学生有天然的学分压力和统一的课表节奏教师则是利用课余时间自主研修年龄跨度从二十多岁到五十多岁都有操作习惯、硬件环境各不相同。按学生端的思路做平台上线后大概率没人用。后来我们按“Vue3 Node.js Element Plus”这套技术栈重新设计并落地了这个平台从需求梳理、数据库设计、核心功能开发到上线后的打通测试前后花了大概三个多月目前已经平稳跑过两学期累计服务了几百名教师。这篇文章就把整个项目的设计思路和落地过程复盘一遍重点讲那些在学生端项目里几乎不会遇到、但在教师端场景里非常要命的细节。1. 教师端不是“精简版学生端”需求分析先于技术选型很多人拿到“高校教师在线学习平台”这个需求第一反应就是“把学生端的课程列表、视频播放、在线考试搬过来再把用户体系换成教师就行了”。这个思路的问题很大。高校教师的在线学习本质上是在岗培训不是学历教育它的使用场景和激励逻辑完全不同。1.1 高校教师在线学习的三类真实场景我接触到的真实场景大致可以分成三类第一类是新入职教师的岗前培训。新人需要在入职后一个学期内完成师德规范、教学制度、信息技术应用等课程学习这是硬性要求存在明确的时间节点和完成率考核。第二类是全体教师的继续教育学时。不少高校每年对专任教师有继续教育学时要求我记得通常要求不少于一定学时数这部分学时来源多样可以是线下培训、学术会议、外出进修也可以是校内在线平台的自选课程由教务或人事部门负责审核认定。第三类是各类专题研修班比如课程思政专项、数字化教学能力提升、教学成果奖申报经验分享等。这类课程通常周期性开放、有明确的受众名单需要统计“谁学了、学了多久、是否完成考核”。这三类场景有一个共同点责任主体是教师本人但管理主体是院系教学秘书和教务处/人事处。也就是说平台不仅要让教师方便地学更要让管理者能轻松地查——谁没学、学了多少、差多少学时这些数据必须实时可查、可导出、可打印。1.2 模块取舍哪些该做哪些不该做基于上面三类场景平台的核心模块经过几轮取舍后确定为以下六块模块面向角色核心功能课程中心教师端课程分类浏览、详情查看、报名开课在线学习教师端视频播放、进度断点续播、文档在线阅读培训考核教师端课后测试、在线考试、成绩查询学时中心教师端学习记录明细、学时累计、证书查看管理后台管理员/院系秘书课程管理、人员管理、学时导出通知公告全部培训通知、开课提醒、考试提醒不该做的事情其实比该做的更难拿捏。比如直播课堂老师和学生的空闲时间高度分散强行排直播课很难凑齐人不如把直播录下来放回课程里再比如社交化的学习圈子、发帖讨论区教师端基本用不起来——高校教师更习惯有问题直接在工作群里问很少专门到平台上留言。这些模块加了只会增加运维负担剪掉反而清爽。说到底教师在线学习平台的产品逻辑是八个字学得到、查得清、导得出。核心不是内容生产而是学习行为的过程记录和学时结果的可信统计。2. 技术栈落位与工程骨架Vue3 Node.js Element Plus的初始化之路技术选型这件事在高校信息化项目里有它的特殊性。高校往往不是没有服务器和运维人员而是系统多、人手少、迭代需求快。用一套能快速交付、前端开发友好、部署不复杂的技术栈比追求高并发、分布式这类宏大概念重要得多。2.1 为什么是这套组合前端选择Vue3 Element Plus理由其实很朴素Vue在国内开发者群体中的占比依然最高后续接手维护的人相对好找。Element Plus的组件足够应付后台管理系统常见的表格、表单、弹窗、树形控件等场景开发效率非常高。管理后台是典型的“表单密集型”应用用Element Plus写起来非常顺手。标题里的ElementUi其实指的是Element UI这个组件库。需要说明一下Element UI是Vue 2生态的产物Element Plus是Vue 3时代对应的版本组件命名和API大体一致。2025年的新项目直接用Vue 3 Element Plus就好没必要再搭Vue 2的新项目。后端选择Node.jsExpress框架同样是基于实际场景的考虑平台预期的并发量并不高几百个教师同时在线观看视频、偶尔并发考试这个负载对于单机Node进程绰绰有余。Node.js开发效率极高前后端都用JavaScript数据结构和接口定义可以直接复用联调成本低。服务器只需要装一个Node运行时和一个Nginx部署链路短后期维护门槛低。数据库方面选了MySQL 8.0没有用更轻量的SQLite或MongoDB主要考虑到高校信息中心通常对MySQL的运维更熟悉且后续如果需要对接校内统一身份认证、人事系统等MySQL更容易做数据集成。2.2 工程初始化与目录设计前端工程用Vite初始化命令很简单npm create vitelatest teacher-learn-web -- --template vue cd teacher-learn-web npm install npm install element-plus axios vue-router pinia后端工程手动搭建Express没有用重型脚手架保持简单直观方便后续接手的人快速上手mkdir server cd server npm init -y npm install express mysql2 sequelize cors cookie-parser multer jsonwebtoken最终项目目录结构如下前后端分仓库管理teacher-learn-platform/ ├── web/ # 前端工程 │ ├── src/ │ │ ├── api/ # 接口封装 │ │ ├── router/ # 路由配置 │ │ ├── store/ # Pinia状态管理 │ │ ├── views/ │ │ │ ├── teacher/ # 教师端页面 │ │ │ └── admin/ # 管理后台页面 │ │ ├── components/ # 公共组件 │ │ ├── App.vue │ │ └── main.js │ └── package.json └── server/ # 后端工程 ├── routes/ # 路由层 ├── controllers/ # 控制器层 ├── models/ # Sequelize模型 ├── middlewares/ # 中间件 ├── uploads/ # 视频/文档存储目录 ├── utils/ # 工具函数 ├── app.js # Express入口 └── package.json后端的分层没有做得很重routes只管路由分发controllers写业务逻辑models用Sequelize定义数据模型。文件上传保存到本机uploads目录学习记录和课程数据存MySQL。视频存在本地不接云存储原因很简单——平台的使用量可控本地磁盘500G机械盘足够存几个学期的课程视频成本几乎为零。2.3 数据表设计六张核心表就够用项目里最终保留了六张核心业务表外加几张辅助表公告、系统配置。由于标题里提到“设计”这里把表结构的关键字段列一下方便有类似需求的朋友直接参考表名主要字段说明userid, name, employee_no, college, department, role, phone, password教师和系统管理员共用role区分courseid, title, cover, category, teacher, description, status, credit_hours, start_time, end_timestatus区分草稿、报名中、学习中、已结束lessonid, course_id, title, video_url, document_url, duration, sort, is_required一门课程包含多个课时learn_recordid, user_id, lesson_id, watched_second, total_second, is_completed, last_watch_time每分钟更新一次学习进度examid, course_id, title, question_config, pass_score, duration_minutes, start_time, end_time考试配置题目用JSON存储exam_recordid, exam_id, user_id, answers, score, is_pass, submit_count, submit_time考试答卷与成绩有一点值得专门说明learn_record里的watched_second和total_second是核心字段学时统计全靠它们。我们没有用is_completed直接决定“是否完成这门课”而是通过“有效观看时长达到课时时长的80%以上”来自动标记完成。这个逻辑在后面踩坑部分还会详细展开。3. 核心功能落地课程、学习进度、考核与学时统计需求理清了技术栈定了接下来就是一个个把模块做扎实。这里挑四个最有代表性的功能介绍具体实现每个都有实际踩坑和调整的过程。3.1 管理员端课程从草稿到上架的完整生命周期课程管理是管理后台最核心的模块我用Element Plus的el-table、el-dialog、el-form组合实现。状态流转得很简单草稿 - 报名中 - 学习中 - 已结束。每个状态下允许的操作不一样比如“草稿”状态下可以编辑课程信息和上传课时“报名中”状态下教师端已经能看到课程但还不能开始学习报名时间结束后自动进入“学习中”这一天的状态切换用一个定时任务来跑。课时管理的实现需要注意一个大坑视频上传大小。Node.js原生的body-parser处理JSON表单没问题但视频上传走的是multipart/form-data必须挂multer中间件而且需要放开文件大小限制const multer require(multer); const path require(path); const storage multer.diskStorage({ destination: (req, file, cb) { cb(null, path.join(__dirname, ../uploads)); }, filename: (req, file, cb) { const ext path.extname(file.originalname); cb(null, Date.now() - Math.round(Math.random() * 1e9) ext); } }); const upload multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 单视频最大5G });这个5G上限是根据实际视频规格摸出来的。平台视频一般由教学发展中心用录屏软件录制1080p分辨率、1小时左右的视频压缩后大约300M-800M。5G的上限足够覆盖绝大多数情况。上传时前端用el-upload组件配合后端接口进度条通过on-progress展示视频上传期间页面不能刷新否则上传中断因此上传按钮旁边我加了一句提示“视频上传中请勿关闭页面”。一段完整的课程上传流程需要按照“先建课程基本信息再逐课时上传视频最后设置考核内容并提交上架”的顺序走。很多学校运营人员习惯一次性把所有材料准备好再录入所以后台要允许“草稿”状态保存多次而不是一次性表单提交。3.2 教师端视频进度自动记录与断点续播教师端学习的核心痛点是看了一半、关掉、下次继续进度必须记住。第一次做的时候我以为只要在video元素的timeupdate事件里把当前播放时间currentTime传给后端就行。结果上线第一天就暴露了问题——timeupdate事件在播放过程中高频触发每秒4次左右几百人同时学习时数据库写入压力直接拉满接口响应从20ms涨到800ms。后来改了策略前端以5秒为周期做节流后端只做更新不递增计数。前端每隔5秒上报一次{ lessonId, currentTime, duration }后端upsert更新对应学习记录。即使关闭浏览器也能保证丢失的数据最多不超过5秒。表达出来的代码逻辑大致是这样// 视频播放器组件内每5秒上报一次进度 let lastReported 0; videoElement.addEventListener(timeupdate, () { const now videoElement.currentTime; if (now - lastReported 5) { reportProgress(lessonId, now, videoElement.duration); lastReported now; } }); async function reportProgress(lessonId, currentTime, duration) { const res await axios.post(/api/learn/progress, { lessonId, currentTime: Math.floor(currentTime), totalTime: Math.floor(duration) }); // 返回值里标记是否已达成完成条件前端据此决定是否显示完成本课时 if (res.data.completed !localStorage.getItem(completed_ lessonId)) { localStorage.setItem(completed_ lessonId, 1); ElMessage.success(本课时已完成); } }断点续播的实现也在这个逻辑里教师再次打开视频时后端接口返回该课时最近的watched_second前端在video加载完成后直接设置currentTime为该值继续播放const { data } await axios.get(/api/learn/progress/${lessonId}); if (data data.watchedSecond 0 data.watchedSecond videoElement.duration - 10) { videoElement.currentTime data.watchedSecond; }这里有一个体验细节如果进度已经到最后10秒以内下次打开默认从头开始避免“差2秒看完”的残留进度干扰。3.3 考核模块防重复提交的考试流程每门课程结束后的在线考试是教师端的另一个关键功能。考虑到用户群体是高校教师考试题目不搞特别复杂的题型以单选题、多选题、判断题为主用JSON存储题目数据。防重复提交是最容易出问题的一环。最开始我天真地以为前端按钮点过一次后加个disabled属性就完事了结果测试阶段就暴露问题教务员反映“明明我只点了一次交卷系统里却出现了两份答卷记录”。原因很简单前端提交请求发出后用户看到转圈动画手太快又点了一下第二次请求又发出去了而此时接口已经执行完第一次交卷逻辑。修复方案分为两层。第一层是前端兜底交卷后立刻将按钮置为不可点击并禁用键盘事件。第二层是后端核心保障在接口事务中检查exam_record表是否已有该用户该考试的记录有则直接拒绝// 考试交卷接口关键逻辑 router.post(/api/exam/submit, async (req, res) { const { examId, answers } req.body; const userId req.user.id; const existing await ExamRecord.findOne({ where: { exam_id: examId, user_id: userId } }); if (existing existing.submit_count 1) { return res.json({ code: 400, msg: 您已交卷请勿重复提交 }); } // 事务内计算分数、保存答卷 const transaction await sequelize.transaction(); try { const score calScore(examId, answers); const record await ExamRecord.create({ exam_id: examId, user_id: userId, answers: JSON.stringify(answers), score, submit_time: new Date() }, { transaction }); await transaction.commit(); res.json({ code: 200, data: { score, recordId: record.id } }); } catch (e) { await transaction.rollback(); res.json({ code: 500, msg: 交卷失败请重试 }); } });另外一个容易被忽视的点是考试倒计时。浏览器标签页切走或者电脑休眠前端定时器可能会被暂停倒计时就不准了。解决方式是后端在下发试卷时返回服务器的截止时间戳前端基于服务器时间计算剩余秒数每30秒和后端同步一次当前时间确保超时自动交卷。3.4 学时统计Excel导出的一键化学时统计是这款平台的“后台硬需求”。每学期末院系教学秘书要统计本学院教师继续教育学时完成情况并上报如果全靠人工比对几十个人就得忙一下午。平台里这个功能要做得足够自动化、足够可追溯。实现方式并不复杂管理后台的“学时统计”页面支持按学院、按年度、按课程筛选查询结果以表格展示点击“导出Excel”按钮后端直接返回一个.xlsx文件流。后端用exceljs这个库生成Excel文件不需要额外依赖const ExcelJS require(exceljs); router.get(/api/admin/export-hours, async (req, res) { const { college, year } req.query; const data await getStatData(college, year); const workbook new ExcelJS.Workbook(); const sheet workbook.addWorksheet(学时统计); sheet.columns [ { header: 工号, key: employeeNo, width: 15 }, { header: 姓名, key: name, width: 12 }, { header: 所属学院, key: college, width: 20 }, { header: 年度, key: year, width: 10 }, { header: 已完成学时, key: hours, width: 12 }, { header: 明细, key: detail, width: 60 } ]; data.forEach(row sheet.addRow(row)); res.setHeader(Content-Type, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); res.setHeader(Content-Disposition, attachment; filenamehours-${year}.xlsx); await workbook.xlsx.write(res); res.end(); });“明细”列会生成类似“《课程思政专题培训》(2学时) 2024-04-12《数字化教学工具使用》(3学时) 2024-05-03……”的字符串。一旦某位老师对学时提出异议管理人员可以直接复制明细核对不用再翻数据库。4. 上线两学期后仍会翻车的三个实战问题项目开发完成、测试通过、正式上线按理说事情该结束了。实际运行两学期之后我陆续收到几类问题反馈每一个都值得单独拿出来说。4.1 跨域与Cookie内网环境下最大的拦路虎开发环境下前端跑在localhost:5173后端跑在localhost:3000端口不同天然存在跨域。解决开发环境的跨域很简单Vite配置里加个代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });真正麻烦的是线上环境。平台部署在校内服务器Nginx托管前端静态页面所有API请求走/api前缀反代到Node服务的3000端口。如果Nginx配置没写对proxy_set_header登录接口就会出现诡异的问题——前端能收到响应但登录状态保持不住。原因在于Cookie的SameSite属性。浏览器跨站请求默认不带Cookie而Nginx反代后浏览器看到的域名是一致的都是学校的域名前端和后端属于同站按理说不会触发SameSite限制。但如果校园网里还有一层代理网关比如上网认证系统就会引入中间域的情况此时Cookie的SameSiteLax设置会影响体验。我最后的配置是显式设置Cookie属性res.cookie(token, token, { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: lax, secure: false // 校园网内http协议下必须为false });secure这里尤其要小心。高校内网很多页面走的是HTTP而不是HTTPS如果把secure设为trueCookie在HTTP下就不会被写入后果就是“登录接口提示成功但跳转后立即回到登录页”。后来排查到这个问题时我一度怀疑是前端路由守卫的问题浪费了小半天。4.2 大视频上传与播放multipart与编码格式的坑视频上传和播放是两个维度的坑。上传维度除了前面说的multer配置还有一个隐蔽的坑是Nginx的请求体大小限制。默认情况下client_max_body_size是1M视频一上传就报413错误。必须在Nginx配置里放开server { listen 80; server_name learn.example.edu.cn; client_max_body_size 5g; 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; proxy_read_timeout 600s; proxy_send_timeout 600s; } }proxy_read_timeout和proxy_send_timeout也需要注意默认60秒对于大视频上传远远不够。我遇到过一次上传300M视频到一半就断开的情况就是Nginx超时导致的不用怀疑后端代码。播放维度最大的坑是视频编码格式兼容性。教务老师用某录屏软件生成的是H.265编码的MP4而不少老师电脑上的浏览器尤其是较老的Edge或Chrome版本不支持H.265硬解只能听到声音、看不到画面或者干脆黑屏。后来统一规范所有上传视频必须转成H.264编码压缩工具用格式工厂或HandBrake预设选择Fast 1080p30音频转成AAC。这个规范写进了平台操作手册管理员上传视频时也必须勾选“已确认视频为H.264编码”。有朋友可能会问为什么不用HLS切片或者DASH流媒体方案。这个平台的并发规模撑不起这种复杂度普通的HTML5 Video标签直接播放MP4已经足够别过度设计。4.3 进度心跳的写入压力与异常时长处理进度上报这是第三个实战问题也是最值得展开细讲的。前面已经说过前端做5秒节流但后来发现另一个问题课后测试成绩达标了学时却不够。查了一圈问题出在这个逻辑上很多老师习惯播放视频后切到别的窗口做事视频在后台继续播放而浏览器为了省资源会对后台标签页的setInterval做节流甚至暂停。我用的timeupdate事件在后台标签页同样会被抑制导致上报频率大幅下降。更要命的场景是老师把视频从头播放一遍然后拖拽进度条从头再放或者直接拖动到视频末尾。这种情况下currentTime会瞬间变大如果不对时长做校验一分钟的视频就能被“看”出两小时的记录。解决方案分三步第一步后端记录watched_second时只允许单调递增且单次上报增加量不超过实际时间差的合理倍率。比如相邻两次上报的实际时间差是5秒那么允许的观看时间增量上限设为20秒考虑快进和拖拽但不至于从0秒直接跳到1000秒const lastRecord await LearnRecord.findOne({ where: { user_id: userId, lesson_id: lessonId } }); let delta currentTime - lastRecord.watched_second; // 时间差秒 const realDelta (Date.now() - lastRecord.last_watch_time.getTime()) / 1000; // 允许的最大增长 const maxAllowed Math.max(realDelta * 4, 20); const effectiveDelta Math.min(delta, maxAllowed); const newWatched lastRecord.watched_second effectiveDelta;第二步后台标签页暂停的问题用visibilitychange事件兜底——页面切到后台时上报一次进度回到前台时再上报一次确保每次切换留下的进度点尽量精确。第三步异常时长的事后清洗。管理后台提供一个“学习时长矫正”按钮运营人员可以一键把明显超出的学习记录按课时实际时长封顶。这个矫正操作不是普通的打折而是要设置为“封顶”毕竟老师在后台挂机的行为比比皆是上来就惩罚说不过去但虚高数据必须纠正。5. 给同样在做高校信息化项目建设的朋友几条实在话这个项目做到后面技术上的问题反而简单难的是需求的边界、部署的稳定性和长期的可维护性。几点经验写出来希望能帮后来者少走弯路。5.1 部署用最“土”但最稳的方式高校信息化项目稳定性高于一切。这个平台最终部署在一台4核8G的服务器上操作系统是CentOS前端静态文件由Nginx托管后端Node进程用PM2守护MySQL单独跑在另一台服务器上每天凌晨3点通过crontab自动备份数据库备份文件保留30天。部署脚本没有用Docker不是Docker不好而是高校信息中心运维人员不一定熟悉容器技术。用传统的Systemd服务、PM2进程守护和Nginx反代出问题时排查起来路径最短。PM2的启动配置很简单cd /home/learn/server pm2 start app.js --name learn-server --max-memory-restart 1G pm2 save pm2 startup一条pm2 startup就能确保证服务器重启后Node进程自动恢复不用额外写运维手册。5.2 权限模型务必留有余地最初设计权限模型时只设了“教师”和“管理员”两个角色结果用了一个学期就顶不住了。各学院教学秘书需要一个“院系管理员”角色只能查看本学院教师的学习数据不能动课程配置教务处则希望有人能导出全校数据但不能修改课程内容。最终在用户表上加了一个role字段的层级控制系统管理员、院系管理员、普通教师三级用中间件校验接口权限function checkRole(...roles) { return (req, res, next) { if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 403, msg: 权限不足 }); } next(); }; } // 使用示例只有系统管理员和院系管理员可以导出学时 router.get(/api/admin/export-hours, checkRole(admin, college_admin), exportHours);这个改动不大但在实际使用中化解了大量权限纠纷。做这类系统千万别把权限模型写死给运营人员留点调整空间能少接很多需求变更的“加急单”。5.3 学会给需求画边界大概每个搞高校信息化的人都会遇到这种情况平台刚上线领导说“能不能加个在线直播功能”隔壁学院说“能不能支持手机App端”人事处说“能不能对接一下职称评审系统”。每一个单独听上去都不难但全加起来就是一笔巨大的时间账。做这篇项目复盘时我想到一句很实际的话技术上可以做产品上更要做判断。在线直播的需求我们最终方案是让管理员上传直播录播视频手机App端需求我们评估下来发现手机浏览器上H5页面体验已经足够之前的课程页面完全适配移动端暂时不做Native App职称评审对接需求可以推后一期迭代因为当前学期的硬指标是学时的统计与导出。清晰可见的需求边界帮我们在“什么都想做”的氛围里保住了核心交付物的质量。平台上线两学期教师端几乎没有收到“想学没法学”的投诉管理端靠Excel导出功能彻底解放了学院教学秘书的期末统计工作这套系统算是真的用起来了。最后分享一个运维小技巧数据库备份脚本不要只备份.sql文件同时备份一次uploads目录里的视频资源。视频文件一旦损坏重新找老师录一套的成本远高于服务器存储的成本。我们写了一个简单的同步脚本每周五凌晨把uploads目录增量同步到一块备份盘。这个操作我强烈建议任何做在线学习类项目的人都加上别等到视频播放黑屏的那天才后悔。