1. 先说清楚:这套系统到底解决什么问题
每次到学期末,教务处和院系教学秘书最头疼的事就是统计教师工作量。上课学时、实验学时、指导毕业设计、批改作业、监考、出版教材、发表论文……这些零散的数据散落在各个教务系统、Excel表格甚至纸质单据里,统计起来费时费力,还容易出错。老师自己也不知道最终分数怎么算出来的,经常因为漏报、误报产生矛盾。
教师工作量管理系统就是来解决这个核心痛点的。它本质上是一个业务型管理软件,围绕"工作量填报—审核—核算—统计—导出"这条业务主线,把原来Excel传文件、微信发消息的人工流程搬到Web端,变成规范化的线上流程。这套基于Java SpringBoot + Vue3 + MyBatis的前后端分离版本,算是当下中小型管理系统中非常典型、非常成熟的一套技术选型。
为什么这个组合值得聊?因为它足够主流。SpringBoot负责后端接口和业务逻辑,Vue3负责前端页面交互,MyBatis负责数据库操作,MySQL存数据。这套技术栈在高校信息化、企业OA、教务管理等领域覆盖率极高,网上资料多、招人好招、出了问题也容易排查。对开发者来说,拿来练手、做毕业设计、给学校或小公司做内部工具,都是性价比很高的选择。
这篇文章不是把源码贴一遍就完事,而是按照我实际开发这类系统的经验,把系统设计、数据库建模、后端接口、前端页面、权限控制、计算规则这些关键环节一个个拆开讲清楚,顺带把容易踩的坑也交代一遍。想复现这套系统的,可以直接按照里面的步骤动手。
2. 整体设计思路:为什么做成前后端分离,模块怎么划分
2.1 前后端分离的取舍逻辑
先说结论:这类系统做成前后端分离,不是为了赶时髦,而是真的有收益。
教师工作量管理的用户分三类:普通教师、教研室/系部审核人、教务处管理员。三类角色用的功能差异很大,老师要填表、查进度,审核人要审批、打回,管理员要配置指标、看统计报表。如果做成传统单体JSP页面,前端逻辑和后端逻辑耦合在一起,每一次需求调整都要同时改前后端,发布还要重启整个应用。前后端分离之后,后端专注提供接口,前端专注页面交互,两边可以并行开发,互不阻塞。
另一个实际好处是部署灵活。后端打成一个Jar包,前端构建后丢到Nginx里托管,甚至可以分开部署到不同服务器。学校信息中心那帮人最喜欢这种结构,出了问题定位方便,前端白屏就看Nginx和浏览器控制台,后端报错就看日志,不用像以前那样一头扎进一堆JSP里翻。
2.2 模块边界怎么划
我习惯把这种系统拆成五大模块:
- 基础数据模块:教师信息、院系信息、学期管理。这是所有业务的底座,教师信息不同步好,后面填报和统计全是乱的。
- 工作量填报模块:教师填报本学期的教学工作量、科研工作量、其他工作量。这里的关键是支持多条目填报,而不是老师只能填一个总数。
- 审核管理模块:教研室初审、院系复审、教务处终审,三级审核链路。每一步都可以通过或退回,退回要填原因。
- 指标与计算模块:管理员维护工作量指标(比如课时费系数、论文加分标准),系统根据填报数据+指标配置自动计算总工作量。
- 统计导出模块:按院系、按学期、按教师维度做统计报表,支持导出Excel给人事处用。
这五个模块不是拍脑袋分的,而是跟着业务流程走的。我早期做类似系统时贪快,把审核逻辑写在填报接口里,结果每次权限调整都要动核心代码,后来痛定思痛才把业务边界理清楚。模块独立的好处,改统计不影响填报,改指标不影响审核,后续维护省心太多。
2.3 技术栈选型为什么是这套
SpringBoot选Vue3、MyBatis而不是其他组合,核心原因是这套搭配在中小型管理项目里太成熟了,踩坑成本最低。
SpringBoot自带内嵌Tomcat,一个Jar包跑起来,不用额外装容器;自动配置能力强,写接口效率比传统SSH那一套高好几个量级。Vue3的Composition API让组件逻辑复用更优雅,一个工作量填报表单组件可以在多页面复用。Element Plus组件库把表格、表单、树、弹窗这些管理系统的常客都封装好了,界面做出来不会太丑。MyBatis则是写复杂统计SQL的利器,工作量核算这种多表关联、条件聚合的查询,用MyBatis的XML映射文件控制SQL比JPA那种自动生成的SQL可控得多。
数据库用MySQL,是因为这类系统的数据量撑死几十万条,MySQL完全够用,而且高校、中小企业普遍都在用它,部署维护成本低。如果哪天数据量真的大到MySQL扛不住,那也是几年后的事,到时候再迁移也不迟,现在没必要给自己上重量级中间件增加复杂度。
3. 数据库设计:工作量系统的表结构怎么建模
3.1 核心表有哪些
数据库是整个系统的地基,地基打不好,上层业务全是空中楼阁。我按模块拆解一下核心表:
部门表(sys_dept):dept_id、dept_name、parent_id、sort_order 用户表(sys_user):user_id、username、password、real_name、dept_id、role_type 角色表(sys_role):role_id、role_name、role_key 学期表(biz_semester):semester_id、semester_name、start_date、end_date、status 工作量指标表(biz_indicator):indicator_id、indicator_name、indicator_type、unit_score、status 工作量填报主表(biz_workload_main):main_id、user_id、semester_id、total_score、status 工作量填报明细表(biz_workload_detail):detail_id、main_id、indicator_id、content_desc、workload_value、score 审核记录表(biz_audit_log):log_id、main_id、auditor_id、audit_status、audit_comment、audit_time这里的核心是主表+明细表的设计。为什么不能只建一张表?因为一位老师在一个学期里可能填报多类工作量,比如"高等数学A 48学时 系数1.2"是一条,"指导毕业论文2人 每人8分"是另一条。如果用一张宽表,字段数量会爆炸,扩展新指标类型就得改表结构。主表明细表拆开之后,明细表各行记录不同的指标条目,结构统一,后续加指标类别不用改表。
3.2 workhour 计算字段的设计陷阱
很多新手设计表的时候喜欢把"分数"直接存进去,审核通过后改一下分数就完事。这个做法短平快,但后患无穷。
我的建议是:明细表只存原始填报值(workload_value,比如学时数、人数、篇数),分数(score)通过指标配置的系数计算出来,不要在填报时写死。这样做的原因是工作量指标经常调整——这学期一篇论文加5分,下学期可能改成3分。如果存的是算好的分数,那系统回溯历史数据时全错;如果存原始值,指标调整后历史数据重新计算即可。
当然,为了查得快,主表可以冗余一个total_score字段,但这个字段只做展示用,真正的计算逻辑在明细层。等指标配置稳定了,再生成最终分,这样既保证了灵活性,又不牺牲查询性能。
3.3 状态字段与审核链路
工作量填报这一块,状态字段设计我用了一个简单的整数状态机:
0 = 草稿(老师还没提交) 1 = 待教研室审核 2 = 待院系审核 3 = 待教务处终审 4 = 审核通过 5 = 已退回(老师可修改后重新提交)状态流转规则在代码里控制:只有状态为0或5时,填报人才能编辑;审核人只能看到状态等于自己这一级待审的数据;退回操作会把状态改成5,并写一条审核记录。
这个状态机看着简单,但一定要严格按流转走,否则会出现"老师还没提交,审核人就能看到"这种权限越界问题。我早期做的时候偷懒,在列表接口里直接根据角色查全部数据,结果老师刚存了个草稿,教研室主任那边已经能看到内容,闹过笑话。
4. 后端核心实现:SpringBoot接口与权限控制
4.1 工程结构怎么搭
后端工程结构建议按功能分包,而不是按技术分层。我实际用的是这样:
com.example.workload ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── config └── commoncontroller层只做参数接收和结果包装,service层写业务逻辑,mapper层写数据库操作。很多新手喜欢把所有代码堆在controller里,一个接口几百行,后期改一个需求找到不到方法在哪。分包规范一点,虽然刚开始多写几个文件,但维护起来真的轻松。
JWT认证我这里用了一个简单的拦截器实现。登录接口发放Token,拦截器校验Token并解析用户信息,放到ThreadLocal里,后续接口直接从ThreadLocal取当前用户。这个方案在中小型系统里足够应付,没必要上Spring Security那种重型框架,学习成本高不说,配置起来也繁琐。
4.2 工作量填报接口:主表+明细表事务操作
核心接口就是填报提交,这里必须用事务控制。伪代码逻辑:
@Transactional(rollbackFor = Exception.class) public void submitWorkload(WorkloadSubmitDTO dto) { // 1. 校验学期状态是否处于填报期 Semester semester = semesterMapper.selectById(dto.getSemesterId()); if (semester.getStatus() != 1) { throw new BizException("当前不在填报期内"); } // 2. 保存或更新主表 WorkloadMain main = new WorkloadMain(); main.setUserId(currentUserId()); main.setSemesterId(dto.getSemesterId()); main.setStatus(1); if (dto.getMainId() == null) { workloadMainMapper.insert(main); } else { main.setMainId(dto.getMainId()); workloadMainMapper.updateById(main); // 3. 删除旧的明细再重新插入 workloadDetailMapper.deleteByMainId(dto.getMainId()); } // 4. 插入明细 for (WorkloadDetailDTO detail : dto.getDetails()) { // 查指标配置,计算得分 Indicator indicator = indicatorMapper.selectById(detail.getIndicatorId()); BigDecimal score = calculateScore(indicator, detail.getWorkloadValue()); WorkloadDetail entity = new WorkloadDetail(); entity.setMainId(main.getMainId()); entity.setIndicatorId(detail.getIndicatorId()); entity.setWorkloadValue(detail.getWorkloadValue()); entity.setScore(score); workloadDetailMapper.insert(entity); } }这里面最关键的一步是"删除旧明细再重新插入"。为什么不是逐条更新?因为前端传过来的是整个表单,用户可能删了某条、改了某条,逐条比对太复杂,直接全删重插最省心,数据量不大完全没性能问题。前提是明细表不能有外键关联到审核表,否则删不掉,这也是我设计时特意让明细表和审核表解耦的原因。
4.3 多级审核的权限判断
审核接口和普通列表接口最大的区别是权限判断不能只看角色,还要看数据归属。
教研室主任审核时,只能看到本教研室老师提交的数据;院系审核人只能看到本院系的数据。这个过滤直接在SQL里做,而不是查出全部数据后在代码里过滤。
<select id="selectPendingAudit" resultType="..."> SELECT m.*, u.real_name AS teacher_name, d.dept_name FROM biz_workload_main m LEFT JOIN sys_user u ON m.user_id = u.user_id LEFT JOIN sys_dept d ON u.dept_id = d.dept_id WHERE m.status = #{currentStatus} <if test="dataScope == 'dept'"> AND u.dept_id = #{deptId} </if> <if test="dataScope == 'college'"> AND d.parent_id = #{deptId} </if> ORDER BY m.update_time DESC </select>数据权限这块,我踩过的坑是:刚开始觉得"反正页面只给审核人显示待审数据,代码里不查也没关系",结果一旦有人手动调接口,数据就泄了。管理系统千万不能指望前端隐藏按钮来保安全,接口层必须自己做好权限校验。
4.4 工作量计算规则怎么实现
工作量计算看起来复杂,拆开就是"指标×数量"的乘法逻辑。但有些学校会有复杂的加权规则,比如公共基础课系数1.0、专业核心课系数1.2、双语课再乘1.5。这种我建议做成两级计算:
第一级,在指标表里配置基础系数;第二级,在课程/项目表里维护额外系数。最终得分 = 基础指标分 × 额外系数。如果学校规则特别复杂(比如超过一定课时后系数递减),可以把计算规则抽成一个独立的工作量计算引擎类,用策略模式实现,每种规则一个实现类,后续加规则不用改动主流程代码。
说实话,大部分学校的工作量计算规则没有复杂到需要上规则引擎的地步,一个switch方法都能搞定。但设计上留好扩展口,比以后推倒重来划算。
5. 前端实现:Vue3 + Element Plus 的页面落地
5.1 项目初始化和路由设计
前端这块我用Vite作为构建工具创建Vue3项目,配合Vue Router管理页面路由。路由设计按角色拆分:
- /login:登录页
- /teacher/workload-list:我的工作量列表页(教师角色)
- /teacher/workload-edit:工作量填报/编辑页
- /audit/list:工作量审核列表页(教研室/院系/教务处共用)
- /admin/indicator:指标配置页(管理员)
- /admin/statistics:统计报表页(管理员)
路由守卫在跳转前判断用户角色,没登录直接扔到登录页,角色不对就提示无权限。这个判断不能只靠前端路由,后端接口同样要校验,前端只是用户体验层面的拦截。
5.2 工作量填报页:动态表单的关键实现
填报页是整个前端最复杂的组件,因为工作量条目是动态的,老师可以点"添加一行",每一行的指标类型下拉框、内容描述、工作量数值都是独立表单控件。
我用的是Element Plus的el-form配合动态v-for渲染:
<el-form :model="formData"> <div v-for="(item, index) in formData.details" :key="index"> <el-select v-model="item.indicatorId" placeholder="选择指标类型" @change="handleIndicatorChange(index)"> <el-option v-for="ind in indicatorOptions" :key="ind.indicatorId" :label="ind.indicatorName" :value="ind.indicatorId" /> </el-select> <el-input v-model="item.contentDesc" placeholder="填写具体内容" /> <el-input-number v-model="item.workloadValue" :min="0" placeholder="工作量数值" /> <el-button @click="removeDetail(index)">删除</el-button> </div> <el-button @click="addDetail">添加一行</el-button> <el-button type="primary" @click="submitForm">提交</el-button> </el-form>这里有个实操细节:el-select切换指标类型后,最好根据指标类型动态显示不同的辅助字段。比如选"课时"类型,就显示"班级人数"和"课程性质"两个附加下拉框;选"论文"类型,就只显示"论文级别"下拉框。每个指标类型配置独立的附加字段模板,用computed根据当前选择动态渲染,这样表单不会出现一堆无关字段,填起来清爽很多。
5.3 审核列表页与状态展示
审核列表页核心是状态标签展示和操作按钮控制。
数据从后端接口一次性返回,前端根据status字段渲染标签:
- 0 草稿:灰色标签,显示"草稿"
- 1 待教研室审核:蓝色标签,显示"待审核"
- 2 待院系审核:蓝色标签,显示"院系审核中"
- 3 待教务处终审:紫色标签,显示"终审中"
- 4 审核通过:绿色标签,显示"已通过"
- 5 已退回:红色标签,显示"已退回"
按钮的显示逻辑是:只有状态和当前用户角色匹配时,才显示"通过/退回"操作按钮。这里前端判断只做了交互展示,真正能不能点通,还是得看后端接口的权限判断。
5.4 统计报表页:ECharts展示
统计报表页不需要太复杂,后端返回聚合好的数据,前端用ECharts画柱状图和饼图。
柱状图对比各院系的总工作量,饼图展示工作量构成比例(教学占比、科研占比、其他占比)。这里有个经验:图表数据不要在前端算,后端SQL聚合好了直接给前端用。前端做数据聚合一是性能差,二是一旦计算规则变了前后端对不上,调试成本高。
6. 关键实操:从零搭建到跑通的步骤记录
6.1 数据库初始化
先在MySQL里建库建表,建议数据库名用teacher_workload,字符集用utf8mb4,排序规则utf8mb4_general_ci。utf8mb4比utf8的好处是能存emoji和生僻字,老师名字里偶尔会有生僻字,用utf8容易乱码。
导完SQL脚本后,记得插入基础数据:管理员账号、几个测试教师账号、部门数据、指标数据。没有这些基础数据,系统跑起来也是空转。
6.2 后端启动步骤
1. 修改 application.yml 里的数据库连接信息 2. 修改 JWT 密钥配置(生产环境一定不能默认) 3. 执行 mvn clean package 打包 4. java -jar teacher-workload.jar 启动 5. 访问 http://localhost:8080/api/doc.html 查看接口文档(如果集成了 Knife4j)我集成Knife4j的主要目的是给前端同学调试接口用的,前后端分离项目没有个接口文档,联调全靠猜,效率低到让人崩溃。
6.3 前端启动步骤
1. 修改 .env.development 里的 API 地址指向后端 2. npm install 安装依赖 3. npm run dev 启动开发服务器 4. 浏览器访问 http://localhost:5173前端开发模式用的端口5173和后端8080不同,会产生跨域问题。解决方案是在Vite配置文件里配置proxy代理:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/xxx时,Vite会自动转发到后端8080端口,浏览器看到的是同源请求,跨域问题天然消失。等到生产部署时,Nginx做一层同样的反向代理,前后端就彻底通了。
6.4 生产环境部署
生产环境部署,我习惯把前端的dist构建产物放到Nginx的html目录,然后配置Nginx把/api路径代理到后端服务。
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那一行非常关键,因为Vue是单页应用,路由跳转是前端控制的,刷新页面时如果Nginx找不到对应的文件路径,就会404。配了try_files之后,所有路径都回退到index.html,由前端路由接管,刷新就没问题了。
7. 常见问题排查:我自己踩过的坑和解决实录
7.1 跨域请求失败
现象:前端调用后端接口,浏览器控制台报Access-Control-Allow-Origin错误。
排查思路:先确认是用proxy代理还是后端CORS配置。如果用proxy,检查Vite配置target地址是否正确;如果直接用axios请求后端地址,需要后端配置CORS允许跨域。最简单的方案是用proxy,开发环境不用改后端代码。
实际处理:我建议前端开发阶段统一用Vite proxy,生产阶段用Nginx代理,这样代码里不用写死后端地址,将来换服务器IP也不影响前端代码。
7.2 MyBatis查询结果为空但SQL在数据库能查出来
现象:同样的SQL在数据库客户端执行有数据,但MyBatis查出来是null或者空列表。
排查思路:大概率是实体类字段和数据库列名对不上。MyBatis默认按驼峰映射下划线,如果没开启驼峰映射配置,查出来的列名不会自动转成实体类属性名。
实际处理:在application.yml里配置:
mybatis: configuration: map-underscore-to-camel-case: true这条配置会把人名存活的字段映射问题解决一大半。另外,如果用了resultMap,检查一下column和property是否一一对应。
7.3 审核状态卡住不动
现象:教研室审核通过了,数据在院系审核人那里看不到。
排查思路:这个90%是状态机流转逻辑写错,审核通过后没有把status从1改成2,或者列表查询时状态条件写死。
实际处理:我在service层加了一个状态流转方法,每个审核动作里统一调用:
private void changeStatus(Long mainId, int fromStatus, int toStatus) { WorkloadMain main = workloadMainMapper.selectById(mainId); if (main.getStatus() != fromStatus) { throw new BizException("当前状态不允许该操作"); } main.setStatus(toStatus); workloadMainMapper.updateById(main); }再加一条审计日志,哪个角色在什么时间做了审核操作,全流程可追溯。这样一来,状态卡住的时候查日志,一眼就能定位到哪一步断了。
7.4 Excel导出中文乱码
现象:导出Excel表格,用WPS或Excel打开,中文全是乱码。
排查思路:Excel导出用的POI库,输出流没设置编码格式时,中文很容易乱码。另外,导出文件的响应头Content-Type没设置正确,浏览器直接当二进制下载,也可能导致乱码。
实际处理:
response.setContentType("application/vnd.ms-excel;charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("教师工作量统计", "UTF-8") + ".xlsx");关键点是文件名要用URLEncoder编码,否则中文文件名在部分浏览器里会变成乱码。
7.5 定时任务自动关闭学期
现象:学期结束之后,老师还能提交工作量,导致下学期统计数据不准确。
排查思路:系统缺少自动关闭学期的机制,学期状态一直保持开启状态。
实际处理:加一个Spring定时任务,每天晚上检查学期结束日期,过了结束日期自动把学期状态改为"已截止",并通知所有未提交的老师邮件提醒(这里需要接入邮件服务)。定时任务的表达式写到配置文件里,避免硬编码:
@Scheduled(cron = "${scheduler.close-semester-cron}") public void autoCloseSemester() { // 检查学期结束时间,更新状态 }这样学期一到,系统自动截止填报,教务员不用每天盯着后台手动关。
8. 我的实操心得与后续扩展建议
这套系统我完整搭过一遍,从数据库建模到前端页面再到部署上线,前后大约花了两周左右。如果是一个人全栈开发,我建议把精力重点放在后端接口设计上,前端先用现成的组件库拼出来,功能跑通后再优化交互。
几个经验放在最后:
第一,设计数据库时,预留一个备注字段往往能救你。老师填报时经常有一些特殊情况,比如"这门课是和另外一位老师合上的",如果没有备注字段,这些信息只能打电话问,效率极低。
第二,工作量系统的指标配置一定要做成可维护的,不要写死在代码里。不同学校的算法千差万别,就算同一个学校,不同年级、不同专业也可能有不同的系数。指标配置在后台可调,系统上线后的维护成本可以降一半。
第三,权限设计宁可多写几行代码,也不要偷懒放开。管理系统的数据都涉及教师隐私,漏配一个数据权限接口,传出去就是事故。
至于后续扩展,我建议从这几个方向考虑:对接学校的统一身份认证(OAUTH或CAS),省去单独维护账号的麻烦;增加消息通知模块,审核结果通过站内信或邮件即时通知老师;数据量大了之后接入Redis缓存,减少高频查询的数据库压力。只要底层表结构设计合理,这些扩展点接起来不会伤筋动骨。
这套系统的价值,表面上是把Excel流程搬到了网上,实质上是用数字化手段把工作量核算的流程规范化了——数据有据可查、过程有迹可循、结果自动计算。做出来之后,教务处那边是真的能省下每周好几天的统计时间。