资讯详情 SpringBoot+Vue3工作量统计系统前后端分离开发实践
📅 2026/10/11 6:41:11
做了这么多年的管理系统我发现自己最怕的不是功能复杂的项目反而是看着很简单的项目。工作量统计系统就是典型代表。听名字似乎就是给每个人记几笔工作记录月底出个汇总表可真做到上线你会发现一大堆问题工作量怎么定义谁有权限改跨部门数据怎么算统计口径怎么统一这些问题不提前想清楚代码写得再花哨也是白搭。这篇文章就基于我最近用SpringBootVue3MyBatisMySQL实现的一套前后端分离工作量统计系统源码完整拆解从业务建模、数据库设计、后端接口开发、前端报表联调到最终部署避坑的全过程。适合正在做毕设、刚入职需要快速搭管理后台、或者想接手一套完整前后端分离项目的朋友参考。1. 先把工作量这件事定义清楚业务模型决定表结构很多人在工作量统计系统上踩的第一个坑就是上来就建表根本不管业务上工作量到底指什么。等你做到统计报表那一步发现数据对不上才回头改表结构那才是真痛苦。1.1 工作量统计和考勤、任务管理的本质区别先说清楚定位。考勤系统统计的是人来了没有、待了多久任务管理系统统计的是一个任务处于什么状态、谁负责、是否完成而工作量统计系统统计的是每个人在单位时间内产出/投入了多少。这三者经常被混在一起做但业务语义差别很大。考勤记录的是物理在场时间工时记录的是投入时间而工作量可能是产出数量、任务条数、评估得分甚至综合加权分数。我这次做的系统核心要解决三个问题员工能快速填报我今天做了什么、花了多久、完成了多少主管能审核这些记录避免乱报、虚报月末/周末能按部门、按人员、按时间维度出统计报表支撑绩效考核。明确了这三点整个数据模型就不会跑偏它必须有一张流水表每行是一次具体的、可审核的工作记录而不是一张直接汇总的统计表。统计结果全部通过流水表动态聚合出来。1.2 核心表设计用户、部门、工作记录、审核日志表结构我建议这么拆尽量避免把所有东西塞进一张大表然后互相冗余。第一张是sys_user用户表除了账号、密码、姓名这些常规字段外一定要冗余一个department_id。是的严格来说部门应该单独抽表但实际开发中工作量统计场景下95%的查询都要按部门过滤与其每次join部门表不如在用户表直接冗余部门ID查询性能好很多。第二张是sys_department部门表字段就是id、父级id、部门名称、排序号。建议用parent_id做树形结构因为后续统计很可能需要部门汇总含子部门这种需求。第三张是核心的work_log工作量记录表。这是整张表里最重要的一张字段我给出关键部分id主键user_id填报人关联用户表task_date工作的日期注意不是填报日期是这份工作发生在哪天task_type工作类型比如开发、测试、会议、文档、培训task_content工作描述varchar别用text统计场景不需要全文检索work_hours投入工时decimal(4,1)支持0.5小时粒度work_quantity产出数量比如处理工单数、完成需求数允许为空weight加权系数用于把不同类型的工作折算成统一分值status审核状态0待审核 1通过 2驳回audit_user_id审核人audit_time审核时间version乐观锁版本号。注意work_quantity和work_hours我建议都保留。因为不同岗位工作量的口径不一样开发岗位按需求点数运维按工单数行政可能按文档数。统计时应该有一个workload_score这样的计算列或计算表达式比如work_hours * weight work_quantity而不是写死某个字段。第四张是audit_log审核日志表。有人会觉得审核状态直接在work_log里记一下不就行了等出现审核人改了状态但说不清是谁改的、什么时候改的这种扯皮问题你就知道日志表多重要了。字段很简单work_log_id、操作人、操作类型提交/通过/驳回、操作时间、备注。1.3 统计口径时长型、数量型和加权型如何并存这是最容易被新手忽视的一点。同一个系统里不同部门对工作量的定义完全不同。比如开发部门看的是完成的需求点数客服部门看的是处理的工单数职能岗看的是投入工时。我建议在表设计层面不做死。work_log表里同时有work_hours和work_quantity再配一个task_type字典表把每种工作类型对应的统计主指标配置好。比如task_type统计主指标权重功能开发work_quantity1.0线上问题处理work_quantity1.5会议work_hours0.5技术培训work_hours1.0这样统计查询时根据task_type动态决定用哪个字段、乘以多少权重既灵活又能保证口径统一。实际编码时这个规则可以放在后端Java枚举里也可以做成一张配置表我更推荐配置表因为你永远不知道业务方哪天会加一种新的工作类型。2. SpringBoot后端工程结构、JWT认证与MyBatis统计SQL后端我用的是SpringBoot MyBatis MySQL认证方案选用JWT没有引入Spring Security全家桶原因是这个项目的权限模型比较简单就三种角色管理员、主管、员工用拦截器加角色判断完全够用没必要把Security那一套复杂的过滤器链拉进来徒增学习成本。2.1 依赖清单与工程目录组织Maven的pom.xml里关键依赖我列一下版本以SpringBoot 2.7.x为例这个版本稳定社区资料多遇到问题好查spring-boot-starter-webWeb基础mybatis-spring-boot-starter 2.3.xMyBatis官方startermysql-connector-j注意8.x驱动名变了jjwt-api/jjwt-impl/jjwt-jackson 0.11.5JWT生成与解析lombok减少样板代码hutool-all工具类日期处理、加密等都很方便。工程目录上我习惯按controller - service - mapper三层来拆但额外加了model和common两个包。model里放实体类、DTO、VOcommon里放统一返回结构、异常处理、常量定义。一个很容易踩的坑是实体类、查询参数、返回对象混淆在一起。比如查询工作量列表时前端传的分页页码、每页条数、筛选条件我建议单独建一个WorkLogQueryDTO不要直接拿实体类接收。否则后面加筛选条件时实体类的字段会被撑得越来越乱MyBatis的if判断也会啰嗦。2.2 JWT登录认证拦截器放行与权限校验分离JWT的逻辑很简单用户登录成功后后端生成一个token返回给前端前端每次请求放在Authorization头里后端写个拦截器统一解析token、提取用户信息、塞进ThreadLocal。这里我有个实际经验拦截器只做两件事一个是校验token是否存在和环境是否有效另一个是把当前登录用户的id和角色放到一个UserContext里。角色权限判断不要全堆在拦截器而是放在service层用自定义注解配合AOP或者直接方法内判断。原因是工作量系统的权限不是简单的路由级控制而是数据级控制。比如员工只能查自己填的记录主管能查本部门所有人的记录而管理员能查全公司。这种细粒度控制拦截器根本拦不明白必须在查询逻辑里根据角色动态拼接条件。核心的JWT拦截器逻辑大致是public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口 String uri request.getRequestURI(); if (uri.contains(/auth/login)) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } Claims claims JwtUtil.parseToken(token); if (claims null) { // 未登录返回401 response.setStatus(401); return false; } UserContext.set(claims.get(userId, Integer.class), claims.get(role, String.class)); return true; }有个细节UserContext用ThreadLocal实现但一定要记得在afterCompletion里清空。否则Tomcat的线程池复用会导致用户信息串到下一个请求里去排查起来非常诡异。2.3 MyBatis动态SQL工作量记录的条件查询与分页工作量记录列表是系统里使用最频繁的查询前端需要按时间范围、部门、人员、工作类型、审核状态多条件筛选。这类场景非常适合MyBatis的动态SQL。我在Mapper层写了一个selectWorkLogPage方法对应的XML片段select idselectWorkLogPage resultTypecom.demo.model.vo.WorkLogVO SELECT wl.id, wl.task_date, wl.task_type, wl.task_content, wl.work_hours, wl.work_quantity, wl.weight, wl.status, u.real_name, d.dept_name FROM work_log wl LEFT JOIN sys_user u ON wl.user_id u.id LEFT JOIN sys_department d ON u.department_id d.id where if teststartDate ! null AND wl.task_date gt; #{startDate} /if if testendDate ! null AND wl.task_date lt; #{endDate} /if if testdepartmentId ! null AND u.department_id #{departmentId} /if if testuserId ! null AND wl.user_id #{userId} /if if testtaskType ! null AND wl.task_type #{taskType} /if if teststatus ! null AND wl.status #{status} /if /where ORDER BY wl.task_date DESC, wl.create_time DESC /select这里有几个坑要专门提醒。第一个是时间范围的条件写法。endDate直接用会有边界问题。假如前端传的是2025-03-31在MySQL里这个日期会被解析成2025-03-31 00:00:00那么3月31号当天下午填报的数据就查不出来了。正确的做法是后端拿到endDate后加一天或者SQL里用 DATE_ADD(#{endDate}, INTERVAL 1 DAY)。第二个是task_date建议建索引。这个表一旦用上半年数据量轻松到几万行而所有统计查询都围绕日期展开没有索引的话月底汇总一次全表扫描MySQL CPU直线拉满。第三个是LEFT JOIN的用法。用户表关联部门表时如果人员在数据初始化时没有分配部门left join能保住person记录不丢。如果换成分页统计sumjoin条件不对还会导致统计数字翻倍这个在报表查询时要格外小心。2.4 报表统计SQL按人员、部门、时间维度聚合工作量统计系统到了月末核心就是各种聚合查询。以某部门当月每人工作量汇总为例最基础的SQLSELECT u.real_name, SUM(wl.work_hours) AS total_hours, SUM(wl.work_quantity) AS total_quantity, SUM( CASE WHEN wl.task_type 1 THEN wl.work_quantity WHEN wl.task_type 2 THEN wl.work_hours ELSE wl.work_hours END ) AS workload_score FROM work_log wl LEFT JOIN sys_user u ON wl.user_id u.id WHERE wl.status 1 AND u.department_id #{deptId} AND wl.task_date gt; #{startDate} AND wl.task_date lt; #{endDate} GROUP BY u.id, u.real_name;注意这里时间上界用的是 #{endDate}也就是下月一日的0点彻底避开边界值问题。月度按周统计时可以用YEARWEEK(task_date, 1)来分组第三个参数1表示周一作为一周起点更符合国内办公习惯。但是要注意函数被用在GROUP BY里会导致索引失效数据量大了以后性能会下降。我一般建议数据量在5万行以内直接用函数没问题再大就要建一个date_dim维表或者提前按周冗余字段。3. Vue3前端Vite工程、Axios封装、统计看板与ECharts前端我没有用Webpack那一套而是直接上Vite。不是赶时髦而是这个场景下Vite的开发服务器启动速度确实肉眼可见地快而且Vite对Vue3的script setup语法支持很自然写起来顺畅。3.1 目录结构和Axios封装前端目录组织src/ api/ # 按模块拆分的接口调用文件 assets/ components/ # 通用组件比如上传、表格封装 layout/ # 后台整体布局侧边栏顶栏 router/ # 路由配置 store/ # Pinia状态管理 views/ login/ dashboard/ # 统计看板 worklog/ # 工作量记录管理 audit/ # 审核页面 report/ # 统计报表 system/ # 用户管理、部门管理Axios封装这块我要多说两句。很多新手把后端接口地址直接硬编码在页面组件里这是后期维护的灾难。我一般会在api/workLog.js里统一导出接口函数import request from /utils/request export function getWorkLogPage(params) { return request({ url: /api/worklog/page, method: get, params }) } export function submitWorkLog(data) { return request({ url: /api/worklog, method: post, data }) }而request实例统一在utils/request.js里创建主要做两件事请求拦截器里带上token响应拦截器里统一处理业务错误码和HTTP 401跳转登录。一个让我印象深刻的坑登录超时后如果响应拦截器直接router.push(/login)但此时路由实例可能还没准备好会报Cannot read properties of undefined。解决方式是在拦截器里用window.location.href跳转或者把router实例用懒加载的方式引入。3.2 登录状态、动态菜单与路由守卫工作量统计系统的路由权限不算复杂但我仍然建议做动态路由而不是全量注册。全量路由的问题在于员工在前端能看到审核页面的菜单虽然接口层面后端会拦截但体验上很怪而且很容易被吐槽系统有Bug。做法是登录成功后返回用户角色前端根据角色过滤出可见的路由表再通过router.addRoute()动态注册。这样侧边栏菜单和路由表天然一致。路由守卫的伪代码router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } if (token to.path /login) { next(/dashboard) return } next() })有个细节token存在localStorage还是pinia我选择localStorage持久化因为刷新页面后pinia里的用户信息会丢还需要重新拉接口而localStorage让刷新体验更顺滑。代价是XSS攻击可能窃取token所以使用时要记得给敏感操作加二次确认不要过度依赖前端安全。3.3 ECharts报表看板图表联动与大数据量渲染统计看板是工作量统计系统的门面也是用户感知最强烈的模块。我用ECharts实现了一个大屏看板包括月度工作总量趋势折线图、部门工作量占比饼图、个人工作量排行横向柱状图。一个实用的经验是图表之间做联动。比如点击饼图的某个部门下方的个人排行柱状图自动切换成该部门的数据。实现思路不复杂饼图的click事件触发时重新调用后端报表接口更新柱状图的数据源。chart.on(click, (params) { const deptId params.data.deptId loadPersonalRank(deptId) })大数据量渲染方面当月度明细超过几千条时我建议后端直接做聚合返回给前端的是已经汇总好的结果而不是裸明细。ECharts处理几百个点的曲线图没有任何压力但如果把几万条流水全丢给前端让它自己聚合页面会明显卡顿。还有一点ECharts按需引入不要全量引入。全量打包会让vendor包超过1MB首屏加载明显变慢。我一般在utils/charts.js里统一按需注册import * as echarts from echarts/core import { LineChart, PieChart, BarChart } from echarts/charts import { TooltipComponent, GridComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([LineChart, PieChart, BarChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])4. 前后端联调与部署跨域代理、Nginx、数据准确性这一章是我认为决定项目能否真正交付的关键。开发时接口通个一两回就以为完事等部署到服务器上再出问题排查成本会翻倍。4.1 开发环境的跨域方案Vite Proxy前后端分离开发时前端跑在5173端口后端跑在8080端口直接请求必然跨域。我推荐开发期用Vite的server.proxy配置来代理而不是在后端写CrossOrigin。原因是代理模式让前端的请求URL和生产环境保持一致都是/api开头上线时只需让Nginx把/api转发到后端前端代码一行不用改。// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }后端接口实际没有/api前缀所以通过rewrite把前缀剥掉。生产环境同理Nginx里配置location /api/转发到后端时也不需要让后端改接口路径。有人在后端统一加server.servlet.context-path/api这也是一种方案但要注意如果Nginx再rewrite一次双重前缀会导致404配置时要保持一致。4.2 生产环境Nginx部署前后端分离的标准部署方式是前端打包后的dist静态文件由Nginx托管后端SpringBoot打成jar包单独跑在某个端口。Nginx关键配置server { listen 80; server_name your-domain.com; location / { root /opt/workload-system/dist; index index.html; try_files $uri $uri/ /index.html; # 前端路由使用history模式时必须 } 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 $uri $uri/ /index.html;这一行经常有人忘。Vue Router如果用的是createWebHistory模式刷新某个子路由页面时如果不配置这行Nginx会返回404用户表现为一刷新页面就丢了。生产部署还有一个服务器防火墙问题。MySQL默认端口3306SpringBoot8080Nginx80。服务器上不需要对外暴露3306和8080只把80/443对外开放即可。有人贪省事把所有端口都放行结果数据库被扫库攻击这种事故我见得太多了。4.3 数据准确性保障并发审核与乐观锁工作量统计系统的数据准确性除了SQL口径要对还有一个容易被忽略的并发问题。设想这个场景主管打开审核列表看到员工A的一条记录状态是待审核正准备点通过。同一时间管理员在后台把这条记录改成了驳回。两个操作同时命中同一条数据如果后写的覆盖前写的就会出现状态错乱。我的做法是在work_log表加version字段更新时带上乐观锁update idupdateStatus UPDATE work_log SET status #{status}, audit_user_id #{auditUserId}, audit_time NOW(), version version 1 WHERE id #{id} AND version #{version} /update通过UPDATE影响行数来判断是否冲突。如果影响行数为0说明版本号不对返回提示该记录已被其他人处理请刷新后重试。这个机制简单有效也符合工作量统计系统的业务特性操作频率不高、冲突概率低、但一旦冲突影响很坏。比分布式锁之类的方案便宜得多。5. 踩坑记录与优化清单最后一部分我把开发过程中踩过的坑和做过的优化集中整理一下希望能帮读者省下几天调试时间。5.1 MySQL连接时区与SSL问题新手第一次连接MySQL 8.x经常报SSL connection error或时间差8小时。我推荐在application.yml里显式配置spring: datasource: url: jdbc:mysql://localhost:3306/workload_db?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriveruseSSLfalse是避免MySQL 8默认启用SSL导致的手续问题serverTimezone必须和服务器时区一致否则JVM和MySQL时间换算错位统计出来的日期全部偏一天。5.2 下划线字段映射驼峰MySQL表字段用task_date这种下划线命名Java实体类用taskDate驼峰MyBatis默认不会自动映射需要在配置里开启mybatis: configuration: map-underscore-to-camel-case: true这个配置漏了以后查出来的字段全是null而且不会报错调试起来非常迷惑。我吃过这个亏花了一个下午排查才发现是映射问题。5.3 统计缓存不能一刀切报表查询是读多写少的典型场景。工作记录只在工作日产生而统计看板可能每5分钟就被刷新一次。我在Service层加了一个简单的缓存方案用Spring Cache的Cacheable装饰按部门、按月的聚合查询方法缓存的key是统计维度部门ID月份缓存失效时间设置为5分钟。但注意审核状态变化时一定要主动清理对应缓存。否则主管刚审核通过一条记录员工刷新看板数据没变就会质疑系统不准。实际编码里我用CacheEvict在审核方法上指定对应的缓存key批次来清理。5.4 分页与排序优化工作量记录页的排序我默认按task_date DESC排。这个排序必须配合索引否则随着数据增长分页查询会越来越慢。推荐联合索引(user_id, task_date)因为绝大多数查询都是查某个人某段时间的记录。如果还要支持按部门过滤那可以把索引设计成(department_id, task_date)需要在用户表和记录表join前先过滤。explain一下SQL执行计划看到typeALL的全表扫描就赶紧加索引。5.5 上线前的初始化数据最后提醒一个很容易拖到上线才发现的坑部门表和用户表一定要在正式环境准备好初始化SQL脚本而且要用Flyway这类工具管理不要手动在数据库执行。我这次的做法是建表脚本、基础数据脚本全部用Flyway管理启动时自动执行。这样新环境部署一条命令就能把库表结构完整恢复不会出现开发环境好好的生产环境少个字段这种尴尬。写在最后的个人感受工作量统计系统虽然名字朴素但做完之后我才意识到它真正考验的不是某个高深技术而是对业务口径的理解、对数据边界条件的处理、对前后端整体链路的把控。尤其在统计类项目里数据对不对永远是第一位的。配合SpringBoot、Vue3、MyBatis、MySQL这套组合开发效率确实很高但千万别因为框架顺手就忽略了业务建模。我个人强烈建议任何准备做这类系统的人先花半天时间把统计口径和表关系设计清楚再动代码后面你会感谢自己这个决定。如果遇到具体问题欢迎按文中的思路先自行排查一遍大部分坑都是可以通过日志和SQL执行计划定位到的。