简介这是一套面向计算机相关专业毕业设计与课程设计场景的学生考勤管理系统完整项目包采用SpringBoot后端与Vue前端分离架构以MySQL作为后台数据库基于B/S模式开发。系统按角色划分为管理员、教师、学生三大模块覆盖学生管理、教师管理、班级与课程信息管理、签到与考勤信息管理、请假信息管理以及考勤统计等核心业务可直接作为毕设、期末大作业或Java项目实战练习使用。资源包共428个文件约69.47MB其中107个java文件承载后端业务逻辑43个vue文件构建前端页面另有xml、yml、properties等配置文件和sql数据库脚本以及docx开发说明文档、答辩PPT、演示视频与代码注释配套素材包含svg、jpg、png等图形资源。项目已通过严格调试确保可运行并附有LW与完整说明便于快速理解目录结构与业务流转。目前已有677人学习下载适合需要完整赛题方案与排错参考的学习者。1. 学生考勤管理系统为什么还在用 SpringBoot Vue 这套组合每年毕业季基于 SpringBoot Vue 的学生考勤管理系统设计与实现都是计算机专业选题里的高频选项。原因很直接业务闭环清晰教师发考勤、学生签到、辅导员统计技术栈成熟后端 SpringBoot、前端 Vue、数据库 MySQL文档和源码容易整理成一套可交付的压缩包。但真正动手做过的人都知道这类系统翻车往往不在业务逻辑而在几个具体环节Vue 打包后怎么塞进 SpringBoot、考勤状态怎么防重复提交、统计报表的 SQL 怎么写才不慢、数据库表结构一开始没设计好后面改到崩溃。这篇笔记面向三类人正在做这个选题的学生、需要快速交付一个内部考勤工具的开发者、以及想拿这套架构练手全栈的新手。我会按「技术选型理由 → 数据库设计 → 后端接口 → 前端页面 → 联调部署 → 避坑」的顺序把每个环节的可复现步骤、参数配置和踩坑记录写清楚。整套方案不依赖任何特定版本的源码包你照着搭就能跑起来。2. 技术选型与项目骨架为什么是 SpringBoot Vue 而不是别的2.1 后端选 SpringBoot 的三个现实理由学生考勤管理系统的后端需求其实不复杂用户认证、考勤记录增删改查、统计查询、导出报表。SpringBoot 在这个场景下的优势不是「先进」而是「省事」。第一内嵌 Tomcat 让部署变成一条java -jar命令不需要单独装 Web 服务器。第二SpringBoot 的 starter 依赖把 MyBatis、MySQL 驱动、JSON 序列化这些常用库的版本冲突问题提前解决了新手不会卡在依赖地狱里。第三热词里频繁出现的「springboot 配置」「springboot 项目结构」「springboot 版本太高」这些搜索说明大量人卡在配置和版本上而这恰恰是有标准答案的部分。版本选择上我一般建议 JDK 17 SpringBoot 3.2.x。如果学校机房还在用 JDK 8那就退到 SpringBoot 2.7.x这是最后一个支持 JDK 8 的稳定分支。不要用 SpringBoot 3.0.0 这种早期版本bug 多。热词里「springboot 版本太高」的典型症状是启动报Unsupported class file major version原因就是 JDK 版本和 SpringBoot 要求不匹配。项目结构按标准分层来src/main/java/com/example/attendance/ ├── AttendanceApplication.java // 启动类 ├── config/ // 配置类 │ ├── WebConfig.java // 跨域、拦截器注册 │ └── MybatisConfig.java // 分页插件等 ├── controller/ // 接口层 ├── service/ // 业务层 │ └── impl/ ├── mapper/ // 数据访问层 ├── entity/ // 数据库实体 ├── dto/ // 请求/响应对象 └── common/ // 统一返回、异常处理这个结构不是摆设。把 DTO 和 Entity 分开能避免「前端多传一个字段就把数据库改了」这类事故。统一返回体ResultT和全局异常处理RestControllerAdvice是必加的否则前端拿到的错误信息格式五花八门联调时你会想砸键盘。2.2 前端选 Vue 的版本决策与目录规划Vue 这边第一个决策是 Vue 2 还是 Vue 3。2024 年之后新项目没有理由再用 Vue 2Vue 3 Vite Element Plus 是当前主流。热词里「vue 安装及环境配置」「vue 安装依赖」「vue 入门」搜索量高说明很多人卡在环境这一步。环境配置的最小步骤# 确认 Node 版本Vite 要求 Node 16 node -v # 用 npm 创建 Vue3 项目选 Vue JavaScript 即可不必上 TS npm create vitelatest attendance-web -- --template vue cd attendance-web npm install npm install element-plus axios vue-router pinia npm run devnpm create vite比vue create快很多因为底层用 esbuild 预构建。element-plus提供表格、表单、日期选择器这些考勤系统必需的组件axios负责请求vue-router管路由pinia管登录状态和全局用户信息。目录规划src/ ├── api/ // 接口封装按模块分文件 ├── router/ // 路由配置 ├── store/ // pinia 状态 ├── views/ // 页面 │ ├── login/ │ ├── attendance/ // 考勤相关页面 │ └── stats/ // 统计报表 ├── components/ // 公共组件 └── utils/request.js // axios 实例与拦截器utils/request.js里要配两件事请求拦截器统一加 token响应拦截器统一处理 401 跳登录。这个文件写好了后面所有接口调用都省心。2.3 前后端分离还是打包合并这是热词「vue 打包放进 springboot 中」直接对应的问题。两种做法方案做法适用场景代价前后端分离前端独立部署Nginx 反代有服务器、要独立扩容需要配跨域或 Nginx打包合并npm run build产物放进static/单机部署、交作业、内网工具每次改前端要重新打包交作业或做内部工具我建议打包合并一个 jar 包搞定答辩演示不会因为「前端服务没起」翻车。具体做法在部署章节展开。3. 数据库设计考勤表结构一开始就要想清楚的四件事3.1 核心表与字段设计考勤系统的数据库不复杂但有几个字段如果一开始没设计好后面统计查询会非常难受。核心表我一般设计五张-- 用户表学生和教师共用用 role 区分 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 学号/工号, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密存储, real_name VARCHAR(50) NOT NULL, role TINYINT NOT NULL COMMENT 1学生 2教师 3管理员, class_id BIGINT DEFAULT NULL COMMENT 学生所属班级, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 班级表 CREATE TABLE class_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL, grade VARCHAR(20) NOT NULL COMMENT 年级如2024级 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 考勤任务表一次点名对应一条 CREATE TABLE attendance_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id BIGINT NOT NULL, class_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 1 COMMENT 1进行中 2已结束, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_teacher (teacher_id), INDEX idx_class (class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 考勤记录表一个学生一次任务一条 CREATE TABLE attendance_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status TINYINT NOT NULL COMMENT 1出勤 2迟到 3缺勤 4请假, sign_time DATETIME DEFAULT NULL, remark VARCHAR(200) DEFAULT NULL, UNIQUE KEY uk_task_student (task_id, student_id), INDEX idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 请假表 CREATE TABLE leave_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, task_id BIGINT DEFAULT NULL, reason VARCHAR(500) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, audit_status TINYINT DEFAULT 0 COMMENT 0待审 1通过 2驳回, audit_by BIGINT DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;四个关键设计点attendance_record上的uk_task_student唯一索引是防重复签到的第一道防线status用 TINYINT 而不是字符串统计时SUM(status1)比COUNT(CASE WHEN...)快sign_time单独存而不是用create_time因为补签场景下签到时间和记录创建时间不是一回事所有时间字段用 DATETIME 不用 TIMESTAMP避免时区和 2038 问题。3.2 统计查询的 SQL 写法考勤统计最常见的需求是「某班级某课程的出勤率」。新手容易写成 N1 查询先查学生列表再循环查每人记录。正确做法是一条聚合 SQLSELECT u.id AS student_id, u.real_name, COUNT(r.id) AS total_count, SUM(CASE WHEN r.status 1 THEN 1 ELSE 0 END) AS present_count, SUM(CASE WHEN r.status 2 THEN 1 ELSE 0 END) AS late_count, SUM(CASE WHEN r.status 3 THEN 1 ELSE 0 END) AS absent_count, ROUND(SUM(CASE WHEN r.status 1 THEN 1 ELSE 0 END) / COUNT(r.id) * 100, 2) AS rate FROM sys_user u LEFT JOIN attendance_record r ON r.student_id u.id LEFT JOIN attendance_task t ON t.id r.task_id WHERE u.class_id #{classId} AND u.role 1 AND t.course_name #{courseName} GROUP BY u.id, u.real_name ORDER BY rate ASC;用LEFT JOIN保证没有考勤记录的学生也出现在结果里出勤率显示 0 而不是消失。SUM(CASE WHEN...)是 MySQL 里做条件计数的标准写法比多次子查询快一个数量级。如果数据量大在attendance_record的task_id上建索引让 JOIN 走索引而不是全表扫描。提示ROUND的第二个参数控制小数位出勤率保留两位就够。如果分母可能为 0学生没有任何考勤记录COUNT(r.id)会是 0除法会得到 NULL前端要处理这种情况显示「暂无数据」。3.3 数据库工具与迁移注意事项热词里「dbx 数据库工具」「数据库同步软件」「mysql 数据库修改结构」这些搜索反映的是开发过程中改表结构的痛点。我的习惯是开发阶段用 Navicat 或 DBeaver 直接改但每次改完必须同步更新一份schema.sql放进项目resources/目录。这样别人拿到项目能一键建表你自己换台机器也能快速恢复。如果表结构改动涉及已有数据比如给attendance_record加字段不要直接ALTER TABLE完事。先备份再在测试库跑一遍确认历史数据的默认值合理。我见过有人给status字段加了个新枚举值结果统计 SQL 里的CASE WHEN没更新报表数字全错排查了一下午。4. 后端接口实现从登录认证到考勤提交的完整链路4.1 登录认证与 JWT 拦截器考勤系统的权限边界很清晰学生只能看自己的记录教师能管自己班级管理员全看。用 JWT 做无状态认证比 Session 更适合前后端分离。// JwtUtil.java 核心方法 public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); }SECRET不要硬编码在代码里放application.yml并通过环境变量注入。过期时间设 7 天是折中太短学生上课上一半掉登录太长 token 泄露风险大。拦截器注册在WebConfig里放行/api/auth/login和静态资源其余请求校验 token。校验失败返回 401前端 axios 拦截器捕获后跳登录页。Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/captcha); }4.2 考勤提交的防重复与状态判定学生签到接口是整个系统最容易出问题的地方。三个必须处理的点重复提交、时间窗口、状态判定。Transactional public Result? sign(Long taskId, Long studentId) { // 1. 查任务是否存在且在进行中 AttendanceTask task taskMapper.selectById(taskId); if (task null || task.getStatus() ! 1) { return Result.fail(考勤任务不存在或已结束); } // 2. 时间窗口校验 LocalDateTime now LocalDateTime.now(); if (now.isBefore(task.getStartTime()) || now.isAfter(task.getEndTime())) { return Result.fail(不在签到时间范围内); } // 3. 唯一索引兜底 先查后插 AttendanceRecord exist recordMapper.selectOne( new LambdaQueryWrapperAttendanceRecord() .eq(AttendanceRecord::getTaskId, taskId) .eq(AttendanceRecord::getStudentId, studentId)); if (exist ! null) { return Result.fail(请勿重复签到); } // 4. 状态判定开始后 10 分钟内为出勤之后为迟到 int status now.isBefore(task.getStartTime().plusMinutes(10)) ? 1 : 2; AttendanceRecord record new AttendanceRecord(); record.setTaskId(taskId); record.setStudentId(studentId); record.setStatus(status); record.setSignTime(now); try { recordMapper.insert(record); } catch (DuplicateKeyException e) { // 并发下唯一索引拦截返回友好提示 return Result.fail(请勿重复签到); } return Result.ok(status 1 ? 签到成功 : 签到成功迟到); }Transactional保证插入失败时事务回滚。先查后插是为了给用户友好提示但真正防并发靠的是数据库唯一索引uk_task_student捕获DuplicateKeyException是最后一道防线。状态判定的 10 分钟阈值应该做成配置项不同课程要求不一样。4.3 统计接口与导出统计接口直接调用 3.2 节的聚合 SQLMyBatis 里用select映射到 DTOselect idselectClassStats resultTypecom.example.attendance.dto.StatDTO SELECT u.id AS studentId, u.real_name AS realName, COUNT(r.id) AS totalCount, SUM(CASE WHEN r.status 1 THEN 1 ELSE 0 END) AS presentCount, ROUND(SUM(CASE WHEN r.status 1 THEN 1 ELSE 0 END) / COUNT(r.id) * 100, 2) AS rate FROM sys_user u LEFT JOIN attendance_record r ON r.student_id u.id LEFT JOIN attendance_task t ON t.id r.task_id WHERE u.class_id #{classId} AND u.role 1 if testcourseName ! null and courseName ! AND t.course_name #{courseName} /if GROUP BY u.id, u.real_name ORDER BY rate ASC /select导出 Excel 用 EasyExcel比 POI 省内存。核心代码public void exportStats(Long classId, String courseName, HttpServletResponse response) throws IOException { ListStatDTO list statsMapper.selectClassStats(classId, courseName); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filenamestats.xlsx); EasyExcel.write(response.getOutputStream(), StatDTO.class) .sheet(考勤统计) .doWrite(list); }StatDTO上用ExcelProperty(姓名)注解控制表头。注意Content-Disposition里的文件名如果含中文要 URL 编码否则部分浏览器下载文件名乱码。5. 前端页面与联调Vue 页面怎么和 SpringBoot 接口对上5.1 axios 封装与接口约定前后端联调翻车八成是接口格式没约定好。我的做法是后端统一返回{code, msg, data}前端 axios 拦截器统一处理。// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers.Authorization Bearer ${token} return config }) request.interceptors.response.use( res { const { code, msg, data } res.data if (code 200) return data ElMessage.error(msg || 请求失败) return Promise.reject(new Error(msg)) }, err { if (err.response?.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(err) } ) export default requestbaseURL: /api配合开发环境的 Vite 代理避免跨域。vite.config.js里配export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样开发时前端 5173 端口请求/api/xxx会被代理到后端 8080生产环境打包后同源部署baseURL不用改。5.2 考勤页面的关键交互学生签到页面要处理三个状态未开始、进行中、已结束。用computed根据任务时间动态渲染按钮const canSign computed(() { if (!task.value) return false const now Date.now() return task.value.status 1 now new Date(task.value.startTime).getTime() now new Date(task.value.endTime).getTime() })教师端发起考勤用 Element Plus 的el-dialogel-form班级下拉框数据从/api/class/list拉。提交后刷新任务列表。统计页面用el-table展示出勤率低于 60% 的行用row-class-name标红const rowClassName ({ row }) row.rate 60 ? warning-row : 配合 CSS.warning-row { background: #fef0f0; }。这个视觉提示在答辩演示时很加分。5.3 打包合并部署前端npm run build生成dist/把里面的文件复制到 SpringBoot 的src/main/resources/static/下。然后配一个转发控制器让非/api开头的请求都返回index.html支持 Vue Router 的 history 模式Controller public class IndexController { RequestMapping(value {/, /{path:[^\\.]*}}) public String index() { return forward:/index.html; } }[^\\.]*这个正则排除带点的路径静态资源避免把 js/css 请求也转发到 index.html。打包后mvn package生成一个 jarjava -jar启动浏览器访问 8080 端口就能看到完整系统。注意如果前端路由用了 history 模式但没配这个转发刷新页面会 404。这是热词「vue 路由」相关搜索里最常见的坑。6. 避坑与排查这套系统最容易翻车的五个地方现象一启动报Table xxx doesnt exist。原因数据库没建表或表名大小写不一致。Linux 下 MySQL 默认表名区分大小写Windows 不区分本地跑通部署到服务器就挂。解决建表 SQL 里表名统一小写application.yml里配lower_case_table_names1需要改 MySQL 配置更稳妥的做法是代码里表名全小写。现象二前端请求 404 但后端日志没记录。原因Vite 代理没生效或baseURL配错。解决检查vite.config.js的 proxy target 端口是否和后端一致检查 axios 实例的baseURL是否和代理前缀匹配。浏览器 F12 看请求实际发到了哪个地址。现象三签到接口并发下插入两条记录。原因先查后插之间有竞态窗口两个请求同时查到「不存在」然后都插入。解决数据库唯一索引uk_task_student必须建代码里捕获DuplicateKeyException。只靠代码判断防不住并发。现象四统计接口返回空但数据库有数据。原因LEFT JOIN的WHERE条件写在了ON后面还是WHERE后面搞混了。如果把t.course_name #{courseName}写在WHERE里会把没有匹配 task 的学生过滤掉LEFT JOIN退化成INNER JOIN。解决主表过滤条件放WHERE从表可选条件放ON。现象五打包后页面白屏控制台报资源 404。原因Vite 默认base: /如果部署在子路径下资源路径不对。解决vite.config.js里设base: ./用相对路径或者确认部署在根路径。另外检查dist/里的文件是否完整复制到了static/。7. 让考勤数据真正可用一个统计口径的进阶技巧系统能跑起来只是及格线考勤数据能不能被辅导员和教务真正用起来取决于统计口径的设计。我踩过最深的一个坑是一开始只统计「出勤率」结果辅导员问「哪些学生连续三次缺勤」系统答不上来。后来我加了一个「连续缺勤预警」的查询思路是用窗口函数标记连续缺勤段。MySQL 8.0 支持窗口函数写法如下SELECT student_id, real_name, course_name, absent_dates, cnt FROM ( SELECT u.id AS student_id, u.real_name, t.course_name, GROUP_CONCAT(DATE(t.start_time) ORDER BY t.start_time) AS absent_dates, COUNT(*) AS cnt, ROW_NUMBER() OVER (PARTITION BY u.id, t.course_name ORDER BY t.start_time) AS rn FROM attendance_record r JOIN sys_user u ON u.id r.student_id JOIN attendance_task t ON t.id r.task_id WHERE r.status 3 GROUP BY u.id, u.real_name, t.course_name, DATE(t.start_time) ) tmp GROUP BY student_id, real_name, course_name HAVING cnt 3;这个查询把每个学生每门课的缺勤日期聚合出来HAVING cnt 3筛出连续缺勤三次以上的。实际用的时候还要考虑日期是否连续中间隔了周末不算连续更严谨的做法是在应用层用 Java 处理日期序列SQL 只负责把缺勤记录拉出来。另一个实用技巧是给统计接口加缓存。考勤统计查询涉及多表 JOIN 和聚合班级人数上百时响应会到秒级。用 SpringBoot 的Cacheable配合 Caffeine 本地缓存设置 5 分钟过期Cacheable(value classStats, key #classId _ #courseName) public ListStatDTO getClassStats(Long classId, String courseName) { return statsMapper.selectClassStats(classId, courseName); }考勤数据不是实时性要求极高的场景5 分钟缓存完全可接受但能把统计页面的加载时间从 2 秒降到 50 毫秒。记得在签到、修改考勤状态的接口上加CacheEvict清除对应缓存否则数据会不一致。最后说个习惯每次改完统计 SQL我都会拿一个班的真实数据手工算一遍出勤率和系统结果对一下。有次发现ROUND在除数为 0 时返回 NULL前端显示空白后来在 SQL 里加了IFNULL(..., 0)才解决。这种问题不手工验证根本发现不了。希望帮到你。本文还有配套的精品资源点击获取