去年给某高校信息中心做教师继续教育平台的时候,对方一开始给我看了一份需求文档,里面赫然写着“参考某某学习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.js(Express框架),同样是基于实际场景的考虑:
- 平台预期的并发量并不高,几百个教师同时在线观看视频、偶尔并发考试,这个负载对于单机Node进程绰绰有余。
- Node.js开发效率极高,前后端都用JavaScript,数据结构和接口定义可以直接复用,联调成本低。
- 服务器只需要装一个Node运行时和一个Nginx,部署链路短,后期维护门槛低。
数据库方面选了MySQL 8.0,没有用更轻量的SQLite或MongoDB,主要考虑到高校信息中心通常对MySQL的运维更熟悉,且后续如果需要对接校内统一身份认证、人事系统等,MySQL更容易做数据集成。
2.2 工程初始化与目录设计
前端工程用Vite初始化,命令很简单:
npm create vite@latest 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 数据表设计:六张核心表就够用
项目里最终保留了六张核心业务表,外加几张辅助表(公告、系统配置)。由于标题里提到“设计”,这里把表结构的关键字段列一下,方便有类似需求的朋友直接参考:
| 表名 | 主要字段 | 说明 |
|---|---|---|
user | id, name, employee_no, college, department, role, phone, password | 教师和系统管理员共用,role区分 |
course | id, title, cover, category, teacher, description, status, credit_hours, start_time, end_time | status区分草稿、报名中、学习中、已结束 |
lesson | id, course_id, title, video_url, document_url, duration, sort, is_required | 一门课程包含多个课时 |
learn_record | id, user_id, lesson_id, watched_second, total_second, is_completed, last_watch_time | 每分钟更新一次学习进度 |
exam | id, course_id, title, question_config, pass_score, duration_minutes, start_time, end_time | 考试配置,题目用JSON存储 |
exam_record | id, 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; filename=hours-${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的SameSite=Lax设置会影响体验。我最后的配置是显式设置Cookie属性:
res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'lax', secure: false // 校园网内http协议下必须为false });secure这里尤其要小心。高校内网很多页面走的是HTTP而不是HTTPS,如果把secure设为true,Cookie在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目录增量同步到一块备份盘。这个操作我强烈建议任何做在线学习类项目的人都加上,别等到视频播放黑屏的那天才后悔。