☰
Java毕业设计:小程序上课考勤系统实现全攻略,从接口到报表导出
2026/10/26 16:56:29 网站建设 项目流程

简介:一款基于Spring Boot与微信小程序的Java毕业设计源码包,面向本科毕业设计与Java开发者,实现后台管理、GPS签到、定位打卡、迟到统计等上课考勤功能,曾获优秀毕业设计,可作为高校考勤类项目的完整参考。包内共488个文件,压缩包仅4.3MB;核心包括51个Java后端源码,以及由128个JavaScript、75个WXML、74个WXSS构成的小程序前端,另有77个JSON配置、46个PNG图标、11个WXS辅助脚本,以及SQL数据库脚本、Spring Boot配置、Dockerfile与一键启动脚本,整体目录结构清晰,便于对照学习。随包附有详细系统操作文档与SQL初始化脚本,可帮助快速理解项目运行流程与数据库结构,运行环境为JDK 1.8与MySQL 5.7,后台与小程序端代码分明,便于二次开发。目前已有944人学习。尤其适合希望完整跑通前后端考勤项目、积累小程序与Spring Boot联调经验的开发者,也可作为毕业设计、课程设计或考勤类小程序开发的参考范本。

1. 这个「源代码.zip」能跑起来,不代表毕设就能过

拿到「java开发项目本科毕业设计小程序上课考勤系统源代码.zip」这样的交付包时,很多人的第一反应是解压、导入 IDE、启动,看到登录页出现就松了一口气。但以我带过的毕设和实际项目经验看,上课考勤系统的验收重点从来不在「能不能登录」,而在「业务闭环是否完整」:学生能不能按课程、按时间、按位置签到,教师能不能看到谁迟到谁缺勤,辅导员能不能期末一键导出报表。这套系统里,后端是 Java 技术栈(常见的是 Spring Boot + MyBatis-Plus),前端是微信小程序,两者通过 HTTP 接口通信。这篇文章就顺着「后端接口 → 小程序端 → 报表导出」这条链路,给你一套能照着复现的实现方案,并把最容易让人翻车的参数配置和边界坑提前标出来。

2. 拆开考勤系统:表结构、角色与核心流程先立住

2.1 三个角色与一条完整考勤闭环

上课考勤系统不是简单的「签到打卡」,它至少包含学生、教师、系统管理员三类角色。学生端要能查看当天课表、发起签到和签退、提交请假申请;教师端要能查看所授课程的学生出勤列表,并能手动补录或驳回异常记录;管理员端则负责课程维护、选课关系导入以及期末考勤统计导出。只有把这三个角色的数据链路打通,这个系统才值得写进简历。

我一般建议先把用例图在纸上画出来,再落到数据库。最小可用闭环是:学生选课后,上课前系统开放签到窗口 → 学生在小程序里定位并提交签到 → 后端校验课程时间与位置后写入考勤记录 → 教师端实时看到出勤率 → 期末导出统计表。这个闭环里最容易被忽视的是「请假审批」和「补签审核」,很多毕设做完签到功能就停了,答辩时老师一问「学生生病了怎么办」就卡住。所以哪怕时间紧,也建议预留请假表和审批状态字段。

2.2 五张核心表的设计与建表 SQL

围绕上面的闭环,我把表收敛到五张:学生表、教师表、课程表、选课关系表、考勤记录表。请假记录我建议作为第六张表加入,因为请假会直接影响考勤状态的判定。下面这份 SQL 是可直接执行的版本,字段命名刻意保留了student_no这类业务编号,方便后期对接教务系统。

CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', class_name VARCHAR(50) DEFAULT '' COMMENT '班级', phone VARCHAR(20) DEFAULT '' COMMENT '手机号', openid VARCHAR(64) DEFAULT '' COMMENT '微信openid', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表'; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL COMMENT '课程名称', teacher_id BIGINT NOT NULL COMMENT '授课教师ID', start_time TIME NOT NULL COMMENT '上课时间', end_time TIME NOT NULL COMMENT '下课时间', week_day TINYINT NOT NULL COMMENT '星期几,1-7', latitude DECIMAL(10,6) DEFAULT NULL COMMENT '上课地点纬度', longitude DECIMAL(10,6) DEFAULT NULL COMMENT '上课地点经度', semester VARCHAR(20) DEFAULT '2024-2025-1' COMMENT '学期' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; CREATE TABLE course_student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, UNIQUE KEY uk_course_student (course_id, student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课关系表'; CREATE TABLE attendance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, lecture_date DATE NOT NULL COMMENT '上课日期', check_in_time DATETIME DEFAULT NULL COMMENT '签到时间', check_out_time DATETIME DEFAULT NULL COMMENT '签退时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1迟到 2早退 3缺勤 4请假', remark VARCHAR(255) DEFAULT '', UNIQUE KEY uk_course_student_date (course_id, student_id, lecture_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤记录表';

这段 SQL 里有两个字段需要特别说明。course表中的latitude和longitude用来存上课地点的坐标,签到时会拿学生手机的定位坐标与它做距离计算,这是判断「人到没到教室」的依据。attendance表的唯一索引uk_course_student_date是防重复签到的关键——同一学生同一课程同一天只允许一条记录,这是数据库层最后的兜底。很多毕设只在代码里判断「是否已签到」,并发请求一来就穿帮,加这个唯一索引能省很多事。

2.3 用 MyBatis-Plus 让实体类自动生成建表语句

说到 MyBatis-Plus,很多人只知道它能根据BaseMapper接口帮你省掉 XML 里的 CRUD 语句,但热词里那条「mybatisplus根据java实体类生成创建表的sql语句」其实指向一个更实用的技巧:让实体类成为表结构的唯一事实来源,建表 SQL 由代码生成,避免手写 SQL 和实体类字段不一致。

常见做法是先用@TableName、@TableId、@TableField把实体类标注完整,然后借助 MyBatis-Plus 内部的TableInfoHelper读取实体类元信息,再拼装成CREATE TABLE语句。写一个启动时自动执行的工具类,效果很直接:

@Component public class TableAutoGenerator { public static void generate(DataSource dataSource, List<Class<?>> entityClasses) { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), entityClasses.get(0)); for (Class<?> clazz : entityClasses) { TableInfo tableInfo = TableInfoHelper.getTableInfo(clazz); String ddl = buildCreateTableSql(tableInfo); try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) { stmt.execute(ddl); } catch (SQLException e) { // 表已存在时会报错,捕获后忽略即可 } } } private static String buildCreateTableSql(TableInfo tableInfo) { StringBuilder sb = new StringBuilder("CREATE TABLE IF NOT EXISTS ") .append(tableInfo.getTableName()).append(" ("); List<TableFieldInfo> fieldList = tableInfo.getFieldList(); for (TableFieldInfo field : fieldList) { sb.append(field.getColumn()).append(" VARCHAR(255) DEFAULT ''"); if (field.isKeyFlag()) { sb.append(" PRIMARY KEY"); } sb.append(","); } sb.setLength(sb.length() - 1); return sb.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4").toString(); } }

这套做法有几个明显的边界要注意。TableInfoHelper.getTableInfo要求实体类已经被 MyBatis-Plus 的 Mapper 扫描过,否则返回的TableInfo里字段列表是空的。所以这个工具类最好在 Spring Boot 启动完成后调用,而不是放在Application类的静态块里。另外,上面为了保持示例精简,所有字段都映射成了VARCHAR(255),实际使用时需要按字段类型做更细的映射,比如LocalDateTime映射到DATETIME、BigDecimal映射到DECIMAL(10,6)。真要做得严谨,可以读取实体类字段类型反射判断,这一步能少写几十行手写 SQL,也避免了手滑把字段名写错。

3. 后端三个关键接口:登录、定位打卡和自动判缺勤

3.1 微信登录换取 token:openid 才是你的用户身份

上课考勤系统的用户不会去网页注册账号,而是直接打开小程序。所以后端第一个接口就是处理wx.login传来的临时code,调用微信的jscode2session接口换取openid。这个openid是用户在你这套小程序里的唯一身份标识,后端要拿它去student或teacher表里匹配用户,匹配不到就按游客处理返回401。

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private RestTemplate restTemplate; @Autowired private StudentMapper studentMapper; @PostMapping("/login") public Result<String> login(@RequestBody LoginRequest req) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; Map<String, Object> resp = restTemplate.getForObject(url, Map.class); String openid = (String) resp.get("openid"); if (openid == null || openid.isEmpty()) { return Result.error("微信登录失败:" + resp.get("errmsg")); } Student student = studentMapper.selectOne( new LambdaQueryWrapper<Student>().eq(Student::getOpenid, openid)); if (student == null) { return Result.error("该微信未绑定学生账号"); } String token = UUID.randomUUID().toString().replace("-", ""); // 用 Redis 或以内存 Map 保存 token -> userId TokenStore.save(token, student.getId(), "STUDENT"); return Result.success(token); } }

这里有一个很多人第一次做会踩的坑:jscode2session接口返回的openid不是每次都一样吗?答案是在同一个小程序下稳定不变,但如果你测试时在开发者工具里换了 AppID,openid就会变,之前绑定的学生就登不上了。所以我一般在登录成功后把openid回写到学生表的openid字段,方便后台管理员做「微信绑定」操作。

还要注意的是,示例里用RestTemplate调微信接口,实际项目里建议用WebClient或OkHttp,并加上超时配置。微信接口偶尔会抖动,超时设 3 秒就够了,不要用默认的无限等待。token 方案我用的是最简版的内存Map,毕设够用,但服务一重启用户就要重新登录。若想体面一点,把 token 改存 Redis 并设置 7 天过期,配套一个@Interceptor拦截器校验所有/api/student/**请求即可。

3.2 签到签退接口:时间窗口和地理围栏缺一不可

签到接口是整个系统里业务逻辑最密集的地方。一个合格的签到请求要把三件事一次做完:查课程是否在今天这个星期几有课、校验当前时间是否落在签到窗口内、校验学生手机坐标与课程地点坐标的距离是否在允许范围内。

@PostMapping("/check-in") public Result<String> checkIn(@RequestBody CheckInRequest req) { // 1. 查询课程,判断今天是否有课 Course course = courseMapper.selectById(req.getCourseId()); LocalTime now = LocalTime.now(); LocalTime start = course.getStartTime(); LocalTime end = course.getEndTime(); LocalDate today = LocalDate.now(); if (course.getWeekDay() != today.getDayOfWeek().getValue()) { return Result.error("今天没有这节课"); } // 2. 时间窗口:上课前30分钟到上课后15分钟内可签到 if (now.isBefore(start.minusMinutes(30)) || now.isAfter(start.plusMinutes(15))) { return Result.error("不在签到时间范围内"); } // 3. 地理围栏:与课程地点距离小于200米 double distance = haversine( req.getLatitude(), req.getLongitude(), course.getLatitude().doubleValue(), course.getLongitude().doubleValue()); if (distance > 200) { return Result.error("当前位置距教室过远,无法签到"); } // 4. 插入考勤记录,迟到判定 int status = now.isAfter(start.plusMinutes(5)) ? 1 : 0; Attendance record = new Attendance(); record.setCourseId(course.getId()); record.setStudentId(LoginUser.getUserId()); record.setLectureDate(today); record.setCheckInTime(LocalDateTime.now()); record.setStatus(status); attendanceMapper.insert(record); return Result.success(status == 0 ? "签到成功" : "签到成功,已标记迟到"); } private double haversine(double lat1, double lng1, double lat2, double lng2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double a = Math.sin((radLat1 - radLat2) / 2); double b = Math.sin(Math.toRadians(lng1 - lng2) / 2); double s = 2 * Math.asin(Math.sqrt(a * a + Math.cos(radLat1) * Math.cos(radLat2) * b * b)); return s * 6371000; }

代码里的时间窗口参数是我根据高校常见场景定的,不是标准值,你可以按需改:典型的配置是「上课前 30 分钟开始允许签到,上课后 15 分钟关闭签到,超出 5 分钟记为迟到」。至于地理围栏的 200 米,建议根据教学楼实际覆盖范围调整,室外大阶梯教室可以放宽到 300 米,室内小教室收紧到 100 米。

签退接口的逻辑与签到对称:在下课前 15 分钟开放,下课后 30 分钟关闭,提前离开标记早退。这里踩过的坑是只判断了「签退时间早于下课时间」就标记早退,没考虑学生可能是提前两分钟才签退。所以正确做法是设置一个宽限期,比如提前 5 分钟内不标记早退。

3.3 用 @Scheduled 定时任务自动补齐缺勤状态

签到接口只会写入「确实点了签到按钮」的学生,那些一节课都没出现的人,考勤表里根本没有记录。所以后端要有一个每日定时任务,在当天所有课程结束后统一扫描:该上课的学生没有考勤记录且没有请假记录,就补一条状态为「缺勤」的记录。

@Component public class AttendanceCronTask { @Autowired private CourseStudentMapper courseStudentMapper; @Autowired private AttendanceMapper attendanceMapper; @Scheduled(cron = "0 30 22 * * *") public void markAbsent() { // 查询所有今天有课的课程ID列表 LocalDate today = LocalDate.now(); List<Course> courses = courseMapper.selectList( new LambdaQueryWrapper<Course>() .eq(Course::getWeekDay, today.getDayOfWeek().getValue())); for (Course course : courses) { List<CourseStudent> relations = courseStudentMapper.selectList( new LambdaQueryWrapper<CourseStudent>() .eq(CourseStudent::getCourseId, course.getId())); for (CourseStudent relation : relations) { Long count = attendanceMapper.selectCount( new LambdaQueryWrapper<Attendance>() .eq(Attendance::getCourseId, course.getId()) .eq(Attendance::getStudentId, relation.getStudentId()) .eq(Attendance::getLectureDate, today)); if (count == 0) { Attendance absent = new Attendance(); absent.setCourseId(course.getId()); absent.setStudentId(relation.getStudentId()); absent.setLectureDate(today); absent.setStatus(3); attendanceMapper.insert(absent); } } } } }

这段任务的执行时间建议定在晚上十点半以后,理由是此时所有课程都已下课,且学生不太可能还在补签。如果学校有晚课到 21:30 的,cron 表达式里的「22 点 30 分」就是安全线。定时任务里的count == 0判断要和数据库唯一索引配合使用,因为定时任务是轮询扫描,前一次执行如果因为网络抖动中断,第二次执行时会插入重复的缺勤记录,唯一索引能拦住后插入的那条。

有一点值得提醒:定时任务一定要做好日志输出,至少在方法开始和结束时打印「开始补缺勤,课程数 xx」。你永远不会知道老师会不会在凌晨质问「为什么缺勤名单里多了个昨天请假的学生」,这时候日志是你排查的第一现场。

4. 小程序端对接:登录态、定位打卡与动态标题

4.1 手机号登录:getPhoneNumber 的权限边界与兜底方案

微信小程序登录获取手机号是这几年变化最大的一个环节。老项目里常见的wx.getUserProfile拿手机号的方式已经行不通了,现在的官方做法是用户在页面上点击一个<button open-type="getPhoneNumber">,用户授权后,前端拿到一个code,再把它传到后端,由后端调用微信的getuserphonenumber接口换取真实手机号。

// pages/login/login.js onLogin() { wx.login({ success: (res) => { const loginCode = res.code; // 这个code用于后端调jscode2session } }); } // 手机号按钮的bindgetphonenumber回调 onGetPhoneNumber(e) { if (e.detail.code) { wx.request({ url: 'https://your-api.com/api/auth/phone', data: { code: e.detail.code }, success: (res) => { wx.setStorageSync('token', res.data.data.token); wx.switchTab({ url: '/pages/index/index' }); } }); } }

这段代码里要特别说明的是:e.detail.code是新版接口返回的动态令牌,后端要拿它去微信服务端换手机号,而不是直接读e.detail.phoneNumber。新接口把手机号明文直接返回的能力收回了,必须走服务端。而且getPhoneNumber目前要求小程序的主体必须是企业、个体工商户等非个人类型,个人开发者的小程序在真机上是调不通这个按钮的。

所以毕设场景里我一般建议做两手准备:如果小程序账号是企业主体,就走上面的流程;如果是个人主体做演示,就在登录页加一个「手机号 + 验证码」的模拟登录通道,后端校验验证码为固定值(比如 123456)即可。这种做法在答辩演示时最稳,因为真机预览不依赖微信的接口审核状态。

4.2 签到页的定位逻辑:授权前置与坐标系转换的坑

签到页是小程序端最重要的页面,核心逻辑是按钮点击后先wx.getLocation拿到当前经纬度,再连同课程 ID 发给后端。这里的第一个坑是权限声明:小程序要在app.json里配置permission字段,否则wx.getLocation直接失败。

{ "permission": { "scope.userLocation": { "desc": "用于上课签到时确认你已到达教室" } }, "requiredPrivateInfos": ["getLocation"] }

requiredPrivateInfos这个字段是 2022 年以后新增的,很多从老项目改造来的源码包里没有它,结果真机一调用定位就报getLocation:fail the api need declare in requiredPrivateInfos。解决方法是到微信公众平台后台「开发-开发管理-接口设置」里申请开通定位接口,同时在app.json里把上面那段配置补上。

坐标系是第二个坑。wx.getLocation默认返回的是wgs84坐标,而后端课程表里存的latitude、longitude如果是从地图上拾取的(大多数人是直接在小程序开发者工具里选点,拿到的是腾讯地图的gcj02坐标),两种坐标系之间有两三百米的偏差。一个 200 米的地理围栏,直接拿wgs84和gcj02比较,大概率签不上。所以我建议前端统一使用wx.getLocation({ type: 'gcj02' })去取坐标,后端课程表也全部存gcj02,两边一致后距离计算才可信。

getLocation() { wx.getLocation({ type: 'gcj02', isHighAccuracy: true, success: (res) => { this.setData({ latitude: res.latitude, longitude: res.longitude }); wx.request({ url: 'https://your-api.com/api/attendance/check-in', method: 'POST', data: { courseId: this.data.courseId, latitude: res.latitude, longitude: res.longitude }, header: { 'token': wx.getStorageSync('token') } }); }, fail: (err) => { wx.showToast({ title: '定位失败,请检查手机定位开关', icon: 'none' }); } }); }

这里我踩过一个印象很深的坑:isHighAccuracy: true对安卓机的定位耗时有明显影响,实测部分机型要 3 到 5 秒才能回调。有学生误以为签到没生效,连续点了好几次。所以按钮在发起请求前要disabled,请求结束再恢复,同时把高精度定位只在签到时开启,不要全局设置。iOS 的话还要注意wx.getLocation的fail回调里包含「用户拒绝授权」的情况,此时应引导用户去设置页打开定位权限,而不是干巴巴地 toast 一句「定位失败」。

4.3 课程列表与动态导航栏标题

学生首页一般是「今日课表」列表,从后端接口拉取当天有课且该学生已选课的课程,每门课显示课程名、上课时间、地点和签到状态。这个列表的后端接口建议分页,前端用onReachBottom触底加载下一页,不要一次性把一学期几百节课全塞到一个接口里。列表项里我一般会显示一个状态标签:未开始、签到中、已签到、已迟到、已缺勤。前端拿到考勤状态后根据状态值染不同颜色,这块属于纯前端样式,注意后端返回的status字段不要用字符串「已签到」这种中文值,用数字枚举,前端自己映射,否则后端改文案还要发版。

小程序动态设置标题对应的是热词里那条「小程序动态设置标题」。做法是在页面onShow里根据你正在看的页面类型调用wx.setNavigationBarTitle:

onShow() { if (this.data.role === 'teacher') { wx.setNavigationBarTitle({ title: '教师考勤工作台' }); } else { wx.setNavigationBarTitle({ title: '我的课表' }); } }

这里有一个细节:wx.setNavigationBarTitle只能设置当前页面的标题,而且要在页面显示后调用,写在onLoad里偶尔会失效。另外,小程序顶部导航栏高度在不同机型上不一样,尤其 iPhone X 以后的机型有刘海,自定义导航栏组件要适配「安全区」。做毕设建议直接用微信原生导航栏,让navigationStyle保持default,省掉一整套自定义导航栏的适配工作。

5. 常见问题排查:启动失败、定位偏差与重复签到

5.1 后端启动即报错:JDK 版本和数据库时区是两大元凶

现象:Spring Boot项目导入 IDE 后,启动到一半报Unsupported class file major version,或者The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。

原因:前者是项目用 JDK 17 编译,而你本地是 JDK 8;后者是 MySQL 连接串里没有指定serverTimezone。这两个问题在毕设源码包里出现频率极高,因为你不知道原作者用什么版本写的。

解决:先看pom.xml里<java.version>是多少,然后到 IDE 的 Project Structure 里把 SDK 切到对应版本。数据库连接串统一改成jdbc:mysql://localhost:3306/attendance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false。useSSL=false也很关键,否则本地 MySQL 没配 SSL 证书时,控制台会刷一堆 SSL 警告。

5.2 小程序开发者工具请求不通:本地调试的三个开关

现象:wx.request发出后,fail回调返回request:fail,或者后端能收到请求但前端报跨域。

原因:小程序不是浏览器,它是一个独立运行时。默认情况下它不允许请求http://localhost:8080这种本地地址,也不允许请求没有备案的 IP。

解决:开发者工具里勾选「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」,再用「真机调试」时要换局域网 IP 并关闭防火墙。后端接口要允许跨域的话,Spring Boot 项目加一个@Configuration类实现WebMvcConfigurer.addCorsMappings,允许http://localhost:*即可。

5.3 签到显示「不在范围内」:先查坐标系再查距离单位

现象:学生明明站在教室里,签到却提示距教室过远。教师端后台看学生定位坐标,和课程地点坐标几乎重合,但还是失败。

原因:这是典型的坐标系混用。课程表坐标是gcj02(地图拾取),前端用的是wgs84,距离计算出来有 200 到 600 米的偏差。另一种可能是我示例代码里haversine的返回单位是米,但有人把它写成了千米,距离阈值没跟上。

解决:前后端统一使用gcj02。排查时别急着改距离阈值,先在数据库里查课程地点坐标,再用小程序端打印出的签到坐标去腾讯地图坐标拾取器里比对,如果偏差在百米级,基本就是坐标系问题。距离单位的话,haversine乘以的地球半径是 6371000 米,别用 6371,那是千米。

5.4 同一节课能打两次卡:数据库唯一索引救场

现象:学生快速连点签到按钮,后端出现了两条同课程同日期的考勤记录,考勤统计里出勤次数翻倍。

原因:前端虽然做了按钮disabled,但两个并发请求已经到了后端,selectCount判断和insert之间没有加锁,两个请求都查到「没有记录」,于是都插入成功。

解决:除了加synchronized或 Redis 分布式锁,最稳妥的做法是attendance表建唯一索引uk_course_student_date。这样第二个insert会抛出DuplicateKeyException,在异常里捕获并返回「您已签到过」。代码层的判断和数据库层的唯一索引互为兜底,缺一不可。

5.5 导出考勤表 Excel 打开乱码:CSV 的 BOM 头问题

现象:后端生成 CSV 文件,学生用 Excel 打开中文全变乱码,但用记事本打开正常。

原因:Excel 默认用 ANSI 编码读取无 BOM 的 CSV,而 Java 写入时用的是 UTF-8。解决方式是在写出内容的最前面追加\uFEFF(UTF-8 BOM),Excel 就能正确识别编码。

6. 把考勤报表做成答辩加分项:异常标记与批量导出

考勤系统做到这里,功能已经完整,但如果想让答辩老师眼前一亮,我建议把重心放在「期末考勤报表」上。不要用 POI 手写那种又长又容易出错的HSSFWorkbook代码,直接用阿里开源的 EasyExcel,三五行代码就能导出一个带样式的 Excel。核心思路是:导出前把每个学生的整学期考勤记录查出来,逐条归纳为「出勤次数、迟到次数、早退次数、缺勤次数、请假次数」,再用一个自定义样式策略把「缺勤」标红、「迟到」标黄。老师打开文件时一眼就能看出谁经常翘课,这是管理系统和「能跑的 Demo」的分水岭。

@RequestMapping("/export") public void export(@RequestParam Long courseId, HttpServletResponse response) throws IOException { List<AttendanceRecordVO> records = attendanceService.listStudentSummary(courseId); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("考勤统计_" + courseId, "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), AttendanceRecordVO.class) .registerWriteHandler(new CellStyleStrategy()) .sheet("考勤统计") .doWrite(records); }

看起来很简单,但这里藏着两个答辩时容易被追问的点。第一个是URLEncoder.encode处理文件名,你不做的话中文文件名在 Chrome 里能下,但在 Safari 里会乱码,连老师用的电脑是 Mac 还是 Windows 都要考虑进去。第二个是CellStyleStrategy这种策略类最好提前把「缺勤红色、迟到黄色」的样式逻辑写在setHeadRowHeight附近,不要每个单元格都 new 一个CellStyle,否则 500 个学生的数据就能把内存撑爆。

我从一开始做考勤系统就吃过导出的亏:第一次交付给客户时,几百个学生的考勤数据导出的 Excel 一片飘红,最后发现是状态判断写反了——把status = 3(缺勤)误写成了status != 3,导致所有正常出勤的学生都被标成缺勤。那次之后我给自己立了个规矩:凡是导出类功能,必定先造一条「已知各状态各一条」的测试数据,导出来人工核验一遍再交给对方。这个习惯帮我躲过了不少次尴尬的交付现场。做毕设的读者,答辩前一晚也别忘了做同样的事——造 5 条覆盖正常、迟到、早退、缺勤、请假的数据,导一次报表看一眼,确认颜色和数字都对,再合上电脑去睡觉。希望帮到你。

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

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

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

立即咨询