资讯动态

SpringBoot+Vue搭建轻量工作量统计系统:设计、部署与避坑指南

发布时间:2026/10/14 4:04:15 来源:尧图企业网站定制
团队管理里有一件事看着不起眼做起来却特别折腾工作量统计。部门 20 多个人每周要填工时月底要按人、按项目、按月汇总还要和绩效、成本核算对得上。早先我们靠共享 Excel 收集结果口径不统一、漏填错填、月底合并表格能合并两小时。后来我花了两周时间做了这套前后端分离的工作量统计系统技术栈就是 SpringBoot Vue MyBatis MySQL随手也整理了完整源码和部署教程。这篇文章把系统设计思路、表结构、核心接口、前端页面、部署流程以及实际踩过的坑完整写出来给正在做练手项目或者公司内部确实需要一套轻量统计工具的朋友一个可以直接抄作业的参考。1. 项目整体设计与需求拆解1.1 需求拆解工作量统计到底要统计什么做任何管理类系统第一步不是建表而是把业务规则问清楚。不然你辛辛苦苦做出来的功能业务方一句“这不是我要的”就全白干。我们系统面向的是两类角色普通员工和管理员。员工要做的事很朴素登录后按月填报自己每天在哪些项目上花了多少时间写了什么内容月底前确认提交管理员要做的事则是看到所有人的填报记录按月、按项目、按人汇总工时必要时驳回有问题的记录让员工重新填。这里我额外加了两条细节规则。第一工时粒度按 0.5 小时计不允许填 2.3 小时这种小数第二同一员工同一天可以有多条记录但不能在同一个项目上重复建两条——如果确实在同一个项目上做了不同的事应该合并成一条记录把内容写清楚否则统计时容易重复计算。这两条规则看着细实际在数据质量上非常关键。非功能需求同样要提前约定系统要轻量、部署简单、响应快不追求花哨数据不能丢。我们内部是小型服务器内存就 2G所以技术选型尽量少占资源前端打包后只有几十 MB 静态文件后端一个 jar 包约 80MB运行时内存 512MB 左右就够。1.2 技术选型为什么是这四件套这套系统的技术栈是我刻意选的每一样都对应着实际痛点。SpringBoot 不用多说它内嵌了 Tomcat直接 java -jar 就能启动不像以前 SSM 还要装外部容器、配一堆 XML。我们这种内部小项目最重要是“能快速跑起来”SpringBoot 完美契合。版本我用的 2.7.x稳定且兼容性最好要求 JDK 8 就够了服务器上不用另外装 JDK 17。MyBatis 的选择可能有人觉得“老”但我恰恰是因为要写统计 SQL 才选的它。JPA 玩面向对象很爽可一到按月分组汇总、按项目聚合这种复杂查询要么写 JPQL 绕来绕去要么还得 nativeQuery反而别扭。MyBatis 就是让你直接写 SQL统计逻辑一目了然改起来也快。加上 xml 文件里动态 SQL 遇到条件查询很方便。这套系统里工作量统计是核心SQL 写得好不好直接影响系统成败所以我选 MyBatis 而不是 JPA。MySQL 没啥争议免费、稳定、资料多内部系统数据量撑死几万条记录MySQL 完全没压力。字段统一用 utf8mb4支持中文和表情符号避免乱码问题。前端用 Vue配 Element UI。这类后台管理系统的页面结构高度套路化左侧菜单、右侧内容区、表格、表单弹窗、分页、图表。Element UI 把这些组件全打包好了写页面效率极高。版本方面我用的是 Vue 2 Element UI如果你愿意用 Vue 3 Element Plus思路完全一样组件写法差异很小迁移成本不高。1.3 目录结构与模块划分项目分前后端两个独立工程物理隔离。后端命名为 workload-server前端命名为 workload-web互不干扰也方便以后任何人接手。后端目录按职责分层controller接收请求、参数校验、返回统一 Resultservice业务逻辑事务控制在这里mapperMyBatis 接口mapper/xmlSQL 映射文件entity数据库实体dto接收参数的传输对象和实体分离config跨域、拦截器、MyBatis 配置common统一返回对象、异常处理、工具类如 JWT、BCrypt前端目录api所有接口请求封装views页面组件登录、填报、统计、用户管理router路由表store登录用户信息、token 等状态utilsaxios 实例、日期工具这样一个目录结构后续加模块只需要横向扩展比如再加一个“审批模块”就新增 controller service mapper views/approval几小时就能铺完。2. 后端核心实现SpringBoot MyBatis MySQL2.1 数据库表设计与初始化脚本数据库我设计了三张核心表用户表、项目表、工作量记录表。不搞复杂的 RBAC 权限模型因为系统就两种角色做成字段就能解决过度设计反而增加维护成本。CREATE DATABASE IF NOT EXISTS workload_stat DEFAULT CHARSET utf8mb4; USE workload_stat; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, real_name VARCHAR(50) NOT NULL COMMENT 姓名, role_type TINYINT NOT NULL DEFAULT 0 COMMENT 0-员工 1-管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-启用 0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB COMMENT用户表; CREATE TABLE project ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, name VARCHAR(100) NOT NULL COMMENT 项目名称, code VARCHAR(50) NOT NULL UNIQUE COMMENT 项目编码, leader VARCHAR(50) COMMENT 负责人, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-进行中 0-关闭 ) ENGINEInnoDB COMMENT项目表; CREATE TABLE work_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, user_id BIGINT NOT NULL COMMENT 填报人ID, project_id BIGINT NOT NULL COMMENT 项目ID, work_date DATE NOT NULL COMMENT 工作日期, hours DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT 工时, work_content VARCHAR(500) NOT NULL COMMENT 工作内容, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-草稿 1-已提交, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, KEY idx_user_date (user_id, work_date), KEY idx_project_date (project_id, work_date) ) ENGINEInnoDB COMMENT工作量记录表;这里设计上有三个容易被忽略的细节。一是 hours 字段用 DECIMAL(5,1)不要用 DOUBLE浮点数在统计求和时会出 0.30000000000000004 这种神奇数字DECIMAL 精确到小数点后一位完全避开这个问题。二是索引要建立在查询频率高的组合上业务最常见的查询是“某个人某个月”和“某个项目某个月”所以我建了 idx_user_date 和 idx_project_date数据量上万时查索引和全表扫描差距是毫秒级和秒级的区别。三是 create_time 和 update_time 直接交给数据库维护减少代码里的手工赋值也避免有人忘了更新时间戳导致排查数据困难。2.2 登录认证与权限控制登录我用的 JWT 方案流程非常简单用户提交用户名密码后端校验通过后签发一个 token前端后续请求在请求头里带 Authorization: Bearer token后端拦截器统一校验。话不多说核心代码逻辑长这样。注意密码必须用 BCrypt 加密绝对不能明文存库。PostMapping(/login) public Result login(RequestBody LoginDTO dto) { User user userMapper.selectByUsername(dto.getUsername()); if (user null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } if (user.getStatus() 0) { return Result.error(账号已被禁用请联系管理员); } String token JwtUtil.createToken(user); return Result.success(token); }JWT 拦截器里我用的方案是从请求头取 token解析成功后把 userId 和 roleType 放进 ThreadLocal。这样后续业务代码里随时能拿到当前登录人而不必每个方法都传参也不要用 HttpSession——前后端分离后 session 管理麻烦JWT 天然无状态后端重启前端也不受影响。权限控制我做得比较轻只写了一个注解和一个拦截器判断在需要管理员权限的接口上标注 RequireAdmin进入 Controller 前判断 ThreadLocal 里的 roleType 是否为 1不是就返回 403。比 Spring Security 轻量得多毕竟系统就两类角色没必要为一个开关引入一套带过滤链的复杂框架。有一个坑必须提醒BCrypt 每次生成的新哈希值都不同所以你不能在数据库中保存“明文密码的 BCrypt 结果”然后拿去和登录输入的 BCrypt 结果直接比较。必须用 BCrypt.checkpw(明文, 库中哈希) 这个专用方法校验很多人第一次写都踩过这个坑。2.3 工作量填报与统计的核心接口整个系统的业务核心是 work_record 的 CRUD 和统计。填报接口除了参数验证还要做业务校验工时必须大于 0、小于等于 24且是 0.5 的倍数工作日期不能是未来日期同一用户同一天同一个项目不能重复填报这三条校验在 Controller 里处理不优雅我放在 service 层做通过 IllegalArgumentException 抛异常然后全局异常处理器兜底。核心接口如下接口路径方法说明权限/api/auth/loginPOST登录获取 token所有用户/api/work/recordPOST新增填报记录员工/管理员/api/work/recordPUT修改自己的记录员工本人/管理员/api/work/record/{id}DELETE删除记录员工本人/管理员/api/work/recordsGET分页条件查询员工看自己管理员看全部/api/work/stats/monthlyGET按月统计按人/按项目员工/管理员/api/user/listGET用户列表管理员分页查询的 MyBatis 写法是重头戏动态 SQL 里要注意 operator 拼接。这是我写的一个按条件查询的 mapper XML重点看 WHERE 和条件标签select idselectRecordsWithCondition resultTypecom.example.entity.WorkRecordVO SELECT r.id, r.work_date, r.hours, r.work_content, r.status, r.user_id, u.real_name, r.project_id, p.name AS project_name FROM work_record r LEFT JOIN sys_user u ON r.user_id u.id LEFT JOIN project p ON r.project_id p.id where if testuserId ! null AND r.user_id #{userId} /if if testmonth ! null and month ! AND DATE_FORMAT(r.work_date, %Y-%m) #{month} /if if testprojectId ! null AND r.project_id #{projectId} /if /where ORDER BY r.work_date DESC /select统计接口我分两个维度按人员汇总和按项目汇总。月度按项目统计的 SQL 是这样的select idselectStatsByProject resultTypecom.example.dto.ProjectStatsDTO SELECT p.id AS project_id, p.name AS project_name, SUM(r.hours) AS total_hours, COUNT(DISTINCT r.user_id) AS participant_count FROM work_record r LEFT JOIN project p ON r.project_id p.id where if testmonth ! null and month ! AND DATE_FORMAT(r.work_date, %Y-%m) #{month} /if /where GROUP BY p.id, p.name ORDER BY total_hours DESC /select用 COUNT(DISTINCT r.user_id) 统计参与人数是个小技巧如果不加 DISTINCT同一个人填了一百条记录会被算成一百人这个坑我亲眼见过别人踩过。3. 前端模块实现Vue 与页面交互细节3.1 页面结构、路由与权限守卫前端整体是经典的管理后台布局左侧菜单栏右边内容区。菜单只有三项工作量填报、统计分析、用户管理其中用户管理只对管理员展示。路由配置采用嵌套路由模式{ path: /login, component: Login }, { path: /, component: Layout, redirect: /work/record, children: [ { path: work/record, component: WorkRecord }, { path: work/stats, component: WorkStats }, { path: user/manage, component: UserManage, meta: { requireAdmin: true } } ] }路由守卫的逻辑很简单判断 localStorage 里有没有 token没有就跳登录页判断目标路由的 meta.requireAdmin 是否为 true如果是且当前登录人 roleType 不是 1就跳回工作量填报页并提示无权限。这套守卫比后端权限早一道拦截用户体验更好。这里有一个开发时容易犯的错用 sessionStorage 而不是 localStorage 存 token。刷新页面时 sessionStorage 还在但新开一个标签页就会丢失用户莫名其妙被登出。统一用 localStorage 省心。另外路由模式用 history 时刷新页面会出现 404这个问题等部署章节详细说nginx 配一行就能解决。3.2 axios 封装与接口联调前端所有请求走一个封装好的 axios 实例。核心工作有两点请求拦截器里自动带上 token响应拦截器里统一处理后端返回结构和异常。const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use(response { const res response.data if (res.code ! 200) { if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.message || 请求失败)) } return res }, error { return Promise.reject(error) }) export default service这样的好处是页面代码里不用每个请求都写 token 处理逻辑后端返回 code 非 200 也能统一处理弹窗提示。很多人写 axios 封装时忽略了 401 的全局处理导致 token 过期后每个页面报一堆乱七八糟的错而用户体验应该是登录过期回到登录页。开发联调阶段跨域问题交给 vue.config.js 的 devServer proxy 处理后端不用额外开 CORS。配置如下devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }3.3 统计报表的图表化呈现工作量填报表单页表格加筛选条件核心是月筛选。统计分析页我用了 ECharts柱状图展示每个人当月总工时折线图展示近 6 个月整体工时趋势再用一个表格展示按项目维度的汇总数据。图表数据来自 /api/work/stats/monthly 接口前端拿到数据后需要转成 ECharts 需要的格式。这里注意一点不要在后端就把数据结构拼成 ECharts 的 series 格式前端只拿纯数据由前端去组装这样后端接口通用性更高。比如后端返回 [{ name: 张三, hours: 168.5 }]前端 map 成 { xAxis: [张三], series: [168.5] }后续换图表库或者新增图表都方便。报表页还有一个交互细节点击项目名称时下方明细表联动筛选出该项目当月的所有记录。这个做法是通过重新请求分页接口并带上 projectId 参数实现的比在前端本地 filter 更靠谱因为明细数据可能量很大本地过滤只适用于全部数据已经加载完毕的场景。4. 部署发布从打包到上线完整流程4.1 本地环境准备与初始化一套干净环境需要JDK 8 或以上、Maven 3.6、MySQL 8.0、Node.js 14。JDK 版本别用太高SpringBoot 2.7 对 JDK 17 也兼容但 8 最稳。数据库初始化用我前面给的建表脚本再插入两个初始账号管理员 admin / admin123普通员工 zhangsan / 123456。密码在 SQL 里直接给 BCrypt 加密后的字符串不要给明文避免有人把脚本原样用到生产环境却不知道密码明文存了库。4.2 后端打包与外置配置后端打包命令简单mvn clean package -DskipTests。跳过测试能省不少时间因为项目里测试用例覆盖不多没必要每次打包都跑。打包前要检查 application.yml 里的关键配置。数据库密码和环境相关不应该写死在源码里我一般用环境变量占位server: port: 8080 spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/workload_stat?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ${DB_USER:root} password: ${DB_PASSWORD:123456} mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity jwt: secret: ${JWT_SECRET:my-default-secret-key-please-change-in-prod} expire-hours: 24这里的 serverTimezoneAsia/Shanghai 是必需品MySQL 8 默认时区和本地不一致会导致日期查询差 8 小时肉眼排查时非常痛苦。启动命令用 nohup 放到后台nohup java -jar workload-server.jar --spring.profiles.activeprod app.log 21 日志一定要落文件不然你重启后连为什么启动失败都看不到。观察日志用 tail -f app.log。4.3 前端构建与 nginx 部署前端构建命令 npm run build产物在 dist 目录。把 dist 整个目录上传到服务器的 nginx html 目录下即可。nginx 配置是部署环节最关键的。两点必须做对一是 vue-router 的 history 模式需要 try_files 兜底否则刷新页面 404二是 /api 路径要反向代理到后端。我的配置如下server { listen 80; server_name _; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意 proxy_pass 后面没有加末尾斜杠这样请求 /api/auth/login 原样转发给后端 8080 端口的 /api/auth/login。如果你误写成 proxy_pass http://127.0.0.1:8080/; 那 /api/auth/login 会被转发成 /auth/login后端口没有这个路径立刻 404。这个细节我亲手犯过排查了半小时才反应过来。4.4 上线前的检查清单我每次部署都会过一遍这个清单很多问题都靠它提前避免数据库是否已经执行最新表结构脚本密码是否是 BCrypt 加密后端的 application.yml 是否使用了生产环境的数据库地址和账号前端 dist 里的接口 baseURL 是否指向生产环境这里因为用 nginx 代理前端写 /api 相对地址即可nginx 配置里 try_files 是否配置history 模式必须要有服务器防火墙是否放行了 80 和 8080 端口用 curl 测试后端接口是否返回 JSON再测试前端页面能否正常打开第一件事先用管理员登录创建一个新项目再让另一个账号填报一条记录走通完整流程这套清单看起来麻烦但能帮你省掉上线后的半夜救火。5. 运行期常见问题与排查实录5.1 跨域与请求异常开发环境最常见的是浏览器报 CORS 错误Access to XMLHttpRequest ... has been blocked by CORS policy。原因无非三种proxy 配了但前端请求用了绝对地址比如 http://localhost:8080/api 而不是 /api或者改了后端端口没同步改 proxy target或者直接用 file:// 协议打开打包后的 HTML。排查顺序先确认请求 URL 是否走 /api 相对路径再确认 proxy target 的端口和后端实际端口一致最后看浏览器 Network 面板里请求的完整地址。生产环境如果也报跨域多半是 nginx 里没有配 proxy或者前端请求地址写死了某个域名导致跨域。按我上面的配置只要前端用 /api 相对路径所有跨域问题都不该存在。5.2 数据库时区与字符集问题模拟一个真实场景员工填报的记录时间是 2025-05-01但在库里查出来却是 2025-04-30。这就是 serverTimezone 没配对导致的。MySQL 8 驱动默认时区是 UTC中国在东八区差 8 小时。必须在 JDBC URL 里写 serverTimezoneAsia/Shanghai。字符集问题通常表现是插入中文变成问号或者乱码。三个层面都要检查建库时 DEFAULT CHARSET 是否 utf8mb4、JDBC URL 是否有 useUnicodetruecharacterEncodingutf8、前端页面本身是否 UTF-8 编码。这三层少一层的项目我都见过统一配齐基本不会乱码。5.3 数据权限与统计口径问题系统上线后最容易发生的业务争执是统计数字对不上。常见的根因有三个第一导出数据时没有过滤已删除的记录导致重复计数我建议所有删除操作改成逻辑删除而非物理删除写 SQL 时统一加 status 条件第二统计工时用的是 hours 字段是 0.5 的倍数但有人填 2.5有人填 2.50前端展示时格式化补零解决不要在数据库层面改来改去第三跨月场景——记录填在下个月但业务上应该算在上个月这个规则必须和业务方敲死不要把模糊规则放进代码里自己猜。还有一个特别容易忽略的权限漏洞普通用户调用 GET /api/work/records 接口手动改参数 userId 查别人的数据。后端不能只靠前端隐藏按钮必须强制按登录人生成查询条件——管理员传 userId 才生效普通用户的后端代码里直接用 ThreadLocal 的 userId 覆盖前端传入的 userId忽略传参。这个校验我放在 service 层从源头防住。5.4 JWT 过期与刷新机制JWT 过期后前端会提示“登录已过期请重新登录”。简洁方案是设置较长过期时间比如 24 小时到期后重新登录。更完善的方案是加 refresh token对内部工具系统来说有点过度设计。我实际采用的是token 过期时间 24 小时过期的请求在后端返回 401前端拦截后跳转登录页并带上当前页面路径用户重新登录后跳回刚才的页面。这个小体验优化成本很低但用户好感度提升明显。要防止 JWT 伪造secret 必须足够长且不在代码仓库里明文保存。我通常是启动时从环境变量注入密钥如果环境变量没配则用代码里的默认值但在生产环境永远要强制指定。我个人做完这套系统最大的体会是技术栈本身一点不难难的是把工作量填报、统计口径和权限边界这些业务规则想清楚再动手写 SQL。这个系统的扩展空间其实很大比如再加上 Excel 导入导出、管理员按月关闭填报窗口、项目维度的预算对比都是很实用的方向。如果你们团队的月度统计还在用 Excel 手工折腾照着这套设计搭一个轻量系统完全够用。后续有朋友需要我也可以把权限模型做得更细把审批流加进去毕竟这套地基已经打好了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑