☰
SpringBoot+Vue学生考勤管理系统:从数据库设计到部署避坑全流程
2026/10/7 10:35:13 网站建设 项目流程

简介:这是一套面向计算机相关专业毕业设计与课程设计场景的学生考勤管理系统完整项目包,采用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 分开,能避免「前端多传一个字段就把数据库改了」这类事故。统一返回体Result<T>和全局异常处理@RestControllerAdvice是必加的,否则前端拿到的错误信息格式五花八门,联调时你会想砸键盘。

2.2 前端选 Vue 的版本决策与目录规划

Vue 这边第一个决策是 Vue 2 还是 Vue 3。2024 年之后新项目没有理由再用 Vue 2,Vue 3 + Vite + Element Plus 是当前主流。热词里「vue 安装及环境配置」「vue 安装依赖」「vue 入门」搜索量高,说明很多人卡在环境这一步。

环境配置的最小步骤:

# 确认 Node 版本,Vite 要求 Node 16+ node -v # 用 npm 创建 Vue3 项目(选 Vue + JavaScript 即可,不必上 TS) npm create vite@latest attendance-web -- --template vue cd attendance-web npm install npm install element-plus axios vue-router pinia npm run dev

npm 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 班级表 CREATE TABLE `class_info` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `class_name` VARCHAR(50) NOT NULL, `grade` VARCHAR(20) NOT NULL COMMENT '年级,如2024级' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 考勤任务表:一次点名对应一条 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 考勤记录表:一个学生一次任务一条 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 请假表 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

四个关键设计点:attendance_record上的uk_task_student唯一索引是防重复签到的第一道防线;status用 TINYINT 而不是字符串,统计时SUM(status=1)比COUNT(CASE WHEN...)快;sign_time单独存而不是用create_time,因为补签场景下签到时间和记录创建时间不是一回事;所有时间字段用 DATETIME 不用 TIMESTAMP,避免时区和 2038 问题。

3.2 统计查询的 SQL 写法

考勤统计最常见的需求是「某班级某课程的出勤率」。新手容易写成 N+1 查询:先查学生列表,再循环查每人记录。正确做法是一条聚合 SQL:

SELECT 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 LambdaQueryWrapper<AttendanceRecord>() .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 节的聚合 SQL,MyBatis 里用<select>映射到 DTO:

<select id="selectClassStats" resultType="com.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 test="courseName != 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 { List<StatDTO> list = statsMapper.selectClassStats(classId, courseName); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=stats.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 request

baseURL: '/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-dialog+el-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生成一个 jar,java -jar启动,浏览器访问 8080 端口就能看到完整系统。

注意:如果前端路由用了 history 模式但没配这个转发,刷新页面会 404。这是热词「vue 路由」相关搜索里最常见的坑。

6. 避坑与排查:这套系统最容易翻车的五个地方

现象一:启动报Table 'xxx' doesn't exist。原因:数据库没建表或表名大小写不一致。Linux 下 MySQL 默认表名区分大小写,Windows 不区分,本地跑通部署到服务器就挂。解决:建表 SQL 里表名统一小写,application.yml里配lower_case_table_names=1需要改 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 List<StatDTO> getClassStats(Long classId, String courseName) { return statsMapper.selectClassStats(classId, courseName); }

考勤数据不是实时性要求极高的场景,5 分钟缓存完全可接受,但能把统计页面的加载时间从 2 秒降到 50 毫秒。记得在签到、修改考勤状态的接口上加@CacheEvict清除对应缓存,否则数据会不一致。

最后说个习惯:每次改完统计 SQL,我都会拿一个班的真实数据手工算一遍出勤率,和系统结果对一下。有次发现ROUND在除数为 0 时返回 NULL,前端显示空白,后来在 SQL 里加了IFNULL(..., 0)才解决。这种问题不手工验证根本发现不了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询