这段时间好几个做交付项目的朋友跟我吐槽:每个月报工作量,不是漏填就是口径对不上,项目核算的时候翻聊天记录去补工时。我就花了点时间把这套“前后端分离工作量统计系统”整理了出来,技术栈是SpringBoot + Vue + MyBatis + MySQL,包含了完整源码和从零到一的部署教程。系统主要解决三个问题:成员每天填报工时和工作内容、管理者按人员和项目维度统计工作量、所有数据可回溯可导出。适合需要做研发团队效能统计、项目成本核算的同学参考,也适合想系统了解前后端分离项目完整落地过程的读者。下面把设计和实现的关键路径完整梳理一遍。
1. 手工月报表的痛:这套工作量统计系统到底解决什么问题
1.1 统计这件事怎么就从简单变成了一地鸡毛
先说说没有这套系统之前的真实场景。团队规模在十个人以内的时候,工作量统计通常一个Excel就能搞定,每个人在共享表格里填一行任务描述和工时,月底负责人汇总一下,看起来没问题。一旦人员超过十五个、同时有多个项目在跑,Excel的弊端就全出来了:单元格格式被改、日期格式五花八门、有人忘了填、有人把任务描述写得很随意,汇总的时候根本没法快速核算哪个项目投入了多少人力。
更麻烦的是口径统一问题。有人说“我今天干了三个小时”,有人说“我今天报了五小时”,实际投入到具体项目的工时到底是多少,完全对不上。到了月底做项目复盘或人力成本分析时,数据严重失真,老板要一个各成员工作量报表,光整理就耗掉大半天。所以我做这套系统的出发点很简单:把填报动作收敛到唯一入口,用强制规则保证数据格式统一,再用SQL把汇总计算交给数据库,而不是靠人手去加。
1.2 系统的核心能力和最终交付形态
这套工作量统计系统的核心能力归纳下来就四条:
- 用户登录与角色权限:普通成员和管理员走同一套登录入口,角色不同看到的功能和范围不同。成员只能填报和查看自己的记录,管理员可以看全员数据、按项目和人员维度拉统计。
- 工作日志填报:成员每天选择所属项目、填写任务描述、填入工作时长和工作日期,支持修改和删除自己范围内的记录。
- 多维统计汇总:按日期区间、按人员、按项目三个维度汇总工时,输出数字报表和图表。
- 部署开箱可用:后端用Maven打包成可执行jar包,前端构建成静态文件,部署到内网服务器之后通过Nginx对外提供服务。
它的最终交付形态就是一个部署在内网Tomcat服务或独立服务器上的Web应用,前端浏览器访问,不走公网也可以正常使用。源码层面,后端是一个标准的分层项目,前端是Vue工程,数据库初始化脚本独立提供,拿过去就能跑。
2. 盘点技术栈:为什么是SpringBoot+Vue+MyBatis+MySQL这个组合
2.1 四件套的配合逻辑
很多刚接触前后端分离的同学会问:为什么不直接用SpringCloud,为什么不配个Redis,为什么ORM选了MyBatis而不是JPA。对这个场景来说,这套组合是最轻、最稳、最好招人的方案。
先说SpringBoot。工作量统计系统本质是围绕业务表的增删改查和统计汇总,根本不需要微服务那套注册中心、配置中心、网关的复杂体系。SpringBoot能把内嵌Tomcat、自动配置、健康检查这些全部搞定,一个依赖引入就少掉一大半繁琐的XML配置,非常适合这种中小型内部系统。
Vue负责前端交互。统计系统的核心交互是表单填写、表格展示、图表渲染,属于典型的单页应用场景。Vue的响应式数据绑定让列表和表单联动变得很直接,配合Element UI或Element Plus组件库,表格分页、日期选择器都现成可用。
MyBatis在这套系统里的价值是可控SQL。统计逻辑比普通CRUD更依赖特定SQL写法,比如按日期区间分组求和、按项目聚合、多表联查。MyBatis的XML Mapper可以把这些复杂SQL写得很清晰,想问什么问题直接看SQL文本,不需要猜JPA自动生成的语句是怎么拼出来的。
MySQL就更没悬念了,内部系统的数据量级通常远没到需要分布式存储的程度,单库单表配InnoDB和合理索引就完全够用,运维成本也低。整个组合的核心逻辑是:把复杂度控制在“能用、易改、好维护”的范围里,而不是追求技术上的花哨。
2.2 关键版本与依赖说明
我实际落地的版本是这样的:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 后端运行环境,稳定兼容 |
| SpringBoot | 2.7.x | 内嵌Tomcat,自动配置 |
| MyBatis | mybatis-spring-boot-starter 2.3.x | 数据库ORM |
| MySQL | 5.7或8.0均可 | 生产环境用的8.0 |
| Vue | 3.x + Vite | 前端工程化 |
| Element Plus | 2.x | UI组件库 |
| ECharts | 5.x | 统计图表 |
| JWT | jjwt 0.9.x | 登录令牌 |
这里有个经验可以分享:前端如果团队里大家更熟Vue2,可以直接改用Vue2.6加Element UI,逻辑几乎不用动,只是API写法有细微差异。我这个版本的代码以Vue3为主,迁移成本不高。后端JDK不建议一开始就上17,SpringBoot的版本兼容性容易折腾,JDK1.8是最不出错的底子。
3. 数据库设计先行:5张核心表的字段思路与关联关系
3.1 表结构总览
工作量统计系统的数据模型不需要复杂,但五张表的职责边界必须清楚。表结构先设计好,后面的业务代码才不会反复推倒重来。
user:用户表,存储账号、密码、姓名、部门、角色。project:项目表,存储项目名称、描述、状态。work_log:工作日志表,存储填报主体数据。leave_record:可以留作请假/缺勤记录扩展,也可暂不建。operation_log:操作日志表,记录关键数据的增删改动作。
最小可用版本必须先有前三种。operation_log可以帮助排查谁改了什么数据,团队内部系统很有必要。SQL脚本utf8mb4字符集必须设置,否则碰到中文特殊符号容易乱码。
3.2 一张表的字段是怎么定下来的(以work_log为例)
work_log是整个系统的核心表,字段怎么定直接影响统计准确性。建表脚本如下:
CREATE TABLE `work_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '填报人ID', `project_id` bigint(20) DEFAULT NULL COMMENT '所属项目ID', `task_desc` varchar(500) NOT NULL COMMENT '任务描述', `work_date` date NOT NULL COMMENT '工作日期', `duration` decimal(5,1) NOT NULL DEFAULT '0.0' COMMENT '工作时长,单位小时', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_date` (`user_id`, `work_date`), KEY `idx_project_date` (`project_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作日志表';字段设计里有几个容易踩坑的点。
duration为什么用decimal(5,1)而不是double:小时数精确到0.1小时,相当于6分钟,既能满足统计精确度,又避免浮点数求和时的精度脏数据。填报界面可以把工时控件改成可选择0.5、1、2、4等多档,配合输入框校验,最大程度保证数据规范。
work_date用date类型而不是datetime:统计天然是按天聚合的,日期类型在SQL里可以直接用BETWEEN和GROUP BY,省掉一层类型转换。
联合索引idx_user_date和idx_project_date:这是为统计查询服务的。最常见的查询是“某人在某段时间内的工时”和“某项目在某段时间内的工时”,联合索引能有效避免全表扫描。数据量即便涨到几十万条,这套索引设计也能保持查询在毫秒级。
user表的密码字段固定存BCrypt哈希值,比如{bcrypt}$2a$10$...,明文密码一律不允许入库。project表除了基础信息外建议加一个status字段:1进行中、2已暂停、3已结束,避免删数据导致历史日志关联不上。
4. 后端搭建与接口实现:登录、填报、统计三条链路
4.1 工程结构与登录鉴权
后端工程目录采用标准分层结构,每个包职责单一,新接手的人一眼就能看懂:
com.example.workstats ├── WorkStatsApplication.java ├── common │ ├── Result.java -- 统一返回体 │ ├── JwtUtil.java -- JWT生成与解析 │ └── AuthInterceptor.java -- 登录拦截器 ├── controller │ ├── AuthController.java │ ├── WorkLogController.java │ └── StatsController.java ├── service ├── mapper └── entity登录接口的核心逻辑是这样:用户提交账号密码,后端查询用户表并用BCryptPasswordEncoder.matches()校验密码,校验通过后生成JWT返回前端:
@PostMapping("/api/auth/login") public Result login(@RequestBody LoginRequest req) { User user = userMapper.findByUsername(req.getUsername()); if (user == null || !passwordEncoder.matches(req.getPassword(), user.getPassword())) { return Result.error("账号或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return Result.success(new LoginResponse(token, user.getRealName(), user.getRole())); }JWT生成的载荷里别放敏感信息,只要用户ID、用户名、角色就够了,默认过期时间设定为24小时。登录成功后前端在本地存储token,每次请求在Header里带Authorization: Bearer xxx。
鉴权用拦截器实现而不是引入Spring Security全家桶,原因是这个系统只有两类角色,拦截器加注解的配置方式足够且可读性更好。拦截器里对/api/auth/login和静态资源路径做放行,其余请求都校验token,校验失败直接返回401。
4.2 填报与查询链路
填报接口要解决的第一个问题是数据归属。普通成员提交的日志,userId必须从token里取,不能信任前端传进来的参数,否则会员就能替别人填报。管理员虽然可以查看所有数据,但不能替他人新增,避免数据伪造。
新增日志的Service方法大致如下:
public void addLog(WorkLog log, Long currentUserId) { if (log.getWorkDate() == null || log.getDuration() == null || log.getDuration() <= 0) { throw new BizException("日期和时长必须填写,时长必须大于0"); } if (log.getDuration() > 24) { throw new BizException("单条日志时长不能超过24小时"); } log.setUserId(currentUserId); workLogMapper.insert(log); operationLogMapper.insert("ADD_WORK_LOG", currentUserId, log.getId()); }查询链路按角色划分场景。普通成员调用GET /api/log/list,后端强制把userId设置为当前登录人,再配合分页参数返回;管理员可以传userId作为条件查看指定成员的数据,不传则默认查全部。分页用MySQL的LIMIT offset, size,前端传pageNum和pageSize,后端做简单换算。
修改和删除同样要校验归属:成员只能操作自己的日志,管理员可以操作所有日志。这个校验在Controller层和Service层各做一遍,后端必须自己兜底,不能依赖前端隐藏按钮来保证安全。
4.3 统计接口的SQL写法
统计是本系统最有含金量的部分。三个核心统计维度对应三条SQL路线。
按时间区间统计某成员每日工时:
<select id="statsPersonalDaily" resultType="map"> SELECT work_date AS date, SUM(duration) AS totalDuration FROM work_log WHERE user_id = #{userId} AND work_date BETWEEN #{startDate} AND #{endDate} GROUP BY work_date ORDER BY work_date </select>按项目统计某段时间内的工时占比:
<select id="statsByProject" resultType="map"> SELECT p.project_name AS projectName, COALESCE(SUM(w.duration), 0) AS totalDuration FROM project p LEFT JOIN work_log w ON p.id = w.project_id AND w.work_date BETWEEN #{startDate} AND #{endDate} WHERE p.status = 1 GROUP BY p.id, p.project_name ORDER BY totalDuration DESC </select>注意这里用了LEFT JOIN,而不是INNER JOIN。目的很明确:没有产生工作量的进行中项目也要显示出来,工时显示0,否则管理者根本不知道哪些项目处在“挂着但没人投入”的状态。
按成员汇总其在一段时间内总工时,SQL写法类似,只是把分组条件换成user_id和用户姓名表。ECharts图表的数据源就是这些接口返回的Map列表,前端不用做任何二次聚合,直接塞给图表配置即可。
5. 前端页面实现:表单、列表与图表可视化
5.1 项目目录和路由设计
前端用Vue3加Vite创建。目录结构里几个关键位置如下:
workstats-web/ ├── src │ ├── api │ │ ├── auth.js │ │ ├── log.js │ │ └── stats.js │ ├── router/index.js │ ├── store/user.js │ ├── views │ │ ├── Login.vue │ │ ├── WorkLog.vue │ │ └── StatsView.vue │ └── utils/request.jsutils/request.js用axios做封装,统一设置baseURL和请求拦截器。请求拦截器里从localStorage取token并拼到Header,响应拦截器里统一处理401状态码,遇到token过期就跳转登录页。这一层建议一开始就封装好,否则每个页面手写一遍Header逻辑,后面改起来很痛苦。
路由设计上,未登录状态一律重定向到/login,登录后动态渲染菜单。管理员和普通成员的路由表可以做成两份,管理员额外挂载“全员统计”页面,普通成员只有一个“我的统计”页面,前端控制显示之外,后端接口权限仍然要做二次校验。
5.2 页面级交互逻辑
工作日志页是使用频率最高的页面,拆成两个部分:上部分是填报表单,下部分是查询列表。关键点在于表单提交后列表数据自动刷新,列表的编辑操作回填表单。实现方式很简单,提交按钮的loading状态期间请求后端,成功后关闭弹窗并重新加载列表。
填报表单示例:
<el-form ref="formRef" :model="logForm" :rules="rules"> <el-form-item label="工作日期" prop="workDate"> <el-date-picker v-model="logForm.workDate" type="date" value-format="YYYY-MM-DD" /> </el-form-item> <el-form-item label="所属项目" prop="projectId"> <el-select v-model="logForm.projectId" placeholder="请选择项目"> <el-option v-for="p in projectList" :key="p.id" :label="p.projectName" :value="p.id" /> </el-select> </el-form-item> <el-form-item label="任务描述" prop="taskDesc"> <el-input v-model="logForm.taskDesc" type="textarea" :rows="3" maxlength="200" show-word-limit /> </el-form-item> <el-form-item label="工作时长(小时)" prop="duration"> <el-input-number v-model="logForm.duration" :min="0.5" :max="24" :step="0.5" /> </el-form-item> <el-form-item> <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button> </el-form-item> </el-form>el-input-number的step属性设为0.5非常关键,能把常见的“1.3小时”“0.7小时”这类难以核算的数据直接挡在门外。规范化的数据录入是统计系统最容易被忽略的设计点,前端的约束越多,后端的统计越干净。
查询列表用el-table,列字段包括日期、项目、任务描述、工时、操作。操作列根据角色来控制:普通成员只能看到“编辑/删除”,管理员额外看到全部数据但编辑权限同样受后端限制。分页用el-pagination组件,current-page和page-size与后端请求参数绑定。
统计图表页用ECharts。至少有三种图表:成员每日工时柱状图、各项目工时占比饼图、成员工作量排行条形图。柱状图代码示例:
const chart = echarts.init(document.getElementById('dailyChart')) chart.setOption({ title: { text: '本月每日工时分布' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: dateList }, yAxis: { type: 'value', name: '工时(h)' }, series: [{ type: 'bar', data: durationList, barWidth: 20 }] })统计接口返回的日期列表和工时时长列表,前端直接用数组解析。一个细节:后端返回的totalDuration是BigDecimal类型,JSON序列化后是数字,前端无需转字符串,但浮点数精度问题需要留意。把duration统一按小时计,返回值保留一位小数,图表上显示“3.5h”这种格式,观感最好。
6. 完整部署教程:从打包到内网可访问
6.1 初始化MySQL
部署的第一步是准备数据库环境。新建数据库和导入脚本按顺序执行,写成一个init.sql文件后可以用一条命令完成导入:
mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS workstats DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p workstats < init.sql导入完成后,记得执行一条验证SQL,确认表结构正常:
SHOW TABLES; SELECT COUNT(*) FROM user;如果user表为空,需要先插入一个管理员账号和初始测试账号。密码使用BCrypt加密,可以在后端程序启动后通过临时接口生成,也可以直接使用在线BCrypt生成工具生成对应哈希值再插入数据。插入时务必填好role字段,管理员填admin,普通成员填user。
6.2 后端打包与启动
后端打包之前,先检查application.yml里的数据库连接配置。生产环境的连接串一定不要使用本机的localhost,改为服务器实际IP。MySQL 8.0需要在连接串上追加serverTimezone=Asia/Shanghai,否则会产生时区差异导致的日期错位问题:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/workstats?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword打包命令很简单,但跳过测试是必须的,本地测试环境如果连不上数据库容易导致打包中断:
mvn clean package -DskipTests执行成功后target目录下出现workstats.jar。启动用nohup方式挂后台:
nohup java -jar workstats.jar --server.port=8080 > app.log 2>&1 &启动后先不急着配前端,用curl验证接口连通性:
curl -X POST http://127.0.0.1:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'返回JSON里带token就说明后端和数据库链路完全正常。如果是防火墙拦截,检查服务器的8080端口是否对外开放。
6.3 Nginx托管前端与反向代理
前端构建前需要确认接口代理地址。在Vite项目的.env.production文件里设置:
VITE_API_BASE_URL=/api构建命令:
npm install npm run build构建产物在dist目录。把整个dist目录上传到服务器的/opt/workstats-web下,随后配置Nginx。这份配置涵盖了静态文件托管、接口反向代理、路由history模式兜底三个核心点:
server { listen 80; server_name your-server-ip; root /opt/workstats-web; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }这里有两个细节必须注意。proxy_pass后面带了/api/路径,代理时会把原URL的/api前缀保留下来,和后端Controller的@RequestMapping("/api")正好对应。try_files是Vue Router history模式的生命线,没有这一行,前端路由刷新时就会报404。配置完重载Nginx:
nginx -t systemctl reload nginx浏览器直接访问服务器IP,看到登录页就代表部署完成。
7. 上线后的运维经验:避坑记录与扩展建议
7.1 踩过的3个典型坑
这套系统从开发到稳定的过程中,有几个坑非常典型,值得单独记录下来。
第一个坑是时区问题。最初后端连接串没有加serverTimezone,MySQL 8.0默认时区是UTC,前端填报的2025-06-01存入数据库后变成了2025-05-31,第2天统计结果整体偏移一天。排查过程不算复杂,但问题很隐蔽。解决方式就是连接串统一指定Asia/Shanghai,同时work_date字段用date类型从源头避免时区干扰。
第二个坑是MyBatis的if test字符串判断。项目里查询接口支持可选日期区间和可选用户筛选,XML里写<if test="startDate != null and startDate != ''">时,之前误写成!= ''以外还加了and startDate != 'null',结果前端传了空字符串时SQL正常,传null时反而拼出错误条件。后来统一规范:参数尽量包一层DTO,所有可选字段都用null判断,前端如果参数为空就不传该字段。
第三个坑是服务器内存吃紧。项目部署在一台只有2G内存的云服务器上,MySQL、后端、Nginx挤在一起,mvn package时经常OOM。后来把打包本机和运行环境分开,本机打包上传jar包,服务器上直接用Maven的编译产物,避免在低配服务器上跑大型编译任务。如果必须在服务器上编译,建议先加swap分区,这个问题就迎刃而解。
7.2 下一步可以扩展的方向
系统上线稳定以后,接下去最值得做的扩展有三个方向。
第一是Excel导出。统计结果用ECharts看可以,但管理层通常更习惯要Excel。后端引入Apache POI或阿里EasyExcel,把统计接口返回的数据转成多sheet的Excel文件,按项目报表和工作量明细两个sheet导出,非常实用。
第二是自动提醒填报。团队成员的填报率初期一定不会是100%,可以加一个定时任务,每天下午5点扫描当天未填报的用户列表,通过系统消息或Webhook方式提醒未填报人员。最简单的实现是SpringBoot的@Scheduled注解配合定时任务,每30分钟执行一次即可。
第三是审核机制。目前管理员只能看数据,不能修正。更完整的流程是成员提交后管理员有审核通过或驳回的操作,驳回时填写原因并通知成员修改。如果需要严谨的成本核算,这个功能几乎必然要补上。
最后说点个人体会。这类内部管理系统最容易犯的设计错误是一上来就做得很重,又是流程引擎又是权限矩阵,真正上线以后发现用户根本不买单。顺着团队的使用习惯把填报、统计、看数这三件事做流畅,比堆功能有效得多。如果你们团队也为工作量统计发愁,完全可以照这套方案先搭一版,数据模型简单、部署成本低,跑起来再迭代,基本不会走弯路。