管理员催学习记录、学生交截图、统计报表做得头大,这种事在高校里几乎每周都在重演。我这次做的“Vue+Spring Boot的青年大学习成果记录管理系统”,说白了就是把“有没有看、看了多久、截图交没交、积分加没加”这条琐碎链路全部线上化,让数据自己跑,让人别再当人肉Excel。项目采用前后端分离架构,前端用Vue 2 + Element UI,后端用Spring Boot 2.6 + MyBatis-Plus,数据库用的MySQL 8.0,身份认证走JWT + Redis,学习进度记录里还兼容了m3u8/HLS视频流的播放与时长上报。对正在做毕业设计、课设,或者想系统走一遍全栈项目从设计到落地的新人来说,这篇文章会尽量把你可能踩的坑直接帮你踩掉,从需求拆解到数据库建模,从接口设计到前端页面,再到联调部署,每个环节都会给出实际能用的方案。
1. 为什么选 Vue + Spring Boot,而不是其他组合
1.1 这个系统到底要解决什么问题
先别急着写代码,得先搞清楚业务现状。以前学习任务下发之后,最原始的统计模式就是:管理员在群里丢一个链接,大家看完之后截图发群,班干部挨个保存,再人工对着名单打钩。这个流程有三个痛点非常致命,第一是截图很容易造假,拿一张旧图或者别人的图顶替,管理员根本看不出来;第二是统计滞后,到截止日期了还有一堆人没交,班干部挨个催,催完还得改Excel;第三是数据无法沉淀,这个月谁学了多少、哪个年级完成率高、哪几个班长期垫底,完全没有一个历史数据仓库来做分析。
这个系统要解决的就是这三件事:学习过程可追溯、提交审核线上化、统计报表自动化。说白了,它不是要取代学习本身,而是把“学习之后那一堆记录、审核、统计的烂摊子”打包带走。学生在系统里看视频,前端通过定时器把观看进度实时上报,后端记录累计时长,达到阈值自动标记为完成。学生再补充提交学习截图,管理员在后台一键审核,系统自动累计积分。所有数据落库之后,ECharts看板直接出学院完成率、个人排行榜、历期趋势图。
1.2 前后端分离架构的考量
选前后端分离不是跟风,是因为这个系统天然就是“多端展示、单点管理”的场景。学生用手机浏览器访问前端页面比较多,管理员在电脑上操作后台,将来如果要做微信小程序,只需要复用后端API,前端重新写一套就行。如果做成传统的Thymeleaf模板方案,页面渲染逻辑和后端业务逻辑耦合在一起,小程序端没法接,接口也不好统计。考虑到系统里还要接入视频播放、进度上报、实时通知这类交互频繁的功能,分离架构的在开发期和维护期的收益都非常明显。
后端只负责纯API:接收JSON请求、校验参数、查库、返回统一响应。前端只负责交互和渲染:axios请求后端、Vue Router控制页面跳转、Vuex管理登录状态和用户信息。两边靠一套RESTful接口契约协作。契约定清楚之后,前后端可以并行开发,我这边后端还没写完,前端朋友就可以拿Mock数据先做着。这个对人的时间利用率、项目的推进速度,都有实际帮助。
1.3 技术栈的横向对比
先摆一下最终选的这套组合:
- Spring Boot 2.6.13:稳定的企业级版本,依赖管理省心,生态成熟,遇到坑基本都能搜到解决方案。
- MyBatis-Plus 3.5.x:单表CRUD不用写SQL,内置分页插件,代码量能少三分之一。复杂统计再用XML写原生SQL。
- MySQL 8.0:免费、稳定,满足这个业务量绰绰有余。
- Vue 2.6 + Element UI:Element UI对表格、表单、对话框、分页这类后台管理场景的支持非常顺手,能快速搭出可用后台。
- JWT + Redis:登录后颁发token,token无状态存储;用户权限信息放Redis做缓存,退出或重置密码能主动失效。
- ECharts:数据可视化报表。
有人可能会问,为什么不用Vue 3 + Element Plus?我统一解释一下:Vue 3的组合式API确实更好用,但很多应届生和课设场景里,Vue 2 + Element UI的资料量更大、坑更少,涉及视频播放M3U8、路由权限、导出Excel这些常见需求时,搜到的现成方案几乎都是Vue 2时代的。当然如果你熟悉Vue 3,结构上完全可以平移,核心的逻辑是不变的。后端的替代方案也有,比如若依框架自带代码生成器,开发速度更快,但它封装太重,很多初学者会迷失在框架的坑里,反而不利于理解原理。自己从零搭一套Spring Boot项目,虽然前期多花点时间,但是每一个配置你都清楚为什么这么写。
2. 需求拆解与数据库设计
2.1 核心业务流程梳理
这个系统有两种核心角色:学生和管理员。学生看任务、看视频、上报进度、提交成果、看自己的积分。管理员管任务、管用户、审核记录、看统计报表。如果学校分学院层级,还可以加一层院级管理员,但初期角色太多了会撑大工作量,我建议第一版只做两种角色,通过用户表里的role字段来区分,后面要扩展再改即可。
核心流程里最值得细说的是“学习成果上报”这一条线,整个状态机如下:
- 管理员发布新一期学习任务,上传视频地址、设置学习截止时间,系统生成一条learning_task记录。
- 学生在前端看到当前任务,点击“去学习”,进入视频播放页。
- 视频开始播放后,前端每10秒向后端上报一次当前进度,后端更新study_record里的watch_duration字段。
- 播放进度超过视频总时长的90%,系统自动把is_complete置为true。
- 学生选择上传学习截图,提交后进入待审核状态。
- 管理员在审核列表里查看记录和截图,点“通过”或“驳回”。
- 通过后系统自动增加学生积分,同时通过WebSocket给该学生推送一条“审核已通过”通知。
这条链路里的关键点在于:完成状态和审核状态必须是两个字段。一开始我偷懒,用一个state字段表示“未学习、学习中、待审核、已通过”,结果出现这种情况:视频看到一半截图先交了,或者视频看完了忘记提交,状态没法准确表达。后来拆成两个字段才彻底清爽。经验就是:一个字段只负责一个维度的状态,别贪多。
2.2 数据库表结构设计
数据库设计是这个项目里决定上限的部分。表与表之间不能乱,字段命名要有规矩,主键统一用自增id或者雪花id。我用的是自增id,因为并发量不大,自增简单且排序索引效率高。下面把核心表的字段放出来,都是可以直接落地的。
学生用户表(student):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_no | varchar(32) | 学号,登录账号,唯一索引 |
| password | varchar(128) | bcrypt加密后的密码 |
| name | varchar(32) | 姓名 |
| college_id | bigint | 所属学院,关联college表 |
| class_name | varchar(64) | 班级 |
| phone | varchar(20) | 手机号 |
| role | tinyint | 0学生、1管理员 |
| status | tinyint | 0正常、1停用 |
| create_time | datetime | 创建时间 |
管理员表没有单独建,我觉得就这个体量,一张用户表用role字段区分就够了。如果系统要做得更规范,或者管理员的字段和学生差异很大,再拆一张sys_user表也不迟,但先期别过度设计。
学习任务表(learning_task):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_no | varchar(32) | 期数编号,如“2024-01” |
| title | varchar(128) | 任务名称 |
| video_url | varchar(255) | 视频地址,兼容mp4、m3u8 |
| cover_url | varchar(255) | 封面图 |
| duration | int | 视频总时长(单位秒) |
| start_time | datetime | 开始时间 |
| end_time | datetime | 截止时间 |
| status | tinyint | 0草稿、1已发布、2已结束 |
学习记录表(study_record):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生id |
| task_id | bigint | 任务id |
| watch_duration | int | 已观看时长(秒) |
| total_duration | int | 视频总时长(秒) |
| complete_flag | tinyint | 0未完成、1已完成 |
| screenshot_url | varchar(255) | 学习截图地址 |
| audit_status | tinyint | 0待审核、1通过、2驳回 |
| audit_remark | varchar(255) | 审核备注 |
| audit_time | datetime | 审核时间 |
| create_time | datetime | 首次上报时间 |
| update_time | datetime | 最后上报时间 |
这张表是核心中的核心,注意watch_duration是前端定时上报累加出来的,不是后端自己计时。就是说用户暂停了、关页了,后端就以最后一次上报的数据为准。还要注意:同一对student_id和task_id只能存在一条主记录,重复上报走的是更新逻辑而不是新增逻辑,否则会产生大量垃圾数据。
积分表(score):简单一点,就是student_id、total_score、month_score、update_time。学习记录审核通过的接口里,同时去更新这张表。为了性能,审核通过是可以容忍偶尔丢一次积分的场景,直接UPDATE也可以,没必要用事务外层套太复杂。
学院表(college):college_name、sort_order。这张表在统计报表里会用到。
通知表(notice):student_id、content、status(0未读、1已读)、create_time。WebSocket推通知之前,先把消息落库,前端断线重连后还能拉取到未读列表,这个设计很重要,纯走WebSocket内存消息,用户刷新一下就丢了。
2.3 统一响应体与接口风格
后端接口我用了统一的Result 返回结构,避免一半接口返回JSON对象、一半返回字符串,前端处理起来很乱。结构如下:
{ "code": 200, "message": "success", "data": {} }code=200表示成功,401需要重新登录,500业务或系统错误。业务异常我统一用了@RestControllerAdvice做全局异常捕获,主动抛出的业务异常可以自定义code,但对外先统一用500即可。传参上,GET请求用@RequestParam,POST请求用@RequestBody,所有REST接口的路径都遵循资源式命名,比如:
- POST /api/auth/login
- GET /api/task/current
- POST /api/study/record
- POST /api/study/record/upload
- PUT /api/study/record/audit
- GET /api/stats/overview
- GET /api/stats/college-ranking
- GET /api/stats/export
统一响应体看起来是个很小的设计,但联调的时候真的能省掉特别多沟通成本。前端axios响应拦截器里只需要判断code,如果code=401就统一弹出“登录已过期”并跳转登录页,不需要每个页面单独写错误处理。
3. 后端核心模块实现
3.1 项目初始化与分层结构
工程结构我是按常见的四层来组织的:controller、service、mapper、entity。四层结构在项目初期看起来有点“重”,但等业务逻辑复杂起来以后,大家各归各位,找代码不迷路。实际创建的包结构如下:
com.example.youthstudy ├── common // 统一响应、异常处理、常量 ├── config // 配置类,如CorsConfig、WebSocketConfig、MybatisPlusConfig ├── controller // 接口层,只做参数接收和结果返回 ├── entity // 数据库实体类 ├── mapper // MyBatis-Plus Mapper接口 ├── service // 业务逻辑层,核心逻辑全在这 │ └── impl ├── utils // 工具类,如JwtUtil、FileUploadUtil └── dto // 接收前端参数的传输对象,避免直接用实体接收创建Spring Boot项目我用的是Spring Initializr,依赖勾选了Spring Web、MyBatis-Plus(这个从Maven中央仓库引入)、MySQL Driver、Lombok、Validation。pom.xml里几个关键依赖需要注意版本对齐:
- Spring Boot 2.6.13
- MyBatis-Plus 3.5.3.1(注意这个版本需要Java 8以上)
- jjwt 0.9.1(JWT生成与解析)
- hutool-all 5.8.x(各种工具方法,强烈推荐)
- easyexcel 3.3.x(导出Excel报表)
- spring-boot-starter-websocket
如果项目里要接Swagger接口文档,springfox 3.0.0用在Spring Boot 2.6.x上会有一个著名的坑:启动直接报空指针,因为Spring Boot 2.6默认的路径匹配策略从AntPathMatcher换成了PathPatternMatcher。解决办法是在application.yml里加一行:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher这行配置不加,Swagger一启动就挂。如果你不用Swagger,而是用Apifox或Knife4j,也同样要关注这个匹配策略问题。这是后端环境配置里最容易卡的坑之一。
3.2 JWT登录认证与权限控制
登录接口的逻辑:学生输入学号和密码,后端查库校验,通过则用用户ID、角色生成JWT token返回给前端,同时把用户基本信息放入Redis,key是token,value是用户对象,过期时间设为24小时。为什么JWT已经无状态了还要存Redis?因为系统里要支持“管理员强制用户下线”的场景,只靠JWT的过期时间是做不到的。Redis里存一份,后台改用户状态或者重置密码的时候把对应的token删了,立刻就能让那个token失效。
JWT的工具类我用jjwt封装,生成token时设置subject为用户id,放入role角色和过期时间。核心代码大致是这个逻辑:
public String generateToken(Long userId, Integer role) { Map<String, Object> claims = new HashMap<>(); claims.put("role", role); return Jwts.builder() .setClaims(claims) .setSubject(String.valueOf(userId)) .setExpiration(new Date(System.currentTimeMillis() + 86400000)) .signWith(SignatureAlgorithm.HS512, SECRET_KEY) .compact(); }注意SECRET_KEY不能写死在代码里,放到application.yml的配置项中,部署的时候通过环境变量覆盖。
接口鉴权我用了一个拦截器HandlerInterceptor,对所有/api/**请求做拦截,白名单放行/api/auth/login。拦截器里从请求头拿到Authorization,解析token,校验通过就把userId和role放到ThreadLocal里,业务层可以直接获取当前登录用户。这个ThreadLocal用完记得remove,否则Tomcat线程池复用会串数据。
只做登录拦截还不够,管理员接口和学生接口还得分开。我用自定义注解@RequireRole(role = 1)来实现,在拦截器里判断注解上要求的角色和当前用户角色是否匹配,不匹配直接返回403。如果没有这个设计,学生登录后直接构造一个/api/stats/export请求,就能把全学院的报表下载走,这是严重越权漏洞,必须堵死。
3.3 学习进度上报与审核接口
学习进度上报是系统里最频繁的写操作,学生播放视频期间每10秒打一次点。这个接口需要注意的地方是:不能让学生随便传一个watch_duration=999999就把任务刷完成。后端的校验逻辑是:前端每10秒上报一次当前进度点,后端只记录进度点的最大值,并且这个值不能超过视频总时长的100%。同时,后端还要判断两次上报时间间隔:如果间隔小于8秒,认为是异常请求,直接忽略更新。这样能在不搞太复杂的风控前提下,挡住大部分低成本刷数据。
核心更新接口service层大致逻辑:
StudyRecord record = studyRecordMapper.selectOne( new LambdaQueryWrapper<StudyRecord>() .eq(StudyRecord::getStudentId, studentId) .eq(StudyRecord::getTaskId, taskId)); if (record == null) { // 首次学习,创建记录,watchDuration为0 } // 只取更大的值 int newDuration = Math.max(record.getWatchDuration(), req.getWatchDuration()); if (newDuration >= totalDuration * 0.9) { record.setCompleteFlag(1); } record.setWatchDuration(newDuration); studyRecordMapper.updateById(record);这里为什么要Math.max而不是直接覆盖?因为网络请求有延迟,前端定时器10秒一次,但两个请求到达后端的时间顺序可能会乱,如果后面到的请求覆盖了前面的,就会把进度“改小”了。用Math.max就能保证单调递增,是这种场景的必备防抖设计。
审核接口是管理员专用的,需要加@RequireRole(role = 1)注解。审核时同时更新audit_status和audit_time,如果通过则加积分、发WebSocket通知。审核场景还有一个“批量通过”的常见需求,前端只勾选多个记录ID,后端用list接收批量处理。这里建议走事务,一个失败全部回滚,保证记录状态和积分数据的一致性。
3.4 视频存储与M3U8播放支持
视频文件有两种存法:一种是把mp4文件直接放到服务器或OSS,前端用普通video标签播放;另一种是为了应对大视频和弱网环境,把视频转码成HLS流(m3u8 + ts分片)。青年大学习这类内容,视频通常有几十分钟,如果用mp4直出格式,用户手机网络稍微差一点,拖动进度条会卡得很难受。我在这个项目里接入了m3u8播放方案,实测移动端兼容性很好。
后端不需要自己实现转码。我用的方案是将视频源文件上传到服务器后,通过ffmpeg转码成HLS格式。一条命令就能转:
ffmpeg -i input.mp4 -codec copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls output.m3u8这里-codec copy表示不重新编码,转码速度非常快,基本是文件拷贝的速度。hls_time 10表示每个ts分片10秒。ffmpeg转出来的产物是:一个m3u8索引文件和一堆ts分片文件。后端把m3u8地址存到learning_task的video_url字段,前端拿到之后用HLS播放器播放。
部署时,这里有个Nginx关键配置必须处理:m3u8和ts分片的访问要允许跨域,否则前端从自己域名请求视频地址,Nginx默认会拦掉OPTIONS预检请求。我踩过一次,播放器一直报跨域错误,排查了半天,最后在Nginx的location块里加了三行:
add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; add_header Access-Control-Allow-Headers '*';如果你有防盗链需求,再加valid_referers判断就行。学生端播放的时候,前端还会做“离开页面自动暂停+上报一次”的处理,避免用户看到一半直接关页,丢失最后一段进度。
3.5 数据统计与报表导出
数据统计是本系统里最能体现价值的部分。我用MyBatis-Plus写一个统计Service,底层用原生SQL做聚合。核心指标比如“某期任务各学院完成率”的SQL长这样:
SELECT c.college_name, COUNT(DISTINCT s.id) AS total_students, COUNT(DISTINCT CASE WHEN r.complete_flag = 1 THEN s.id END) AS completed_students, ROUND(COUNT(DISTINCT CASE WHEN r.complete_flag = 1 THEN s.id END) / COUNT(DISTINCT s.id) * 100, 2) AS complete_rate FROM student s LEFT JOIN college c ON s.college_id = c.id LEFT JOIN study_record r ON r.student_id = s.id AND r.task_id = #{taskId} GROUP BY c.id ORDER BY complete_rate DESC这里的LEFT JOIN和CASE WHEN的组合很关键。如果用INNER JOIN,没参与学习的学生会被直接过滤掉,统计结果就会虚高。LEFT JOIN能保证每个学院的所有学生都出现在结果里,没学的学生算到分母里但不进分子,这样得到才是真实完成率。
统计结果接口不直接返回图表数据,而是返回前端可以直接用的数据结构:每个学院一行,包含total、completed、rate。前端ECharts拿到后渲染柱状图和饼图。
导出Excel我用的是EasyExcel。为什么不用POI直接写?EasyExcel的流式写入方式对大数据的处理更友好,内存占用低。导出接口的逻辑很简单:查数据集合,用EasyExcel的write方法输出到HttpServletResponse的输出流,同时设置响应头让浏览器识别为附件下载。导出文件名要注意做一下URL编码,否则浏览器下载时中文名会乱码。
实际效果是:管理员在前端点“导出报表”按钮,浏览器直接下载一个带多Sheet的Excel,第一个Sheet是学院完成率汇总,第二个Sheet是每个学生每期任务的学习明细,第三个Sheet是积分排行榜。这个报表拿来直接给上级交差就很方便了,不需要再人工二次加工。
3.6 审核结果实时通知
审核结果的实时通知,当初是面试官追问“有没有做过WebSocket”时我想起来加的功能。Spring Boot集成WebSocket其实非常轻量,核心就三步:
- 引入spring-boot-starter-websocket依赖。
- 写一个WebSocketConfig配置类,注册WebSocketHandler。
- 写一个WebSocketHandler类,管理连接和消息推送。
我在系统里的用法很简单:学生前端登录成功后,建立WebSocket连接,连接地址里带上token。后端在握手阶段解析token拿到userId,用一个ConcurrentHashMap维护userId和WebSocketSession的对应关系。管理员审核记录时,审核通过后往该学生userId对应的session发送一条消息:“您在第X期学习中的成果记录已通过审核,积分已到账。”学生前端收到消息后弹一个通知,同时刷新当前积分显示。
这里要考虑WebSocket连接不稳定、手机锁屏断开的情况,所以我在通知表里做了落库。每次发送WebSocket消息之前先插入notice表,标记为未读。学生重新进入页面时不仅通过WebSocket接收实时消息,也会拉取所有未读通知补齐到页面顶部的小铃铛里。这种双通道设计保证了消息不丢,这个经验是从聊天系统里迁移过来的。
WebSocket配置还有个细节:Spring Boot默认的web socket端点和Spring MVC的DispatcherServlet对路径的拦截规则不同,如果项目里配了Shiro或者Spring Security,必须手动放行WebSocket握手请求。这个项目没引安全框架,只做了拦截器,所以只要放行/ws/**路径就行了。
4. 前端核心模块实现
4.1 初始化工程与目录结构
前端用Vue CLI创建工程,我习惯在创建完基础结构后重新梳理一下目录,因为脚手架默认生成的目录太散,后期维护不便。最终目录结构如下:
src ├── api // 所有接口请求定义,按模块拆分文件 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // Vuex状态管理 ├── utils // 工具类,如request.js axios封装 ├── views // 页面级组件 │ ├── login │ ├── dashboard │ ├── task │ ├── record │ ├── audit │ └── user ├── App.vue └── main.jsElement UI在main.js里全局注册,ECharts我按需引入,不整包引入,否则打包体积会大得离谱,首次打开要白屏好几秒。按需引入的方式是在一个独立的echarts.js文件里统一引入需要用到的图表组件,然后挂载到Vue原型上,页面直接this.$echarts访问。
axios封装是前端的基础设施,我在utils/request.js里做了统一处理:基础URL用环境变量区分开发/生产,请求拦截器里自动从Vuex或localStorage读取token放入Authorization请求头,响应拦截器统一解包Result,遇到code=401就清空登录状态并跳转登录页。这样业务代码里只需要写this.$http.get('/xxx'),拿到的就是data字段,不用每层都解包一次。
4.2 Vue Router路由设计与权限控制
前端路由有两层嵌套,外层叫Layout,包含顶部导航栏和左侧菜单。所有需要登录的页面都挂在Layout下。路由表里每个页面通过meta字段标记需要的角色:
{ path: '/audit', name: 'Audit', component: () => import('@/views/audit/index.vue'), meta: { title: '成果审核', roles: ['admin'] } }路由守卫写在router.beforeEach里,核心逻辑分三步:第一步判断本地有没有token,没有就跳转登录页;第二步判断store里有没有用户信息,没有就调用获取用户信息接口;第三步判断目标路由的meta.roles和当前用户角色是否匹配,不匹配就跳403页面。这个流程是后台管理系统最常见的标准做法。
这里有个实际开发容易踩的坑:动态路由或者说权限路由。早期我把所有路由一次性注册了,学生端也能访问管理员页面,虽然在接口层做了拦截,但页面看到了还是体验不好。如果要做“菜单根据角色动态生成”,要把路由拆成两部分:公共路由(登录页和Layout)在初始化时就注册,管理员和学生的业务路由分别定义成数组挂在菜单数据里,根据后端返回的role字段动态addRoute。这个方案看起来高级,但对初学阶段复杂度有点高。我做的是折中:路由全量注册,但侧边栏菜单根据角色过滤显示,用户手输地址访问倒是能进页面,但页面里的接口会返回403。对内部管理系统来说这个安全级别够用了。
4.3 视频学习页面与进度上报
视频播放器我用的video.js配合videojs-contrib-hls插件,同时兼容mp4和m3u8两种格式。页面上的核心交互不难,难的是把播放进度和上报时机处理好。
我定义了一个上报策略,统一在utils/reportProgress.js里封装:
- 监听video标签的timeupdate事件,每秒钟获取一次当前currentTime,维护一个lastReportSec变量。
- 当currentTime - lastReportSec >= 10秒时,向上报接口发起POST请求,把parseInt(currentTime)传给后端。
- 页面visibilitychange事件变为hidden(用户切走或锁屏)时,立即上报一次。
- 组件beforeDestroy时,立即上报一次。
- 上报使用axios默认的异步请求,不做阻塞等待。
视频播放页还做了“未登录不允许观看”的判断,路由守卫已经拦截了,但页面里还会再次检查store里的用户信息,因为路由守卫只能保证进入时的状态,用户刷新页面时Vuex数据会丢,需要重新拉取。
播放器初始化时还有个小细节:如果后端返回的m3u8地址需要携带token才能访问,那么video.src不能直接放视频地址,得用fetch先请求视频地址拿到带鉴权参数的URL,再赋值给播放器。不过这个项目里Nginx对视频做了公开访问,没有加鉴权,所以省了一步。如果做了防盗链,需要注意这个点。
4.4 数据看板与图表可视化
数据看板页面是管理员登录后的默认首页,我用ECharts做了四个模块:顶部四个统计卡片(总人数、本期完成率、平均积分、待审核数)、学院完成率柱状图、近六期完成率折线图、个人积分排行榜。图表数据全部来自/stats/overview和/stats/college-ranking接口。
柱状图渲染时有一个容易忽略的细节:容器div必须有明确的宽度和高度,否则ECharts初始化出来是一个0×0的画布,啥也看不见。我在mounted里调用this.$nextTick,确保DOM渲染完成后才初始化图表。另外一个经验是,页面里的图表在window.resize时要调用chart.resize()方法,否则浏览器窗口拉大后图表还是原来大小,显得很呆。我在destroyed钩子里一定要removeEventListener,同时调用chart.dispose()释放实例,否则多页面切换后内存泄露,页面越来越卡。
统计卡片的数字我用了一个vue-count-to组件做滚动动画,从0滚到目标数字,视觉效果好,代码也不复杂。这类小组件不用自己写,npm装一个用就行,重点还是把数据拉准、渲染对。
4.5 学习记录提交与我的中心
学生端页面相对简单,核心是两个:当前任务页和我的记录页。
当前任务页展示最新一期学习任务的信息:标题、封面、截止时间、视频时长。如果学生还没开始学习,显示“去学习”按钮;如果学完了但还没提交成果,显示“提交截图”按钮;如果提交了但还在审核中,显示“审核中”的标签;如果审核通过了,显示积分已到账。这个状态展示依赖学习记录接口返回的complete_flag和audit_status,前端根据这两个字段的组合渲染不同状态。这里的组合判断我在前端统一封装成了一个方法:
getStatusText(record) { if (!record) return '未学习'; if (record.auditStatus === 1) return '已通过'; if (record.auditStatus === 0 && record.completeFlag === 1) return '待审核'; if (record.completeFlag === 1) return '待提交'; return '学习中'; }我的记录页是一个列表,展示该学生所有历史学习记录,包括每期任务的完成情况、积分变化。列表用el-table渲染,分页用el-pagination,数据请求带pageNum和pageSize参数。每一行右侧放一个“详情”按钮,弹对话框展示观看时长、完成时间、审核备注等详细信息。
5. 联调、部署与高频问题排查
5.1 前后端联调阶段的环境配置
前后端分离联调时最大的坑是跨域。开发阶段前端跑在8080端口,后端跑在8081端口,前端请求/api接口被浏览器直接拦截。解决跨域有两条路线:一条是在后端加CorsConfig配置类,允许跨域访问;一条是在前端脚手架里配置proxy代理,让前端请求转发到后端。
我实际用的方案是后端加CORS配置,因为这样无论是本地联调还是将来部署到Nginx后面,接口行为都一致。CorsConfig的核心代码是注册一个CorsFilter,放行所有来源、所有请求头、所有方法,并允许携带凭证。注意allowedOriginPatterns("")和allowedOrigins("")的区别:如果允许携带Cookie,allowedOrigins不能写*,必须写allowedOriginPatterns,否则浏览器会报错。这个细节不注意到,联调时跨域问题排查半天都查不完。
联调阶段的另一个建议是准备一个ApiFox或者Postman的接口集合,后端每写一个接口就调通一个,把每个接口的请求参数和返回结果保存下来。联调时前端可以直接导入这个集合,快速看到接口的出入参格式,不用反复找后端对字段名。
5.2 部署上线与Nginx配置
部署时后端和前端分开部署。后端打包成jar包,服务器上装好JDK 8和MySQL,用systemd做进程守护。前端打包成dist目录,用Nginx托管。
Nginx配置有两个作用:一是托管前端静态文件,二是反向代理后端接口。一个完整的server块大致如下:
server { listen 80; server_name www.example.com; # 前端静态资源 root /opt/youthstudy/dist; index index.html; # 解决Vue Router history模式刷新404 location / { try_files $uri $uri/ /index.html; } # 后端接口代理 location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 视频分片与上传文件访问 location /uploads/ { alias /opt/youthstudy/uploads/; add_header Access-Control-Allow-Origin *; } }location /里的try_files配置是Vue Router history模式必须配的,否则用户在某个子路由下刷新,Nginx找不到对应的文件,直接返回404。如果不写这行,前端页面一刷新就白屏,这个问题在第一次部署时几乎必踩。
Nginx还有一个坑是默认上传大小限制1MB,系统中学生要上传截图,管理员要上传视频,图片都传不上去。需要在http块里加入client_max_body_size 50m,视视频大小调整。不调这个配置,前端上传大文件时会报413 Request Entity Too Large,第一次遇到时容易懵。
5.3 高频问题速查表
把开发过程中遇到的高频问题整理成一张表,方便遇到同样问题的同学直接对照排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| Spring Boot启动报错,Swagger空指针 | Spring Boot 2.6默认PathPattern匹配策略和springfox不兼容 | application.yml加spring.mvc.pathmatch.matching-strategy=ant_path_matcher |
| 后端启动时数据库连接失败 | MySQL未启动或连接串少了时区参数 | 连接串加?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai |
| 前端请求接口报CORS错误 | 后端未配置跨域或配置写错 | 检查CorsConfig,allowedOriginPatterns不能和allowCredentials同时用* |
| 上传图片报413 | Nginx默认限制上传大小1MB | http块里加client_max_body_size 50m |
| 下载Excel文件名中文乱码 | 响应头Content-Disposition未做URL编码 | 用URLEncoder.encode对文件名编码 |
| 视频播放M3U8报跨域 | Nginx返回ts分片时缺少CORS头 | location /uploads/里加add_header Access-Control-Allow-Origin * |
| 审核通过后学生端没有及时收到通知 | WebSocket连接断开或没有落库 | 确认前端建立了ws连接;确认通知落库,页面刷新自动拉取未读 |
| 刷新前端页面后菜单/用户信息丢失 | Vuex是内存态,刷新即丢失 | 在路由守卫里判断store为空时调用获取用户信息接口重新填充 |
| 学生不断上报进度,后台数据异常大 | 前端定时上报频率太高、后端没做频率限制 | 后端校验两次上报间隔不低于8秒,超过就忽略 |
| 多标签页打开系统,WebSocket重复连接 | 没有做连接去重 | 前端关闭旧连接再新建,或后端按userId替换已有session |
最后再分享一个我在这个项目里体会最深的一点:技术栈选型本身不太能拉开差距,真正让系统好用的,是那些容易被忽略的细节设计,比如审核状态和完成状态分开存、进度上报做幂等和防抖、消息推送做落库兜底、Excel导出做多Sheet。把这些细节一个个抠扎实,系统就算不华丽,也会非常“抗用”。如果你准备照着做一版,建议先把业务状态机画清楚,再动手写代码,后面会省下大量改表结构的返工时间。