ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot+Vue3开发员工考勤管理系统:架构设计与实现全解析

SpringBoot+Vue3开发员工考勤管理系统:架构设计与实现全解析 员工考勤管理系统在很多项目里并不是一个很难写到能跑的功能难的是把打卡、请假、审批、统计拆成一套结构稳定、口径一致、上线后还能持续改的流程。用 SpringBoot 提供接口用 Vue3 做管理页面是目前前后端分离式管理系统里非常常见的选型。业务方通常只提一句话员工可以在页面里打卡管理员能够看到月底报表。但如果直接把这句话当成需求去建表后面大概率会出现迟到统计对不上、缺卡记录无法处理、请假状态管不住一类问题。这篇文章会按一个可落地的员工考勤管理系统来展开先梳理考勤核心规则再做数据库设计、SpringBoot 后端接口、Vue3 前端页面最后补上联调、生产部署、常见报错和排错清单。适合正在做相关毕业设计、课程项目或者准备接手企业内部人事考勤类小系统的开发者参考。文章里的代码和 SQL 是教学级示例落到自己项目时要按照实际包名、数据库版本和部署方式调整。1. 先把考勤业务规则理清楚再讨论技术选型很多考勤系统做砸不是因为不会写增删改查而是没有把“一天的出勤结果怎么算出来”这件事讲清楚。程序员如果只知道“打卡成功就行”就会在月底统计时被各种状态问题反复折磨。1.1 考勤管理不等于打两次卡典型员工考勤系统至少包含三类对象。第一类是原始打卡行为用户在某年某月某日几点几分按了一次上班卡或下班卡。这个行为只表示“发生了一次打卡”它本身不说明打卡人是否迟到。第二类是出勤结果也就是员工在某一天究竟算正常、迟到、早退、缺卡还是缺勤。出勤结果需要结合排班规则算出来。第三类是请假和审批请假单一旦审批通过通常会影响当天的出勤判断。例如当天上午请假、下午正常打卡最终打卡结果不能简单按“缺勤半天迟到”处理。如果开发时把所有信息堆在一张表里比如把“今天是否正常”“是否迟到”直接写到打卡明细行上短期看着方便后期一旦要支持调班、补卡、异常申诉数据口径会非常混乱。1.2 迟到的判断必须依赖班次时间上午 9 点上班员工 9 点 20 分打卡通常算迟到。但如果没有班次概念系统根本没依据判断什么是迟到。最简单的固定班制包含几个参数上班时间、下班时间、宽限分钟数。宽限分钟数用于处理“公司允许缓冲 10 分钟”这类规则。计算逻辑可以这样表达实际打卡时间晚于“上班时间 宽限分钟”记为迟到。实际打卡时间早于“下班时间 - 宽限分钟”记为早退。只有上班卡没有下班卡记为缺卡。一天内没有任何打卡记录且没有通过审批的请假记为缺勤。固定班制只用一张配置表就能完成。更复杂的排班、轮班、跨天班次需要引入“班次表”和“员工班次关系表”这篇先不做展开。1.3 所有状态字段要先把枚举定下来在 Java 中最好不要使用字符串散装记录状态例如直接写“迟到”或late。建议使用数字枚举并在数据库字段注释和代码常量里统一说明。考勤记录状态的常见设计如下status含义说明0未出勤当天没有任何打卡也没有审批通过的请假1正常上班不迟到、下班不早退2迟到有上班卡但超过允许宽限3早退有下班卡但早于允许时间4迟到并且早退上下班均有异常5缺卡有上班卡无下班卡或有下班卡无上班卡请假状态则单独管理status含义0待审批1已通过2已驳回这些枚举要集中放在一个常量类、枚举类或者至少在数据库字段注释中写清楚。后面所有统计 SQL 都依赖这些数字含义等到月底发现统计错了再回去猜数字成本非常高。1.4 员工端和管理端功能要分开系统页面不能只有管理员视角。普通员工需要看到自己的状态管理员则需要全局处理数据。员工端功能上下班打卡。查看当天打卡结果。查看本月考勤日历。提交请假申请。查看审批结果。管理端功能员工账号维护。查看所有员工当天打卡情况。查看任意日期范围的考勤明细。月度统计报表包括出勤天数、迟到次数、早退次数、缺卡次数。请假审批。基础参数维护例如上下班时间、宽限分钟数。如果功能直接混在一个大后台里前端页面越来越长后权限逻辑会很难维护。2. 技术选型和前后端项目初始化明确了业务边界后再选择技术栈。SpringBoot Vue3 的组合在企业内部系统里使用很广SpringBoot 负责接口和数据Vue3 负责页面交互。2.1 环境版本先对齐实际开发时最容易出问题的不是框架本身而是 Java 版本和 SpringBoot 版本不对应。组件建议说明JDK17 或 21如果使用 SpringBoot 3.x必须使用 JDK 17 及以上SpringBoot3.x新项目建议使用 3.x但要注意依赖兼容性Node.js18Vue3 Vite 需要较高的 Node 版本Vue33.4使用setup语法写组合式 API前端构建Vite对比 Webpack 更轻量UI 组件Element Plus做后台管理系统很常用数据库MySQL 8.0字段支持更好事务稳定ORMMyBatis-Plus适合单表 CRUD 快速落地这里有一个很容易踩到的坑。如果你按照网上老教程下载 SpringBoot 2.7.x代码里导入的是javax.servlet.*如果下载 SpringBoot 3.x代码里要导入的是jakarta.servlet.*。不要把两个版本的配置混着复制。下文示例使用 SpringBoot 3.x 思路编写实际项目以你本机 Maven 仓库能拉到的最新稳定版为准。2.2 后端项目生成和后端依赖选择后端可以从 Spring Initializr 网站生成也可以手写 pom 文件。核心依赖至少包含 Web、MySQL 驱动、MyBatis-Plus、JWT 相关库、Lombok。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId !-- 实际版本以 Maven 仓库为准3.5 以上对 SpringBoot3 支持更好 -- version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意MyBatis-Plus 在 SpringBoot 3 时代要使用适配 jakarta 命名空间的 starter不要继续用只适配 SpringBoot 2 的旧版。如果报错里出现ClassNotFoundException: javax.servlet.Filter基本就是依赖版本和 SpringBoot 版本不匹配。2.3 接口文档依赖给后台管理类项目配 Swagger 接口文档很实用。SpringBoot 3 下常用 Knife4j 的 jakarta 版本也可以使用springdoc-openapi-starter-webmvc-ui。接口文档不影响业务功能但能在联调阶段帮前后端快速对齐参数。2.4 Vue3 前端项目创建前端使用 Vite 创建命令如下npm create vitelatest employee-attendance-web -- --template vue cd employee-attendance-web npm install npm install element-plus axios vue-router pinia npm run dev命令执行完成后Vite 默认会在 5173 端口启动开发服务。前端目录结构建议按页面隔离src ├── api │ ├── auth.js │ └── attendance.js ├── router │ └── index.js ├── store │ └── user.js ├── views │ ├── login.vue │ ├── attendance │ │ ├── check.vue │ │ ├── record.vue │ │ └── leave.vue │ └── admin │ ├── user.vue │ ├── approval.vue │ └── statistics.vue └── main.js开发阶段先把目录建好后面写页面时不会乱。如果只写单个大组件临时看效果很快但进入审批、统计这类多个页面后就会失控。3. 数据库设计打卡流水和出勤结果要分开思考考勤系统建表时最容易犯的错误是“把出勤结果当打卡记录存”。我建议记原始行为的表保持简单算出结果的逻辑放到 Service 层和统计 SQL 中。3.1 核心表结构教学级考勤系统至少需要三张表系统用户表、考勤记录表、请假表。业务参数可以单独放入配置表。员工账号表保存登录信息和基础人事字段CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后的密码, real_name VARCHAR(50) NOT NULL COMMENT 员工姓名, department VARCHAR(100) COMMENT 部门, phone VARCHAR(20) COMMENT 手机号, user_type TINYINT NOT NULL DEFAULT 1 COMMENT 1 员工2 管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 在职0 停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username(username) ) COMMENT系统用户表;考勤记录表按“员工 业务日期”唯一CREATE TABLE attendance_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 员工ID, attendance_date DATE NOT NULL COMMENT 业务日期, check_in_time DATETIME COMMENT 上班打卡时间, check_out_time DATETIME COMMENT 下班打卡时间, source VARCHAR(20) DEFAULT web COMMENT 打卡来源, remark VARCHAR(200) COMMENT 备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date(user_id, attendance_date) ) COMMENT员工考勤记录表;这里没有把“是否迟到”直接做成字段。出勤状态可以在每次打卡时计算并更新但不建议把状态作为流水表唯一依据。状态字段使用如下规则ALTER TABLE attendance_record ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT 0 未出勤1 正常2 迟到3 早退4 迟到早退5 缺卡6 请假;请假表包含时间范围、类型、原因和审批状态CREATE TABLE leave_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 申请人, leave_type VARCHAR(20) NOT NULL COMMENT 年假/事假/病假/调休, start_time DATETIME NOT NULL COMMENT 请假开始时间, end_time DATETIME NOT NULL COMMENT 请假结束时间, reason VARCHAR(500), status TINYINT NOT NULL DEFAULT 0 COMMENT 0 待审批1 已通过2 已驳回, approver_id BIGINT COMMENT 审批人ID, approve_time DATETIME, approve_remark VARCHAR(500), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT请假申请表;考勤基础参数可以简单用配置表管理CREATE TABLE sys_config ( config_key VARCHAR(50) PRIMARY KEY, config_value VARCHAR(200) NOT NULL ); INSERT INTO sys_config(config_key, config_value) VALUES (work_start_time, 09:00:00), (work_end_time, 18:00:00), (late_grace_minutes, 10), (early_grace_minutes, 10);把上下班时间写在配置表而不是代码里是为了以后调整班次时避免重新打包发版。固定班制阶段这个设计已经够用。3.2 关键字段为什么这样设计attendance_date使用DATE类型而不是直接取check_in_time的日期。因为员工可能在 23:58 上班打卡下班时间已经到了第二天凌晨。如果系统中暂时不考虑大夜班两者差别不大但以后要接入夜班时业务日期必须独立维护这个字段现在先留好会省很多事。check_in_time和check_out_time使用DATETIME类型而不是TIME类型。后续如果需要判断跨天、计算工作时长DATETIME可以直接参与时间运算TIME则丢失日期信息。唯一键uk_user_date保证同一个员工在同一天只有一条考勤记录。如果今天打了两次上班卡第二次要么被拒绝要么走补卡流程而不是新增两条数据。3.3 数据库和 Mysql 时间参数连接 MySQL 时数据库连接串需要显式指定serverTimezone否则 Java 和数据库之间的时区不一致考勤时间就会出现偏差。spring: datasource: url: jdbc:mysql://localhost:3306/attendance_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root中文字符集也建议在连接串中明确设置。某些 CentOS 环境默认字符集不是 UTF-8数据库表又是 utf8mb4插入中文姓名或请假原因时可能报Incorrect string value。4. SpringBoot 后端认证、打卡、统计、审批怎么做后端接口要按业务能力拆分而不是把代码全部写进 Controller 里。推荐至少按下面目录组织复杂项目还可以增加dto、vo、common包src/main/java/com/example/attendance ├── config │ ├── WebMvcConfig.java │ └── MybatisPlusConfig.java ├── controller │ ├── AuthController.java │ ├── AttendanceController.java │ └── LeaveController.java ├── entity │ ├── SysUser.java │ ├── AttendanceRecord.java │ └── LeaveRequest.java ├── mapper │ ├── SysUserMapper.java │ ├── AttendanceRecordMapper.java │ └── LeaveRequestMapper.java ├── dto │ └── LoginDTO.java ├── service │ ├── AuthService.java │ ├── AttendanceService.java │ └── LeaveService.java └── interceptor └── JwtAuthInterceptor.java4.1 登录认证使用 JWT 拦截器而不是在每个 Controller 里做判断如果系统里只有管理员和员工两种角色使用 Spring Security 的功能其实很强大但过滤器链的学习成本偏高。很多内部考勤项目会自己实现 JWT 签发和拦截器这样能快速跑通。核心思路如下用户输入用户名和密码。后端校验密码校验通过后签发 JWT。前端保存 token后续请求在Authorization请求头中携带。后端拦截器解析 token并把 userId 放入 request 属性。管理员相关接口再通过用户角色判断是否能访问。后端拦截器只处理非白名单地址。登录接口、Swagger 文档路径、静态资源路径需要放行。Component public class JwtAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } String token authHeader.substring(7); Long userId JwtUtils.parseUserId(token); if (userId null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } request.setAttribute(userId, userId); return true; } }实际项目中管理员角色判断可以使用一个RequireAdmin注解配合拦截器实现或者直接读取userType。不要在 Controller 里重复写一大段if userType ! 2逻辑否则接口一多很容易漏判。密码不能使用明文存储和明文比较。推荐使用 BCrypt 加密。数据库预置账号时要先通过BCryptPasswordEncoder生成密文再插入到password字段。4.2 打卡时间以后端为准打卡接口有一个容易被忽视的设计点不要信任前端传来的打卡时间。浏览器时间可以被用户修改登录用户的设备时钟也可能和服务器相差几分钟。正确做法是由后端在接收到请求时读取服务器当前时间并让前端调用成功后展示后端返回结果。上班打卡逻辑示例如下public AttendanceRecord checkIn(Long userId) { LocalDate today LocalDate.now(); AttendanceRecord record attendanceMapper.selectOne( new LambdaQueryWrapperAttendanceRecord() .eq(AttendanceRecord::getUserId, userId) .eq(AttendanceRecord::getAttendanceDate, today)); if (record ! null) { throw new ServiceException(今天已经打过上班卡); } record new AttendanceRecord(); record.setUserId(userId); record.setAttendanceDate(today); record.setCheckInTime(LocalDateTime.now()); record.setSource(web); attendanceMapper.insert(record); return record; }插入之后需要立即根据班次时间判断是否迟到。判断逻辑可以单独抽取一个方法public Integer calcStatus(AttendanceRecord record, ConfigDTO config) { LocalTime checkIn record.getCheckInTime().toLocalTime(); LocalTime checkOut record.getCheckOutTime() null ? null : record.getCheckOutTime().toLocalTime(); LocalTime start LocalTime.parse(config.getWorkStartTime()); LocalTime end LocalTime.parse(config.getWorkEndTime()); int lateGrace Integer.parseInt(config.getLateGraceMinutes()); int earlyGrace Integer.parseInt(config.getEarlyGraceMinutes()); boolean late checkIn ! null checkIn.isAfter(start.plusMinutes(lateGrace)); boolean early checkOut ! null checkOut.isBefore(end.minusMinutes(earlyGrace)); if (late early) { return 4; } if (late) { return 2; } if (early) { return 3; } if (checkIn ! null checkOut null) { return 5; } if (checkIn null checkOut ! null) { return 5; } if (checkIn ! null checkOut ! null) { return 1; } return 0; }这个计算方法把“迟到”和“早退”判断从打卡接口里拆出来。后面如果系统支持补卡、异常申诉也可以复用同一个计算函数避免口径不一致。下班打卡稍复杂一些因为可能当天员工上午请假、下午正常下班。这里先不做完整处理实现时要在打卡前查询当天请假状态。如果请假已经覆盖整天打卡应该提示“请假中无需打卡”。4.3 请假审批的状态流转请假单据先插入status 0管理员查看待审批列表后执行通过或者驳回public void approve(Long requestId, Long approverId, Integer nextStatus, String remark) { LeaveRequest leave leaveMapper.selectById(requestId); if (leave null) { throw new ServiceException(请假单不存在); } if (!Integer.valueOf(0).equals(leave.getStatus())) { throw new ServiceException(该请假单已经审批过不能重复审批); } leave.setStatus(nextStatus); leave.setApproverId(approverId); leave.setApproveTime(LocalDateTime.now()); leave.setApproveRemark(remark); leaveMapper.updateById(leave); }重复审批是多人员管理系统里最容易出现的并发问题。两个管理员同时打开一个待审批单后审批的人把先审批的结果又覆盖了。所以在更新语句里要带上状态条件例如UPDATE leave_request SET status ? WHERE id ? AND status 0。使用 MyBatis-Plus 时可以写自定义 SQL或者用乐观锁插件。4.4 月度统计接口月度统计需求通常包含每个员工一个月正常出勤多少天、迟到几次、早退几次、缺卡几次、请假多少天。最简单的做法是按用户和月份分组。Mapper 中使用动态 SQL 会非常清晰。SELECT user_id, DATE_FORMAT(attendance_date, %Y-%m) AS month, SUM(CASE WHEN
RELATED READING

延伸阅读

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