ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue的教师工作量管理系统:从Excel混乱到在线闭环

SpringBoot+Vue的教师工作量管理系统:从Excel混乱到在线闭环

每年年底的教学秘书办公室,几乎都会被一堆命名诡异的Excel文件淹没:工作量统计-最终版.xlsx、工作量统计-改3-千万别动.xlsx、2024课时(真·最终版).xlsx。更让人崩溃的是,任课老师自己统计的课时数和教务处认定的对不上,科研积分和院办核对的又不一致,来回撕扯两三个星期都是常态。我接手这个教师工作量管理系统的需求时,第一反应就是:这玩意儿要是不用系统管,光靠Excel和微信传文件,迟早要出大问题。

这套系统的技术选型比较常规稳健:后端用SpringBoot,配合MyBatis操作MySQL,前端用Vue做单页应用。整体解决的核心问题很简单——把教师工作量从"申报-核对-统计-反馈"这条手动链路,变成"录入-审核-统计-导出"的在线闭环。适合正在做管理类毕设项目的同学、需要给学校或院系搭工作量系统的开发者,以及想了解SpringBoot+Vue前后端分离项目完整落地过程的人参考。下面我把从需求梳理、数据库设计到部署联调的全过程拆开讲。

1. 工作量统计到底难在哪:需求是这样一步步逼出来的

1.1 一张Excel引发的"互相不认账"

最开始和对接的教务老师聊需求时,对方对系统的期待非常简单:"能统计出每个老师这个学期干了多少活就行。"但等我坐到工位上仔细看他们现有流程,才发现工作量远不是"统计"两个字能概括的。

他们目前的手工流程大概是这样的:学期末发通知,要求老师把课时、监考次数、论文指导人数、竞赛获奖情况填写在统一的Excel模板里,然后交到系里;教学秘书把所有老师填的表格收齐后合并成一张总表,再去教务处核对课时标准,找科研秘书核对课题和论文积分;中间一旦有老师反馈"我这个表填错了""我少报了一个班",就得重新收一轮文件。等到院里领导要数据,秘书还得手动按教研室、按职称、按学期做透视表。

这套流程的问题非常多,但最核心的是三点:第一,Excel文件通过微信和邮件传来传去,版本极容易混乱,经常出现合并时用了过期版本;第二,公式可以被随意修改,但没有人知道是谁改的、为什么改;第三,所有核对都依赖教学秘书的个人经验,一旦换了人,很多认定口径就不清楚了。

1.2 三类用户三种诉求:同一个系统要满足三种完全不同的人

这个需求梳理到最后,我确认这个系统里实际上有三类角色,它们的诉求差异极大:

  • 任课教师:最关心"我填了什么、审核到哪一步了、为什么被驳回"。他们要能自助录入工作量、随时查看审核状态,尽量不要隔三差五去问教学秘书。
  • 教学秘书:最关心"汇总是否准确、谁还没交、怎么快速出表"。他们要批量催办、批量审核,还要能修正错误记录,但所有修正必须留下痕迹。
  • 院系负责人:最关心"整个学期的总量是多少、各教研室之间怎么分布"。他们需要的是按学期、按教研室、按项目类型查看统计结果,并能导出格式化的报表。

需求方的第一位是教学秘书,但真正每天用系统的人其实是普通老师。如果一个老师每填一项工作量都要多点好几个页面、操作又卡又慢,那系统上线第一周就会被弃用。所以后面我在前端交互上做了很多针对"懒人"的优化,这一点后文会详细讲。

1.3 功能模块清单:不盲目做全,先抓住主流程

结合需求,最终确定的功能模块如下:

模块核心功能使用角色
用户与角色管理账号维护、角色分配、重置密码系统管理员
工作量类型管理项目类型配置,如课堂教学、毕业论文指导、监考、竞赛指导等管理员
工作量申报教师录入工作量、保存草稿、提交审核教师
工作量审核教学秘书逐条审核、批量通过、驳回并填写原因教学秘书
统计查询按学期/学年/教师/教研室/类型汇总统计教师/秘书/负责人
报表导出导出Excel格式总表与明细表教学秘书/负责人

这里我做了一个刻意取舍:没有做和教务系统、科研系统的数据自动对接。原因很简单,学校里的异构系统太多,各系统的数据口径、更新时效都不一样,强行做实时同步投入产出比极低。人工申报加系统校验,再加上审核留痕,才是这类项目最现实、也最能落地的方案。

2. 数据库设计:工作量不是简单把数字加起来

2.1 核心表划分:一眼就能看明白的五张表

数据库设计是整个系统的地基。我第一版想着把用户、角色、工作量项目、记录、学期全塞进一张大表里,后来被自己否了——工作量记录会随着学期增长呈线性膨胀,如果和用户配置类数据混在一个表里,后续查询和扩展都是灾难。

最终拆成了这几张核心表:

表名作用关键字段
sys_user用户表id、username、password、real_name、dept_id、status
sys_role/sys_user_role角色及用户角色关联id、role_code、role_name
work_type工作量项目类型表id、type_name、unit、default_base_score、status
academic_term学年学期表id、academic_year、term_name、start_date、end_date、status
work_record工作量申报主表id、teacher_id、term_id、type_id、title、total_hours、status
work_record_item工作量明细表id、record_id、description、quantity、score、audit_remark
work_rule计算规则配置表id、type_id、level、coefficient、formula

最开始我也会纠结要不要做一个通用的"表单字段配置表",让工作量项目可以动态扩展字段。但后来想想,这个系统的使用者和维护者大概率是学校信息技术老师,搞得太抽象反而没人会配。老老实实用固定表结构,配合合理的关系拆分,比花里胡哨的动态模型可靠得多。

2.2 为什么把"原始申报"和"认定明细"拆成两张表

这是这个数据库设计里我觉得最值得说的一点。

最初我的设计很简单:一张work_record表,字段包括教师ID、学期ID、类型ID、工作量分值、审核状态。但做了一圈需求分析后发现问题:一条"工作量申报"往往包含多个明细。比如一个老师申报"指导毕业论文",一次申报可能同时包含本科生论文10篇、研究生论文5篇,两者认定标准还不一样。如果硬塞在一条记录里,要么得增加冗余字段,要么审核时只能整体通过或整体驳回,非常不灵活。

拆成主表和明细表后,一条申报记录对应多条明细,审核时教学秘书可以逐条处理,遇到"学生名单填错了"这种问题,直接驳回对应的那一条明细并写明原因,其他明细照常通过。这种"部分驳回"能力在Excel时代几乎没法实现,是系统相对手工流程的一大优势。

同时在work_record主表上保留一个total_hours字段作为冗余统计值,明细表变化时由后端重新汇总写入。这样做的一个实际好处是:统计页查询时不需要频繁做明细表的SUM聚合,数据量上来后查询性能差别还是挺明显的。

2.3 学年学期隔离设计:别让数据跨学期串掉

工作量统计强依赖时间维度。老师申报的是2024-2025学年第一学期的工作,就不能被统计到第二学期里去。我的做法是建academic_term表,每个学期一行记录,所有工作量记录强制携带term_id,而不是直接存"2024-2025-1"这么一段字符串。

为什么要多一层表而不是直接存字符串?因为不同学期可能有不同的开启、关闭状态。学期可以配置为"申报中""审核中""已归档",归档后前端默认不再允许修改,这比用字符串硬编码去判断当前是哪个学期灵活得多。另外,查询统计时按term_id关联能走索引,比乱用like '%2024%'快得多。

核心的复合索引我建了两个:一个是(teacher_id, term_id, status),用于教师查自己的申报记录;另一个是(term_id, type_id, status),用于按学期按类型统计。MySQL在复合索引最左前缀规则下,这两个索引基本能覆盖绝大多数查询场景,没必要再多建乱七八糟的单独索引。

3. 后端从登录到接口的完整链路:SpringBoot怎么把流程串起来

3.1 登录认证:不引Spring Security,拦截器加Token就够用

项目技术栈是SpringBoot 2.7、MyBatis、MySQL 8。很多参考项目一上来就直接上Spring Security,但我的选择是只用JWT加一个HandlerInterceptor,把登录和角色控制控制在"够用"的粒度上。

原因很简单:这个系统只有三种角色,权限模型就是最普通的RBAC(用户-角色-菜单/接口),不需要Spring Security那一大套过滤器链和复杂的配置。对做管理系统的同学而言,自己实现一个Token拦截器,反而更能看清认证的底层逻辑,后面出问题排查起来也快。

JWT部分我用的是常见的jjwt库,登录成功后签发Token,有效期设为8小时,Token里只放userId和roleCode,避免敏感信息。拦截器里的逻辑大致是:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (!token.startsWith("Bearer ")) { throw new AuthException("未登录或Token缺失"); } Claims claims = JwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("roleCode", claims.get("roleCode")); return true; } }

用户点击退出后Token在客户端丢掉即可,服务端不做额外状态管理,这对工作量系统这种低并发场景非常合适。如果后面要维护多端登录状态,再换Redis存储也不迟。

3.2 统一返回结果和异常处理:接口层最容易被忽略的部分

项目里的所有接口返回统一使用Result<T>结构,包含code、message、data三个字段。同时用@RestControllerAdvice做了全局异常处理,业务异常统一抛BizException,异常时返回带错误信息的Result。

举例来说,教师提交审核时如果当前学期已经归档,就会抛一条"当前学期已归档,不能提交申报"的提示。如果没有全局异常处理,这些错误信息很容易散落在各个Controller里,甚至直接返回500页面。统一异常处理器带来的好处是前端拿到的永远是同一结构,错误提示弹窗逻辑只需要写一遍。

这一点看起来基础,但很多项目就是因为接口返回结构不统一,前端联调时每个接口都要单独写解析逻辑,严重拖慢进度。

3.3 MyBatis动态SQL:多条件统计查询的重头戏

管理系统最核心的查询就是"按各种条件组合筛选"。比如统计页要支持按学期、按学院、按教师姓名、按工作量类型、按审核状态五个条件任意组合查询。如果用JDBC手动拼接SQL,字符串拼接地狱能让人写到崩溃,更可怕的是容易漏掉过滤条件导致SQL注入。

MyBatis的<where>配合<if>标签能优雅解决这个问题:

<select id="selectWorkloadStat" resultType="map"> SELECT u.real_name AS teacherName, d.dept_name AS deptName, t.type_name AS typeName, SUM(i.score) AS totalScore FROM work_record r LEFT JOIN work_record_item i ON r.id = i.record_id LEFT JOIN sys_user u ON r.teacher_id = u.id LEFT JOIN work_type t ON r.type_id = t.id LEFT JOIN sys_dept d ON u.dept_id = d.id <where> <if test="termId != null"> AND r.term_id = #{termId} </if> <if test="deptId != null"> AND u.dept_id = #{deptId} </if> <if test="teacherName != null and teacherName != ''"> AND u.real_name LIKE CONCAT('%', #{teacherName}, '%') </if> <if test="typeId != null"> AND r.type_id = #{typeId} </if> <if test="status != null"> AND r.status = #{status} </if> </where> GROUP BY r.teacher_id, r.type_id, u.real_name, d.dept_name, t.type_name ORDER BY totalScore DESC </select>

<where>标签的聪明之处在于,如果所有条件都不成立,它不会生成多余的WHERE关键字;如果第一个条件成立,它会自动把前面那个多余的AND去掉。这个细节解决了动态SQL拼接时最容易出现的语法错误。

另外统计结果用的是Map接收,因为汇总查询的结果结构并不固定。写代码时我会把这类统计SQL放在独立的Mapper里,不在Controller里解析Map的字段,而是统一转成前端需要的DTO,避免前端拿到一堆命名随意的字段。

3.4 审核状态机:不能让状态乱跳

工作流的本质是状态机。我的申报状态设计为:草稿→待审核→已通过/已驳回。驳回后老师可以修改再提交,重新回到待审核;学年学期归档后,所有未完成记录强制置为已驳回并锁定。

状态流转的合法性判断我放在了Service层,而不是Controller里:

public void audit(Long recordId, String action, String remark) { WorkRecord record = workRecordMapper.selectById(recordId); if (record == null) { throw new BizException("记录不存在"); } // 已归档学期禁止编辑 Term term = termMapper.selectById(record.getTermId()); if (term.getStatus().equals(TermStatus.ARCHIVED)) { throw new BizException("当前学期已归档,不能执行审核操作"); } if ("APPROVE".equals(action)) { if (!WorkStatus.PENDING.equals(record.getStatus())) { throw new BizException("只有待审核状态才能通过"); } record.setStatus(WorkStatus.APPROVED); } else if ("REJECT".equals(action)) { record.setStatus(WorkStatus.REJECTED); record.setAuditRemark(remark); } workRecordMapper.updateById(record); }

这种显式的状态判断看起来笨重,但好处是逻辑直白。系统的审核操作只有几种,用if判断完全够了,如果硬要套一个状态机框架反而增加维护负担。只要保证"待审核"是唯一可流转的中间状态,流程就不会乱。

4. 工作量计算规则:把"老师都懂但系统不懂"的标准变成可配置数据

4.1 规则表拆解:一门课、一场监考、一篇论文到底值多少分

工作量系统最微妙的地方在于"计分标准"。我走访对接的老师后发现,工作量标准不是一条全校统一的公式,而是一张充满各种特例的对照表:

  • 课堂教学:基本课时 × 课程系数,专业课、通识课、合班课的系数不一样。
  • 监考:一场考试固定计2个学时,但期末集中监考和补考监考占比不同。
  • 论文指导:指导一篇本科毕业论文计5个学时,硕士论文计8个学时。
  • 竞赛指导:按竞赛级别(国家级、省级、校级)和获奖等级(一等、二等、三等)对应不同分值,而具体值每年可能微调。

如果把这些标准写死在代码里,每次调整都要改代码、重新部署,非常痛苦。我的做法是建一张work_rule规则表:

字段说明示例
type_id对应工作量类型监考
level类型下的级别,可为空期末集中监考
base_score基础分值2.0
coefficient系数1.2
formula自定义表达式,可选base * count
is_active是否启用1

4.2 计分表达式的落地方式

计算逻辑我没有引入复杂的规则引擎,因为引入一个Groovy脚本引擎或者Drools对这个小项目来说太重了,维护成本反而高。

我的实现思路是:大多数工作量项目都可以用基础分值 × 数量 × 系数这种统一公式覆盖。比如"监考2场,每场2分",就是base_score=2、quantity=2、coefficient=1,得分 = 4。竞赛指导这类"级别决定分值"的场景,则在work_type下面拆出多个带有不同level和base_score的规则行。

在代码里体现为:

WorkRule rule = workRuleMapper.selectActiveRule(typeId, level); if (rule == null) { throw new BizException("未找到工作量类型对应的计分规则,请联系管理员配置"); } BigDecimal score = rule.getBaseScore() .multiply(BigDecimal.valueOf(item.getQuantity())) .multiply(rule.getCoefficient());

注意这里我特意用了BigDecimal而不是double,因为工作量分值可能涉及小数,比如一篇论文5.5学时,用浮点计算容易出现0.1+0.2不等于0.3的问题。凡是涉及学时的字段,全部用DECIMAL(10,2),这个经验是吃过亏才记住的。

4.3 特殊情况与人工调整通道

再完善的规则也有覆盖不到的时候。比如两个老师共同指导一个毕业设计,工作量按6:4拆分;比如某门课中途换了老师,需要把课时按月份比例切分;再比如临时安排的政治学习活动,不属于任何预设工作量类型。

我的处理方案是:允许管理员在工作量明细中做人工调整,调整记录写入一个work_record_adjust表,字段包括记录ID、原分值、调整后分值、调整原因、操作人ID、操作时间。这样既能兜底特殊情况,又保留了完整的审计痕迹,将来有老师对分数有异议,能追溯到是谁在什么时候调的、为什么调。

这一点是很多管理系统容易忽略的地方。系统越严格,越需要在边缘场景保留人工兜底通道,否则业务根本跑不动。

5. Vue端页面:让老师愿意用,比让系统"高级"更重要

5.1 技术选型与登录路由设计

前端我用的Vue 3加Element Plus,状态管理用了Pinia,HTTP请求用的Axios。如果你的参考源码是Vue 2加Element UI,思路也完全一致,无非是API细节上有差异。

前端不是简单的"页面集合",它同样有权限控制需求。登录后后端会返回用户角色,前端根据角色动态生成菜单。路由层面用router.beforeEach做全局守卫:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() return } if (!token) { next('/login') return } const roles = JSON.parse(localStorage.getItem('roles') || '[]') if (to.meta.roles && !to.meta.roles.some(r => roles.includes(r))) { next('/403') return } next() })

这里有个容易踩的细节:如果只在前端做菜单隐藏,用户依然可以手动敲URL访问没有权限的页面。前端路由守卫只是提升体验,真正的权限安全必须靠后端接口拦截,两者配合才是完整方案。

5.2 工作台:不同角色的首屏体验

登录后的首屏我设计了三套:

  • 教师登录后,首屏是我的工作台:显示本学期已填报工作量总分、待审核条数、被驳回条数,以及最近几条审核消息。
  • 教学秘书登录后,首屏是待办审核列表:按提交时间倒序展示所有待审核的记录,可直接批量通过或逐条驳回。
  • 院系负责人登录后,首屏是统计概览:当前学期的全院工作量总量、同比上期变化、各教研室占比。

技术含量不高,但极其影响使用意愿。教务老师第一次看到待办清零的界面时,明显比看到一堆复杂菜单更兴奋。一个管理系统好不好用,很多时候不在功能多少,而在核心任务是否触手可及。

5.3 工作量录入与批量Excel导入

手工逐条录入对老师来说还是太烦,因为工作量大头是重复性条目,比如每周同一门课、固定监考场次。我在录入页做了一个"复制上期数据"的按钮,老师可以选择上一学期的申报记录一键复制到本学期,再修改变化的部分。这个功能其实后端就一个查询加批量插入,但非常受欢迎。

另外考虑到学校大量老师习惯先在家填Excel,我支持了批量导入。前端用xlsx库解析Excel文件,把数据转换成JSON数组后调用后端批量导入接口;后端逐行校验,返回错误信息时定位到具体行号,例如"第12行:监考场次必须是正整数"。

这里必须提醒一句:前端解析Excel只能做格式预检,真正的数据校验必须放在后端。比如"同一学期不能重复申报同一类型同一项目"这种逻辑,前端无法可靠保证,只能靠后端SQL和事务兜底。

5.4 统计看板与导出

统计页用ECharts做了几个核心图形:按工作量类型的柱状图、按教研室的饼图、按教师排名的横向条形图。这些图表的底层数据都来自同一个统计接口,前端拿到的已经是聚合好的结果,不在浏览器里做二次聚合计算。

导出Excel是使用后端接口在服务端生成文件。有段时间图省事想在前端直接用表格转Excel,但试了几次发现格式控制很难,而且数据量大时前端容易卡死。最终方案是后端用EasyExcel或Apache POI生成文件,前端通过window.open直接下载,整个过程稳定可靠。

6. 部署联调实录:那些文档里不会写的坑

6.1 数据库初始化和连接配置

这个项目我在本地和服务器上分别部署过。本地开发还好,真正部署到服务器时遇到的第一坑就是MySQL驱动和连接参数。

旧版MySQL 5.x用的驱动类是com.mysql.jdbc.Driver,MySQL 8之后换成了com.mysql.cj.jdbc.Driver。如果你的项目用的MySQL 8,driverClassName里还写老驱动,启动时会直接报ClassNotFoundException。同时连接URL里建议加上useSSL=false和serverTimezone=Asia/Shanghai,否则要么出现SSL警告,要么日期时间不对,白白排查一晚上。

另一个细节是数据库建库时字符集一定要用utf8mb4,不然老师录入的姓名、课程名中的生僻字大概率变成乱码。初始化SQL脚本里我明确写了:

CREATE DATABASE IF NOT EXISTS workload_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

6.2 跨域问题与代理配置

前端开发服务器默认跑在9528端口,后端在8080端口,请求必然跨域。解决跨域有两条路:

  • 后端通过WebMvcConfigurer配置CORS映射。
  • 前端通过开发服务器代理转发/api到后端。

我实际用的是"前后端双保险":开发环境靠Vite的proxy,生产环境靠Nginx的proxy_pass,后端也开了CORS兜底。Nginx配置看起来很常规,但容易出错的点在于要同时处理好WebSocket和前端路由刷新404的问题。

6.3 三个极易出错的隐蔽细节

第一个是前端路由刷新404。Vue是单页应用,路由实际是前端JS控制的,刷新页面时浏览器会请求对应URL的后端路径,如果不配置try_files,Nginx会返回404。必须在Nginx的location配置里加上:

location / { root /opt/workload-front; index index.html; try_files $uri $uri/ /index.html; }

第二个是Excel导入时手机号、学号这类长数字被自动转成科学计数法。这个需要在解析Excel时把单元格格式强制转成字符串再处理,同时前端解析也要注意。第三个是工作量分值的精度问题,前面提过的BigDecimal不能换成double,否则统计汇总后会出现4.799999这种诡异数字。

6.4 一次真实的500错误排查过程

最后分享一次联调时印象深刻的排查。当时"统计接口偶发返回500",一开始完全摸不着头脑,因为同一个接口多调几次偶尔正常。后来我翻后端日志,发现错误信息是Incorrect datetime value,再往前查SQL,发现问题出在一个按月份分组的统计SQL上,里面用了DATE_FORMAT(work_date, '%Y-%m'),而MySQL的连接时区和服务器的系统时区不一致,导致边界时间转换异常。

排查链路是这样的:前端报500 → 看后端日志定位到SQL异常 → 查MySQL时区参数 → 用show variables like '%time_zone%'确认是SYSTEM→ 在连接URL的serverTimezone改为Asia/Shanghai并重启,问题消失。这类问题在本地开发时几乎不会出现,因为连接参数一直没设置,正好本地机器时区和数据库一致,等到部署服务器才暴露。

顺带一提,工作中遇到偶发性报错,优先怀疑并发的资源竞争、时区、字符集这类"环境因素",而不是一上来就改业务代码。把日志完整拉下来,很多时候答案就在异常堆栈里。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进