Java校运会管理系统:Spring Boot轻量级赛事管理实战
简介:本资源是一个面向高校数据库与Java课程设计的田径运动会信息管理系统,专为计算机专业学生、课程设计实践者及Java初学者打造,解决运动会场景下运动员管理、赛事编排、成绩录入与排名展示等核心业务的信息化需求。压缩包共81个文件,含25个可读性良好的Java源码文件(覆盖GUI界面、业务逻辑与数据库交互)、48个编译后class文件、1个SQL建表与初始化脚本(taf.sql)、1份PDF格式的完整课程设计报告(含系统架构图、模块流程与实现细节)、2个运行依赖JAR包(含MySQL连接驱动)及配套readme说明,整体仅2.04MB,轻量易部署。已有2171人学习下载,资源结构规范,根目录清晰划分源码、数据库、文档与可执行模块,附带详细实验报告与注释充分的代码,便于理解MVC分层思想、Swing/JavaFX界面开发及JDBC数据库操作全流程,是掌握Java桌面应用开发与信息系统设计的典型教学案例。
1. 一个跑完就删的运动会系统:Java写的田径赛事管理后台,真能扛住校级500人+实时录入、3天赛程排表、成绩自动排名、PDF证书生成全链路
去年帮某高校体育部搭过一版田径运动会系统,现场用Java Swing搭了个本地客户端——结果开赛前两小时,裁判长掏出一台Windows老笔记本,双击jar包直接报错“找不到主类”。后来我们连夜重写成B/S架构,用Spring Boot + Thymeleaf + H2嵌入式数据库,打包成单jar部署在校园网虚拟机上。三天赛程下来,42个竞赛项目、286名运动员、17支院系代表队的成绩录入零中断,最终导出139份带校徽水印的PDF获奖证书。这不是教学Demo,是实打实压在真实赛程节奏上的轻量级赛事管理系统:它不追求大屏可视化,但要求成绩提交后3秒内刷新排名;不搞微服务拆分,但得让三位非计算机专业的体育老师同时录入跳高、铅球、接力数据不锁表;不连LDAP统一认证,但支持Excel批量导入运动员信息并自动去重校验。如果你正被“校运会系统要上线但没预算买商用软件”卡住,或者想拿一个有真实业务闭环的Java项目练手——这个源码包就是你该下载、编译、改配置、跑起来的第一站。
2. 技术栈与工程结构:Spring Boot 2.7.x + MyBatis-Plus + H2/MySQL双模支持,目录结构直指运维友好性
这个项目不是从零手搓的玩具工程,而是按生产级Java Web项目规范组织的。它用Maven多模块结构,但实际只启用了core(核心业务)、web(控制器层)、data(数据访问)三个子模块,没有引入Spring Cloud或Dubbo这类重型组件——因为校运会系统根本不需要跨服务调用,所有逻辑都在单JVM内完成。关键点在于:它把数据库适配做成了编译时可插拔设计,H2用于开发调试,MySQL用于正式部署,切换只需改一行配置,不用动任何DAO代码。
2.1 模块划分与职责边界:为什么data模块里没有XML映射文件?
项目彻底弃用了MyBatis原生XML配置,全部采用MyBatis-Plus的注解式CRUD。data模块下只有两个核心包:
entity:实体类全部继承BaseEntity,自带createTime、updateTime、isDeleted字段,且通过@TableLogic实现软删除;mapper:接口继承BaseMapper<T>,每个Mapper接口只声明一个自定义方法(如List<Athlete> selectByEventId(Long eventId)),其余增删改查全部由MP自动注入。
提示:这种设计省去了90%的XML维护成本,但代价是复杂联表查询必须写
@Select注解SQL。比如成绩统计页需要查“项目名称+运动员姓名+成绩+名次”,就得在ResultMapper里手写带ROW_NUMBER() OVER的SQL,不能依赖MP的LambdaQueryWrapper。
2.2 数据库双模支持:H2内存库调试 vs MySQL生产库部署
项目默认使用H2内存数据库,启动时自动建表并加载src/main/resources/data-h2.sql中的测试数据(含3支院系、12名运动员、5个比赛项目)。切换到MySQL只需三步:
- 修改
application.yml中spring.profiles.active: mysql - 在
application-mysql.yml里填入你的MySQL地址、账号密码 - 执行
mvn clean package -Pmysql重新打包
# application-mysql.yml 片段 spring: datasource: url: jdbc:mysql://localhost:3306/athletics?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: athletics_user password: athletics_pass_2024 jpa: hibernate: ddl-auto: validate # 关键!设为validate而非update,避免误删生产表注意:
ddl-auto: validate是血泪经验。早先用update模式,一次误操作导致MySQL里athlete表被清空——因为MP根据实体类字段发现少了emergency_contact字段,就默默执行了ALTER TABLE DROP COLUMN。现在强制校验结构一致性,启动失败也比数据丢失强。
2.3 静态资源与模板路径:Thymeleaf如何加载带版本号的CSS/JS?
前端资源放在web/src/main/resources/static/下,但所有HTML模板(templates/目录)里的静态引用都加了时间戳参数:
<!-- templates/layout.html --> <link rel="stylesheet" th:href="@{/css/main.css?v=__${#dates.format(new java.util.Date(), 'yyyyMMddHHmm')}__}"> <script th:src="@{/js/result.js?v=__${#dates.format(new java.util.Date(), 'yyyyMMddHHmm')}__}"></script>这样每次编译都会生成新URL,彻底规避浏览器缓存旧JS导致成绩提交按钮失效的问题。而main.css本身是用PostCSS处理过的,支持@apply语法和变量注入,比如.rank-badge { @apply bg-blue-500 text-white px-2 py-1 rounded; }。
2.4 启动类与Profile控制:为什么AthleticsApplication.java里有@ImportResource?
启动类上标注了@ImportResource("classpath:spring-config.xml"),但这个XML文件实际为空。这是为未来可能接入学校统一身份认证(CAS)预留的钩子——如果后续要集成CAS,只需往XML里加<bean class="org.jasig.cas.client.validation.Cas30ServiceValidationFilter"/>,不用改Java代码。目前所有登录逻辑走的是LoginController里的简单Session验证,用户名密码硬编码在application.yml的auth.admin节点下。
3. 核心业务模块拆解:从运动员注册到PDF证书生成的6个关键链路
整个系统围绕“人-项目-成绩-排名-证书”五要素流转。下面六个模块是真正决定系统能不能用的核心,每个都附带可运行的代码片段和参数说明。
3.1 运动员批量导入:Excel解析如何避免中文乱码和空行崩溃?
AthleteService.importFromExcel(MultipartFile file)方法是入口。它用Apache POI读取.xlsx文件,但做了三层防护:
- 编码兜底:即使Excel保存为ANSI格式,也强制用
new XSSFWorkbook(file.getInputStream()),POI会自动识别编码; - 空行跳过:遍历每行时检查
row.getCell(0)是否为null或空白字符串,跳过整行; - 字段校验:对“姓名”“学号”“院系”“性别”四列做非空校验,任一为空则记录错误行号到
importResult.errorRows。
// AthleteService.java 片段 public ImportResult importFromExcel(MultipartFile file) { ImportResult result = new ImportResult(); try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) { Sheet sheet = workbook.getSheetAt(0); for (int i = 1; i <= sheet.getLastRowNum(); i++) { // 跳过标题行 Row row = sheet.getRow(i); if (row == null || StringUtils.isBlank(getCellValue(row.getCell(0)))) continue; Athlete athlete = new Athlete(); athlete.setName(getCellValue(row.getCell(0))); athlete.setStudentId(getCellValue(row.getCell(1))); athlete.setDepartment(getCellValue(row.getCell(2))); athlete.setGender("男".equals(getCellValue(row.getCell(3))) ? 1 : 0); // 校验学号唯一性(查库) if (athleteMapper.selectCount(new QueryWrapper<Athlete>().eq("student_id", athlete.getStudentId())) > 0) { result.addErrorRow(i, "学号重复:" + athlete.getStudentId()); continue; } athleteMapper.insert(athlete); } } catch (Exception e) { result.setSuccess(false).setErrorMessage("解析失败:" + e.getMessage()); } return result; }getCellValue()方法封装了POI的多种Cell类型转换,对日期型Cell自动转为yyyy-MM-dd字符串,对数字型Cell调用cell.getNumericCellValue()再转String——这是防止Excel里“2024-05-01”被读成45078.0的关键。
3.2 赛程编排引擎:基于贪心算法的项目-场地-时段三维分配
ScheduleService.generateSchedule(List<Event> events, List<Venue> venues)不依赖外部调度库,而是用纯Java实现的贪心策略:
- 先按项目时长降序排序(铅球>100米>接力);
- 对每个项目,遍历所有场地,找第一个满足“当前时段未被占用且容量≥该项目最大参赛人数”的空闲时段;
- 若所有场地都满,则顺延到下一可用时段。
核心逻辑在VenueSlot.findAvailableSlot(int durationMinutes)方法里:
// VenueSlot.java public Slot findAvailableSlot(int durationMinutes) { // 按开始时间升序遍历当天所有预设时段(6:00-18:00,每30分钟一段) for (LocalTime start = LocalTime.of(6, 0); start.isBefore(LocalTime.of(18, 0)); start = start.plusMinutes(30)) { LocalTime end = start.plusMinutes(durationMinutes); // 检查该时段是否与已分配时段重叠 boolean conflict = scheduledSlots.stream() .anyMatch(s -> !(end.isBefore(s.getStart()) || start.isAfter(s.getEnd()))); if (!conflict) return new Slot(start, end); } return null; // 无可用时段,返回null触发告警 }参数说明:
durationMinutes来自Event.durationMinutes字段,单位为分钟;scheduledSlots是当前场地已分配的时段列表,由VenueService.assignSlotToVenue()维护。这个设计让编排结果可预测——铅球永远优先占满田赛场地,径赛项目自动挤到下午。
3.3 成绩实时录入与排名计算:为什么用Redis缓存Top10而不是查库?
成绩录入走ResultController.submitResult(),但排名计算不在提交时同步执行,而是异步触发:
- 提交成绩后,向Redis发布消息
PUBLISH result:updated {"eventId":123,"athleteId":456}; ResultRankingListener监听该频道,收到后执行recomputeRanking(eventId);- 计算逻辑:查出该项目所有有效成绩(
status=1),按成绩升序(短跑)或降序(跳远)排序,用ROW_NUMBER()生成名次,更新result.rank字段。
// ResultService.java @CacheEvict(value = "ranking", key = "#eventId") public void recomputeRanking(Long eventId) { // 1. 获取所有有效成绩(排除弃权、犯规) List<Result> results = resultMapper.selectList( new QueryWrapper<Result>().eq("event_id", eventId).eq("status", 1) ); // 2. 排序(此处以跳远为例:成绩降序) results.sort((a, b) -> Double.compare(b.getMark(), a.getMark())); // 3. 批量更新名次 List<UpdateWrapper<Result>> updates = new ArrayList<>(); for (int i = 0; i < results.size(); i++) { UpdateWrapper<Result> wrapper = new UpdateWrapper<>(); wrapper.eq("id", results.get(i).getId()).set("rank", i + 1); updates.add(wrapper); } resultMapper.update(null, new UpdateWrapper<Result>().in("id", results.stream().map(Result::getId).collect(Collectors.toList()) ).set("rank", 0)); // 先清空所有名次,再逐条设值,避免并发问题 resultMapper.updateBatchById(results); // MP批量更新 }为什么用Redis缓存?因为首页“实时排名榜”只显示Top10,直接查库压力大。
@Cacheable(value = "ranking", key = "#eventId")注解让getTop10Results(Long eventId)方法自动缓存结果,TTL设为60秒,既保证新鲜度又减轻DB压力。
3.4 PDF证书生成:iText 7如何动态插入校徽和运动员照片?
证书生成用iText 7.2.x,不走模板渲染,而是代码绘图:
- 背景:用
PdfCanvas绘制浅灰色网格线作为底纹; - 校徽:
Image.getInstance("static/images/seal.png")加载,缩放至120×120px居中; - 照片:从
athlete.photoUrl字段读取相对路径,若为空则用默认头像; - 文字:标题用
FontConstants.TIMES_BOLD,字号28;姓名用FontConstants.HELVETICA_BOLD,字号36,加粗描边防打印模糊。
// CertificateGenerator.java public byte[] generateCertificate(Athlete athlete, Event event, Result result) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); PdfWriter writer = new PdfWriter(baos); PdfDocument pdfDoc = new PdfDocument(writer); Document document = new Document(pdfDoc, PageSize.A4.rotate()); // 横版A4 // 绘制校徽 ImageData seal = ImageDataFactory.create("static/images/seal.png"); Image sealImg = new Image(seal).scaleAbsolute(120, 120); sealImg.setFixedPosition(297, 420); // x,y坐标(毫米制) // 插入运动员照片 String photoPath = athlete.getPhotoUrl() != null ? "static/images/athletes/" + athlete.getPhotoUrl() : "static/images/default-avatar.png"; ImageData photoData = ImageDataFactory.create(photoPath); Image photo = new Image(photoData).scaleToFit(100, 120); photo.setFixedPosition(450, 380); // 添加文字 Text title = new Text("第XX届田径运动会").setFontColor(ColorConstants.BLUE).setFontSize(28); document.add(new Paragraph(title).setFixedPosition(200, 500)); Text name = new Text(athlete.getName()).setFontColor(ColorConstants.BLACK).setFontSize(36).setBold(); document.add(new Paragraph(name).setFixedPosition(300, 420)); document.close(); return baos.toByteArray(); }关键细节:
PageSize.A4.rotate()必须在new Document()时指定,否则setFixedPosition坐标系会错乱;照片路径用相对路径而非URL,避免生成环境网络不可达;所有字体用iText内置常量,不依赖系统字体,确保Linux服务器上也能正确渲染。
3.5 成绩导出Excel:POI SXSSF如何避免OOM导出2000+行数据?
ResultExportService.exportAllResults()面对全校成绩导出,用SXSSF(Streaming Usermodel)替代HSSF/XSSF:
SXSSFWorkbook wb = new SXSSFWorkbook(100):内存中只保留100行,超出部分刷入临时文件;- 每写100行调用
sheet.trackAllColumnsForAutoSizing(),避免最后autoSizeColumn()卡死; - 列宽用
sheet.setColumnWidth(i, 256 * chars)精确控制,256是Excel最小单位。
// ResultExportService.java public ByteArrayInputStream exportAllResults() { SXSSFWorkbook workbook = new SXSSFWorkbook(100); Sheet sheet = workbook.createSheet("全部成绩"); // 写标题行 Row headerRow = sheet.createRow(0); String[] headers = {"项目", "运动员", "院系", "成绩", "名次", "状态"}; for (int i = 0; i < headers.length; i++) { Cell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); sheet.setColumnWidth(i, 256 * 12); // 12字符宽度 } // 查询所有成绩(分页避免OOM) int pageNum = 1, pageSize = 500; while (true) { Page<Result> page = new Page<>(pageNum++, pageSize); IPage<Result> resultPage = resultMapper.selectPage(page, new QueryWrapper<Result>().orderByDesc("create_time")); if (resultPage.getRecords().isEmpty()) break; for (int i = 0; i < resultPage.getRecords().size(); i++) { Result r = resultPage.getRecords().get(i); Row row = sheet.createRow(sheet.getLastRowNum() + 1); row.createCell(0).setCellValue(r.getEventName()); row.createCell(1).setCellValue(r.getAthleteName()); row.createCell(2).setCellValue(r.getDepartment()); row.createCell(3).setCellValue(formatMark(r.getMark(), r.getEventType())); row.createCell(4).setCellValue(r.getRank() == null ? "" : r.getRank().toString()); row.createCell(5).setCellValue(r.getStatus() == 1 ? "有效" : "无效"); } } ByteArrayOutputStream out = new ByteArrayOutputStream(); workbook.write(out); workbook.dispose(); // 必须调用,释放临时文件 return new ByteArrayInputStream(out.toByteArray()); }血泪经验:
workbook.dispose()不调用会导致/tmp/poi-xxxxx临时文件堆积,最终磁盘爆满。我们在线上环境加了定时清理脚本:find /tmp -name "poi-*" -mmin +60 -delete。
3.6 权限与审计日志:如何用Spring AOP记录谁在什么时间改了哪条成绩?
系统没上Shiro或Spring Security,而是用自定义@LogOperation注解+Aspect切面记录关键操作:
- 注解定义:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogOperation { String value() default ""; // 操作描述,如"修改成绩" }- 切面逻辑:
@Aspect @Component public class OperationLogAspect { @Around("@annotation(logOperation)") public Object logOperation(ProceedingJoinPoint joinPoint, LogOperation logOperation) throws Throwable { long startTime = System.currentTimeMillis(); Object result = joinPoint.proceed(); long costTime = System.currentTimeMillis() - startTime; // 解析参数:假设第一个参数是Result对象 Object[] args = joinPoint.getArgs(); if (args.length > 0 && args[0] instanceof Result) { Result r = (Result) args[0]; OperationLog log = new OperationLog(); log.setOperator(getCurrentUsername()); // 从Session取 log.setOperation(logOperation.value()); log.setTargetId(r.getId()); log.setTargetType("result"); log.setCostTime(costTime); log.setCreateTime(LocalDateTime.now()); operationLogMapper.insert(log); } return result; } }使用方式:在
ResultController.updateResult()方法上加@LogOperation("修改成绩"),调用后自动入库。日志表operation_log包含operator、target_id、cost_time字段,运维查问题时直接SELECT * FROM operation_log WHERE target_id = 12345 ORDER BY create_time DESC就能看到完整操作链。
4. 常见问题排查:5条线上翻车记录与对应解法,每条都来自真实赛程现场
4.1 现象:成绩提交后排名不刷新,页面仍显示“暂无排名”
原因:Redis服务未启动,或spring.redis.host配置错误导致ResultRankingListener订阅失败。
解决:
- 检查
application.yml中spring.redis配置是否指向正确地址; - 手动执行
redis-cli -h your-redis-host PING确认连通性; - 若用Docker部署,检查
docker ps | grep redis是否运行,端口映射是否为6379:6379; - 临时关闭Redis缓存,在
ResultService.getTop10Results()方法上加@Cacheable(cacheNames = "ranking", unless = "#result == null")改为@Cacheable(cacheNames = "ranking", unless = "true")强制走DB。
4.2 现象:Excel批量导入时,中文姓名显示为“???”
原因:POI读取Excel时未指定字符集,且Excel文件本身保存为ANSI编码(Windows默认)。
解决:
- 在
AthleteService.importFromExcel()中,将new XSSFWorkbook(file.getInputStream())替换为:
InputStream is = file.getInputStream(); // 强制用UTF-8读取,兼容ANSI ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[1024]; int n; while ((n = is.read(data, 0, data.length)) != -1) { buffer.write(data, 0, n); } Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(buffer.toByteArray()));- 更治本的方法:要求体育部老师保存Excel时选择“另存为→工具→Web选项→编码→UTF-8”。
4.3 现象:PDF证书生成后,校徽图片位置偏移,或完全不显示
原因:setFixedPosition(x,y)的坐标单位是用户单元(1/72英寸),不是像素;且iText 7的坐标原点在左下角,不是左上角。
解决:
- 将
setFixedPosition(297, 420)改为setFixedPosition(297, pdfDoc.getDefaultPageSize().getHeight() - 420); - 校徽图片尺寸必须明确设置:
sealImg.scaleAbsolute(120, 120),不能只用scaleToFit(); - 检查图片路径是否为绝对路径,
static/images/seal.png在jar包内需用getClass().getResourceAsStream("/static/images/seal.png")加载。
4.4 现象:MySQL部署后,启动报错Table 'athletics.athlete' doesn't exist
原因:application-mysql.yml中spring.jpa.hibernate.ddl-auto值为update,但H2建表SQL里有AUTO_INCREMENT,MySQL不识别。
解决:
- 将
ddl-auto改为validate; - 手动执行建表SQL:从
src/main/resources/data-h2.sql复制建表语句,将AUTO_INCREMENT改为BIGINT AUTO_INCREMENT PRIMARY KEY,VARCHAR(255)改为VARCHAR(255) NOT NULL; - 在MySQL中执行
CREATE DATABASE athletics CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;,再导入。
4.5 现象:多人同时录入成绩时,出现两条相同名次(如两个“第1名”)
原因:recomputeRanking()方法未加分布式锁,多个请求并发执行导致排名覆盖。
解决:
- 引入Redis分布式锁:在
recomputeRanking()开头加
String lockKey = "ranking:lock:" + eventId; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (!locked) throw new RuntimeException("排名计算中,请稍后重试"); try { // 原有逻辑 } finally { redisTemplate.delete(lockKey); }- 前端提交按钮加防抖:
document.getElementById('submitBtn').disabled = true; setTimeout(() => { btn.disabled = false; }, 2000);。
5. 二次开发实战:3个高频改造需求与落地代码,从改界面到加功能
5.1 需求:把成绩录入页从表单改成扫码录入(对接微信小程序)
体育老师抱怨“每人输学号太慢”,需要扫运动员二维码直接跳转成绩录入页。改造分三步:
第一步:生成运动员专属二维码
用com.google.zxing生成带参数的URL二维码:
// AthleteController.java @GetMapping("/qrcode/{athleteId}") public void generateQrCode(@PathVariable Long athleteId, HttpServletResponse response) throws Exception { String url = "https://your-domain.com/result/input?athleteId=" + athleteId; BitMatrix matrix = new MultiFormatWriter().encode(url, BarcodeFormat.QR_CODE, 300, 300); response.setContentType("image/png"); MatrixToImageWriter.writeToStream(matrix, "PNG", response.getOutputStream()); }第二步:成绩录入页接收athleteId参数
修改result/input.html,用Thymeleaf获取参数:
<!-- 页面顶部隐藏域 --> <input type="hidden" id="athleteId" th:value="${param.athleteId[0] ?: ''}" /> <!-- JavaScript自动填充运动员信息 --> <script th:inline="javascript"> const athleteId = [[${param.athleteId[0]}]]; if (athleteId) { fetch('/api/athlete/' + athleteId) .then(r => r.json()) .then(data => { document.getElementById('athleteName').value = data.name; document.getElementById('department').value = data.department; }); } </script>第三步:后端API支持按athleteId查信息
在AthleteController加方法:
@GetMapping("/api/athlete/{id}") public Result<Athlete> getAthleteById(@PathVariable Long id) { Athlete athlete = athleteMapper.selectById(id); if (athlete == null) return Result.fail("运动员不存在"); return Result.success(athlete); }改造后效果:老师手机扫二维码,自动打开录入页并填好姓名院系,只需选项目、输成绩、点提交——单条录入时间从45秒降到8秒。
5.2 需求:增加“替补运动员”功能,允许同一项目最多报3人,取最好成绩
原系统只支持一人一项目,现在要支持团体项目(如4×100米接力)报3人,取其中1人成绩计入排名。改造重点在Result实体和录入逻辑:
第一步:扩展Result实体
新增字段isMain(是否主力)、substituteId(替补运动员ID):
@TableField("is_main") private Boolean isMain = true; @TableField("substitute_id") private Long substituteId;第二步:修改成绩录入接口ResultController.submitResult()增加判断:
@PostMapping("/submit") public Result<String> submitResult(@RequestBody ResultSubmitDTO dto) { // 若是替补,查主力成绩,取更好者 if (!dto.getIsMain()) { Result mainResult = resultMapper.selectOne( new QueryWrapper<Result>() .eq("event_id", dto.getEventId()) .eq("athlete_id", dto.getMainAthleteId()) .eq("is_main", true) ); if (mainResult != null && isBetter(dto.getMark(), mainResult.getMark(), dto.getEventType())) { // 替补成绩更好,更新主力成绩 mainResult.setMark(dto.getMark()); mainResult.setUpdateTime(LocalDateTime.now()); resultMapper.updateById(mainResult); return Result.success("主力成绩已更新"); } return Result.fail("替补成绩未超越主力"); } // 原有主力录入逻辑... }isBetter()方法根据项目类型判断优劣(短跑比小、跳远比大):
private boolean isBetter(Double newMark, Double oldMark, Integer eventType) { if (eventType == EventType.SPRINT.getCode()) { // 短跑 return newMark < oldMark; } else { // 田赛 return newMark > oldMark; } }第三步:前端成绩页加“设为主力”按钮
在result/list.html中,对同一项目的多条成绩,加操作列:
<td th:if="${result.isMain == false}"> <button type="button" class="btn btn-sm btn-primary" th:onclick="'setMain(' + ${result.id} + ')'" th:text="设为主力"></button> </td>JavaScript调用API:
function setMain(resultId) { fetch('/api/result/set-main/' + resultId, {method: 'POST'}) .then(r => r.json()) .then(data => alert(data.message)); }这个改造只动了3个文件(实体类、Controller、HTML),却支撑起接力赛、团体总分等新场景。从那以后我每次接新需求,都先问一句:“这个改动影响几个类?能不能控制在5个文件以内?”——因为校运会系统上线窗口只有赛前48小时,冗余代码就是倒计时里的炸弹。
5.3 需求:导出成绩报告时,增加“各院系得分榜”汇总页
原Excel导出只有明细,现在要加一个汇总Tab页,统计各院系总分(第一名10分、第二名8分、第三名6分...)。改造在ResultExportService.exportAllResults()里追加:
// 导出完明细页后,创建汇总页 Sheet summarySheet = workbook.createSheet("院系得分榜"); Row summaryHeader = summarySheet.createRow(0); summaryHeader.createCell(0).setCellValue("院系"); summaryHeader.createCell(1).setCellValue("总分"); summaryHeader.createCell(2).setCellValue("金牌数"); summaryHeader.createCell(3).setCellValue("银牌数"); summaryHeader.createCell(4).setCellValue("铜牌数"); // 查询各院系得分(用MyBatis-Plus的QueryWrapper聚合) List<DepartmentScore> scores = resultMapper.selectDepartmentScores(); for (int i = 0; i < scores.size(); i++) { DepartmentScore s = scores.get(i); Row row = summarySheet.createRow(i + 1); row.createCell(0).setCellValue(s.getDepartment()); row.createCell(1).setCellValue(s.getTotalScore()); row.createCell(2).setCellValue(s.getGoldCount()); row.createCell(3).setCellValue(s.getSilverCount()); row.createCell(4).setCellValue(s.getBronzeCount()); } // 自动列宽 for (int i = 0; i < 5; i++) { summarySheet.autoSizeColumn(i); }对应的Mapper XML(ResultMapper.xml)里加SQL:
<select id="selectDepartmentScores" resultType="com.example.athletics.data.entity.DepartmentScore"> SELECT department AS department, SUM(CASE WHEN rank = 1 THEN 10 WHEN rank = 2 THEN 8 WHEN rank = 3 THEN 6 ELSE 0 END) AS total_score, COUNT(CASE WHEN rank = 1 THEN 1 END) AS gold_count, COUNT(CASE WHEN rank = 2 THEN 1 END) AS silver_count, COUNT(CASE WHEN rank = 3 THEN 1 END) AS bronze_count FROM result r JOIN athlete a ON r.athlete_id = a.id WHERE r.status = 1 AND r.rank IS NOT NULL GROUP BY department ORDER BY total_score DESC </select>这个汇总页让体育部老师第一次在赛后1小时内拿到各院系积分排名,不再需要手动Excel求和。希望帮到你。
本文还有配套的精品资源,点击获取