3
0

12 Commits 2b2a2e8cc1 ... bd1d9e1973

Autor SHA1 Mensagem Data
  zhou bd1d9e1973 feat(game): 实现参赛证生成任务管理功能- 新增参赛证生成任务的完整CRUD操作 há 2 dias atrás
  zhou accba3e287 feat(game): 更新运动员和团队编号唯一性校验逻辑 há 6 dias atrás
  zhou 788e1f0590 feat(game-event): 完善赛事项目管理功能 há 2 semanas atrás
  zhou 0ae318b803 feat(game-event): 实现参赛队员数据导出功能 há 2 semanas atrás
  zhou e95cfed032 feat(game-score): 添加成绩详情导出功能 há 2 semanas atrás
  zhou 347ec55934 feat(enroll): 实现报名表导入校验功能- 新增导入报名表并验证数据接口,支持Excel文件校验- 添加EnrollImportResult、EnrollValidationResult等校验结果实体类 há 2 semanas atrás
  zhou a709a957d9 feat(game): 添加参赛项目选择验证功能 há 2 semanas atrás
  zhou e8065a0753 feat(game-event): 增强Logo上传验证和PDF生成容错性 há 2 semanas atrás
  zhou 3e14ebeff5 feat(game-event): 更新号码布生成功能 há 2 semanas atrás
  zhou c8e0039141 refactor(game):重构赛事积分计算逻辑以提升准确性- 调整成绩查询范围为当前赛事 há 2 semanas atrás
  zhou daef5b2a4d feat(game): 添加运动员和队伍编号唯一性校验- 在保存运动员信息前校验编号唯一性 há 2 semanas atrás
  zhou 0395b6a156 feat(game-event): 实现运动员、裁判和队伍的批量关联清理功能 há 2 semanas atrás
59 ficheiros alterados com 4619 adições e 590 exclusões
  1. 5 5
      pom.xml
  2. 1 1
      ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java
  3. 3 5
      ruoyi-admin/src/main/resources/application-dev.yml
  4. 1 0
      ruoyi-admin/src/main/resources/application.yml
  5. 2 2
      ruoyi-modules/ruoyi-game-event/pom.xml
  6. 65 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/config/FileUploadConfig.java
  7. 30 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/EnrollController.java
  8. 10 5
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameAthleteController.java
  9. 1 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameEventProjectController.java
  10. 4 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameRefereeController.java
  11. 10 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameScoreController.java
  12. 384 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/NumberController.java
  13. 299 299
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/TestPoi.java
  14. 13 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/EnrollImportResult.java
  15. 14 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/EnrollValidationError.java
  16. 14 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/EnrollValidationResult.java
  17. 100 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameBibTask.java
  18. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameEvent.java
  19. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameEventProject.java
  20. 3 3
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameAthleteBo.java
  21. 122 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameBibTaskBo.java
  22. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameEventBo.java
  23. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameEventProjectBo.java
  24. 36 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GenerateBibBo.java
  25. 15 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/ProjectSelectionValidationBo.java
  26. 42 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/query/GameBibTaskQuery.java
  27. 7 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/EnrollProjectVo.java
  28. 30 14
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameAthleteVo.java
  29. 108 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameBibTaskVO.java
  30. 35 21
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameEventProjectVo.java
  31. 10 3
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameEventVo.java
  32. 3 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameTeamVo.java
  33. 19 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameAthleteMapper.java
  34. 15 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameBibTaskMapper.java
  35. 2 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameEventMapper.java
  36. 2 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameEventProjectMapper.java
  37. 20 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameRefereeMapper.java
  38. 18 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameTeamMapper.java
  39. 21 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IEnrollService.java
  40. 18 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameAthleteService.java
  41. 152 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameBibTaskService.java
  42. 23 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameEventService.java
  43. 7 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameRefereeService.java
  44. 7 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameScoreService.java
  45. 7 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameTeamService.java
  46. 214 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameAthleteServiceImpl.java
  47. 420 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameBibTaskServiceImpl.java
  48. 1 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventConfigServiceImpl.java
  49. 2 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventConfigTypeServiceImpl.java
  50. 210 12
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventProjectServiceImpl.java
  51. 775 32
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventServiceImpl.java
  52. 43 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameRefereeServiceImpl.java
  53. 374 32
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameScoreServiceImpl.java
  54. 34 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameTeamServiceImpl.java
  55. 652 133
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/IEnrollServiceImpl.java
  56. 52 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/EventNameUtils.java
  57. 123 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/GenderDictUtils.java
  58. 21 11
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/QRCodeUtils.java
  59. BIN
      ruoyi-modules/ruoyi-game-event/src/main/resources/template/enroll_template.xlsx

+ 5 - 5
pom.xml

@@ -91,7 +91,10 @@
                 <monitor.username>ruoyi</monitor.username>
                 <monitor.password>123456</monitor.password>
             </properties>
-
+            <activation>
+                <!-- 默认环境 -->
+                <activeByDefault>true</activeByDefault>
+            </activation>
 
         </profile>
         <profile>
@@ -102,10 +105,7 @@
                 <monitor.username>ruoyi</monitor.username>
                 <monitor.password>123456</monitor.password>
             </properties>
-            <activation>
-                <!-- 默认环境 -->
-                <activeByDefault>true</activeByDefault>
-            </activation>
+
 
         </profile>
     </profiles>

+ 1 - 1
ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java

@@ -104,7 +104,7 @@ public class AuthController {
         Long userId = LoginHelper.getUserId();
         scheduledExecutorService.schedule(() -> {
             SseMessageDto dto = new SseMessageDto();
-            dto.setMessage("欢迎登录RuoYi-Vue-Plus后台管理系统");
+            dto.setMessage("欢迎登录赛事后台管理系统");
             dto.setUserIds(List.of(userId));
             SseMessageUtils.publishMessage(dto);
         }, 5, TimeUnit.SECONDS);

+ 3 - 5
ruoyi-admin/src/main/resources/application-dev.yml

@@ -49,11 +49,9 @@ spring:
           driverClassName: com.mysql.cj.jdbc.Driver
           # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
           # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
-#          url: jdbc:mysql://192.168.1.146:3306/game_event?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
-          url: jdbc:mysql://localhost:3306/game-event?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
-          username: root
-#          password: P@ssw0rd
-          password: 123456
+          url: jdbc:mysql://182.92.79.54:3306/game_event?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
+          username: game_event
+          password: TxtYJXEtnC6ecmBw
 #        # 从库数据源
 #        slave:
 #          lazy: true

+ 1 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -130,6 +130,7 @@ tenant:
     - sys_user_role
     - sys_client
     - sys_oss_config
+    - game_bib_task
 
 # MyBatisPlus配置
 # https://baomidou.com/config/

+ 2 - 2
ruoyi-modules/ruoyi-game-event/pom.xml

@@ -108,12 +108,12 @@
         <dependency>
             <groupId>org.apache.poi</groupId>
             <artifactId>poi</artifactId>
-            <version>3.17</version>
+            <version>4.1.2</version>
         </dependency>
         <dependency>
             <groupId>org.apache.poi</groupId>
             <artifactId>poi-ooxml</artifactId>
-            <version>3.17</version>
+            <version>4.1.2</version>
         </dependency>
         <dependency>
             <groupId>org.dromara</groupId>

+ 65 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/config/FileUploadConfig.java

@@ -0,0 +1,65 @@
+package org.dromara.system.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import jakarta.annotation.PostConstruct;
+import java.io.File;
+
+/**
+ * 文件上传配置
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Configuration
+public class FileUploadConfig {
+
+    @Value("${file.upload.path:}")
+    private String uploadPath;
+
+    private String bibImagePath;
+    private String bibResultPath;
+
+    @PostConstruct
+    public void init() {
+        // 如果没有配置上传路径,使用项目根目录下的upload文件夹
+        if (uploadPath == null || uploadPath.trim().isEmpty()) {
+            uploadPath = System.getProperty("user.dir") + File.separator + "upload" + File.separator;
+        }
+        
+        // 确保路径以分隔符结尾
+        if (!uploadPath.endsWith(File.separator)) {
+            uploadPath += File.separator;
+        }
+        
+        bibImagePath = uploadPath + "bib" + File.separator + "images" + File.separator;
+        bibResultPath = uploadPath + "bib" + File.separator + "results" + File.separator;
+        
+        // 创建必要的目录
+        createDirectoryIfNotExists(bibImagePath);
+        createDirectoryIfNotExists(bibResultPath);
+    }
+
+    private void createDirectoryIfNotExists(String path) {
+        File dir = new File(path);
+        if (!dir.exists()) {
+            boolean created = dir.mkdirs();
+            if (!created) {
+                throw new RuntimeException("无法创建目录: " + dir.getAbsolutePath());
+            }
+        }
+    }
+
+    public String getBibImagePath() {
+        return bibImagePath;
+    }
+
+    public String getBibResultPath() {
+        return bibResultPath;
+    }
+
+    public String getUploadPath() {
+        return uploadPath;
+    }
+}

+ 30 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/EnrollController.java

@@ -13,6 +13,7 @@ import org.apache.ibatis.javassist.bytecode.AnnotationsAttribute;
 import org.dromara.common.core.domain.R;
 import org.dromara.common.excel.utils.ExcelUtil;
 import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.system.domain.EnrollImportResult;
 import org.dromara.system.domain.vo.EnrollProjectVo;
 import org.dromara.system.domain.vo.GameAthleteVo;
 import org.dromara.system.service.IEnrollService;
@@ -61,4 +62,33 @@ public class EnrollController {
         Boolean result = enrollService.importDataForPoi(file, eventId);
         return R.ok(result ? "导入成功" : "导入失败");
     }
+
+    /**
+     * 导入报名表并验证数据
+     */
+    @PostMapping("/importDataWithValidation/{eventId}")
+    public R<EnrollImportResult> importDataWithValidation(
+        @RequestParam("file") MultipartFile file,
+        @PathVariable Long eventId) {
+        try {
+            // 添加文件校验
+            if (file == null || file.isEmpty()) {
+                return R.fail("请选择要导入的文件");
+            }
+            
+            String fileName = file.getOriginalFilename();
+            if (fileName == null || !fileName.toLowerCase().endsWith(".xlsx")) {
+                return R.fail("请选择Excel文件(.xlsx格式)");
+            }
+            
+            EnrollImportResult result = enrollService.importDataWithValidation(file, eventId);
+            return R.ok(result);
+        } catch (IllegalArgumentException e) {
+            log.warn("参数错误:{}", e.getMessage());
+            return R.fail("参数错误:" + e.getMessage());
+        } catch (Exception e) {
+            log.error("导入报名表失败,赛事ID: {}", eventId, e);
+            return R.fail("导入失败:" + e.getMessage());
+        }
+    }
 }

+ 10 - 5
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameAthleteController.java

@@ -1,10 +1,7 @@
 package org.dromara.system.controller;
 
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
 import java.util.stream.Collectors;
 
 import cn.hutool.json.JSONUtil;
@@ -18,6 +15,7 @@ import org.dromara.common.excel.core.ExcelResult;
 import org.dromara.common.redis.utils.RedisUtils;
 import org.dromara.system.domain.GameAthlete;
 import org.dromara.system.domain.GameTeam;
+import org.dromara.system.domain.bo.ProjectSelectionValidationBo;
 import org.dromara.system.domain.constant.GameEventConstant;
 import org.dromara.system.domain.vo.AthleteScoreVo;
 import org.dromara.system.domain.vo.GameTeamVo;
@@ -69,7 +67,7 @@ public class GameAthleteController extends BaseController {
     @Log(title = "参赛队员", businessType = BusinessType.EXPORT)
     @PostMapping("/export")
     public void export(GameAthleteBo bo, HttpServletResponse response) {
-        List<GameAthleteVo> list = gameAthleteService.queryList(bo);
+        List<GameAthleteVo> list = gameAthleteService.exportData(bo);
         ExcelUtil.exportExcel(list, "参赛队员", GameAthleteVo.class, response);
     }
 
@@ -166,4 +164,11 @@ public class GameAthleteController extends BaseController {
                           @PathVariable Long[] athleteIds) {
         return toAjax(gameAthleteService.deleteWithValidByIds(List.of(athleteIds), true));
     }
+
+    @PostMapping("/validateProjectSelection")
+    @SaCheckPermission("system:gameAthlete:add")
+    public R<Map<String, Object>> validateProjectSelection(@RequestBody ProjectSelectionValidationBo bo) {
+        Map<String, Object> result = gameAthleteService.validateProjectSelection(bo);
+        return R.ok(result);
+    }
 }

+ 1 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameEventProjectController.java

@@ -113,7 +113,7 @@ public class GameEventProjectController extends BaseController {
     }
 
     /**
-     * 删除赛事项目
+     * 批量删除赛事项目
      *
      * @param projectIds 主键串
      */

+ 4 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameRefereeController.java

@@ -25,6 +25,8 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.system.utils.QRCodeUtils;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
+import static cn.dev33.satoken.SaManager.log;
+
 /**
  * 裁判
  *
@@ -130,7 +132,7 @@ public class GameRefereeController extends BaseController {
             // 获取裁判信息
             GameRefereeVo referee = gameRefereeService.queryById(refereeId);
             if (referee == null) {
-                return R.fail("裁判信息不存在");
+                return R.fail("裁判信息不存在,Id:" + refereeId);
             }
             // 调用工具类封装的裁判二维码生成逻辑
 //            String qrCodeBase64 = qrCodeUtils.generateRefereeQRCodeBase64(referee);
@@ -138,6 +140,7 @@ public class GameRefereeController extends BaseController {
 
             return R.ok("二维码生成成功", qrCodeBase64);
         } catch (Exception e) {
+            log.error("生成裁判二维码失败,裁判ID: {}", refereeId, e);
             return R.fail("生成二维码失败: " + e.getMessage());
         }
     }

+ 10 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/GameScoreController.java

@@ -214,4 +214,14 @@ public class GameScoreController extends BaseController {
             throw new RuntimeException("参数解析失败:" + e.getMessage());
         }
     }
+
+    /**
+     * 导出成绩详情数据(全部)
+     */
+    @SaCheckPermission("system:gameScore:export")
+    @Log(title = "导出成绩详情", businessType = BusinessType.EXPORT)
+    @PostMapping("/exportScoresDetail")
+    public void exportScoresDetail(@RequestParam("eventId") Long eventId, HttpServletResponse response) {
+        gameScoreService.exportScoresDetail(eventId, response);
+    }
 }

+ 384 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/NumberController.java

@@ -6,16 +6,29 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.common.redis.utils.RedisUtils;
+import org.dromara.system.domain.bo.GameBibTaskBo;
 import org.dromara.system.domain.bo.GenerateBibBo;
 import org.dromara.system.domain.constant.GameEventConstant;
 import org.dromara.system.domain.vo.AthleteNumberTableVO;
+import org.dromara.system.domain.vo.GameBibTaskVO;
+import org.dromara.system.service.IGameBibTaskService;
 import org.dromara.system.service.IGameEventService;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 
 @Slf4j
 @Validated
@@ -25,6 +38,10 @@ import java.util.List;
 public class NumberController {
 
     private final IGameEventService gameEventService;
+    private final IGameBibTaskService gameBibTaskService;
+
+    // 预览框尺寸(固定)
+    private static final int previewWidth = 600;
 
     /**
      *
@@ -58,9 +75,375 @@ public class NumberController {
     @PostMapping("/generateBib")
     public void generateNumberBib(HttpServletResponse response,
                                   @RequestPart("bgImage") MultipartFile bgImage,
-                                  @RequestPart(name = "logo") MultipartFile logo,
+                                  @RequestPart(name = "logo", required = false) MultipartFile logo,
                                   GenerateBibBo bibParam) {
         gameEventService.generateNumberBib(response, bgImage, logo, bibParam);
     }
 
+    /**
+     * 创建参赛证生成任务(异步)
+     */
+    @SaCheckPermission("system:gameEvent:numberBib")
+    @PostMapping("/createBibTask")
+    public R<String> createBibTask(@RequestPart("bgImage") MultipartFile bgImage,
+                                @RequestPart(name = "logo", required = false) MultipartFile logo,
+                                @RequestParam("taskName") String taskName,
+                                @RequestPart("bibParam") GenerateBibBo bibParam) {
+        Object cacheObject = RedisUtils.getCacheObject(GameEventConstant.DEFAULT_EVENT_ID);
+        Long eventId = Long.valueOf(cacheObject.toString());
+
+        // 边界检查和修正
+        validateAndCorrectBibParams(bibParam);
+
+        // 调试日志
+        log.info("创建参赛证任务,taskName: {}, bibParam: {}", taskName, bibParam);
+        log.info("bibParam详情 - fontName: {}, fontSize: {}, fontColor: {}, logoX: {}, logoY: {}",
+                bibParam.getFontName(), bibParam.getFontSize(), bibParam.getFontColor(),
+                bibParam.getLogoX(), bibParam.getLogoY());
+        log.info("二维码坐标详情 - qRCodeX: {}, qRCodeY: {}",
+                bibParam.getQRCodeX(), bibParam.getQRCodeY());
+        log.info("Logo缩放: {}, 二维码缩放: {}", bibParam.getLogoScale(), bibParam.getBarcodeScale());
+
+        // 先保存图片文件,再创建任务
+        final String bgImagePath = gameBibTaskService.saveImageFile(bgImage, "bg_" + System.currentTimeMillis());
+        final String logoImagePath;
+        if (logo != null && !logo.isEmpty()) {
+            logoImagePath = gameBibTaskService.saveImageFile(logo, "logo_" + System.currentTimeMillis());
+        } else {
+            logoImagePath = null;
+        }
+
+        // 创建任务(不包含图片文件)
+        final Long taskId = gameBibTaskService.createTaskWithImagePaths(taskName, eventId, bgImagePath, logoImagePath, bibParam);
+
+        // 异步执行任务 - 使用画布模版方案
+        CompletableFuture.runAsync(() -> {
+            try {
+                // 生成画布模版
+                byte[] templateImage = generateCanvasTemplate(bgImagePath, logoImagePath, bibParam);
+
+                // 使用模版生成参赛证
+                gameEventService.generateBibFromTemplateAsync(taskId, eventId, templateImage, bibParam);
+            } catch (Exception e) {
+                log.error("参赛证生成任务执行失败,taskId: {}", taskId, e);
+                gameBibTaskService.updateTaskStatus(taskId, "3", e.getMessage());
+            }
+        });
+
+        return R.ok(taskId.toString(), "任务创建成功,请到任务列表查看进度");
+    }
+
+    /**
+     * 查询任务列表
+     */
+    @GetMapping("/taskList")
+    public TableDataInfo<GameBibTaskVO> queryTaskList(GameBibTaskBo bo, PageQuery query) {
+        return gameBibTaskService.queryPageList(bo, query);
+    }
+
+    /**
+     * 停止任务
+     */
+    @PostMapping("/pauseTask/{taskId}")
+    public R<Void> pauseTask(@PathVariable Long taskId) {
+        gameBibTaskService.pauseTask(taskId);
+        return R.ok("任务已停止");
+    }
+
+    /**
+     * 删除任务
+     */
+    @DeleteMapping("/task/{taskId}")
+    public R<Void> deleteTask(@PathVariable Long taskId) {
+        gameBibTaskService.deleteTask(taskId);
+        return R.ok("任务已删除");
+    }
+
+    /**
+     * 下载任务结果
+     */
+    @GetMapping("/downloadTask/{taskId}")
+    public void downloadTaskResult(@PathVariable Long taskId, HttpServletResponse response) {
+        gameBibTaskService.downloadTaskResult(taskId, response);
+    }
+
+    /**
+     * 验证和修正参赛证参数边界
+     */
+    private void validateAndCorrectBibParams(GenerateBibBo bibParam) {
+        // 统一使用百分比坐标系统 (0-100)
+        final double maxPercent = 100.0;
+        final double minPercent = 0.0;
+
+        // 修正Logo位置(百分比)
+        if (bibParam.getLogoX() != null) {
+            double logoX = Math.max(minPercent, Math.min(maxPercent, bibParam.getLogoX()));
+            if (Double.compare(logoX, bibParam.getLogoX()) != 0) {
+                log.warn("Logo X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getLogoX(), logoX);
+                bibParam.setLogoX(logoX);
+            }
+        }
+
+        if (bibParam.getLogoY() != null) {
+            double logoY = Math.max(minPercent, Math.min(maxPercent, bibParam.getLogoY()));
+            if (Double.compare(logoY, bibParam.getLogoY()) != 0) {
+                log.warn("Logo Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getLogoY(), logoY);
+                bibParam.setLogoY(logoY);
+            }
+        }
+
+        // 修正二维码位置(百分比)
+        if (bibParam.getQRCodeX() != null) {
+            double qrX = Math.max(minPercent, Math.min(maxPercent, bibParam.getQRCodeX()));
+            if (Double.compare(qrX, bibParam.getQRCodeX()) != 0) {
+                log.warn("二维码 X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getQRCodeX(), qrX);
+                bibParam.setQRCodeX(qrX);
+            }
+        }
+
+        if (bibParam.getQRCodeY() != null) {
+            double qrY = Math.max(minPercent, Math.min(maxPercent, bibParam.getQRCodeY()));
+            if (Double.compare(qrY, bibParam.getQRCodeY()) != 0) {
+                log.warn("二维码 Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getQRCodeY(), qrY);
+                bibParam.setQRCodeY(qrY);
+            }
+        }
+
+        // 修正号码位置(百分比值,范围0-100)
+        if (bibParam.getNumberX() != null) {
+            double numberX = Math.max(0.0, Math.min(100.0, bibParam.getNumberX()));
+            if (Double.compare(numberX, bibParam.getNumberX()) != 0) {
+                log.warn("号码 X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getNumberX(), numberX);
+                bibParam.setNumberX(numberX);
+            }
+        }
+
+        if (bibParam.getNumberY() != null) {
+            double numberY = Math.max(0.0, Math.min(100.0, bibParam.getNumberY()));
+            if (Double.compare(numberY, bibParam.getNumberY()) != 0) {
+                log.warn("号码 Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getNumberY(), numberY);
+                bibParam.setNumberY(numberY);
+            }
+        }
+
+        // 修正赛事名称位置(百分比值,范围0-100)
+        if (bibParam.getEventX() != null) {
+            double eventX = Math.max(0.0, Math.min(100.0, bibParam.getEventX()));
+            if (Double.compare(eventX, bibParam.getEventX()) != 0) {
+                log.warn("赛事名称 X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getEventX(), eventX);
+                bibParam.setEventX(eventX);
+            }
+        }
+
+        if (bibParam.getEventY() != null) {
+            double eventY = Math.max(0.0, Math.min(100.0, bibParam.getEventY()));
+            if (Double.compare(eventY, bibParam.getEventY()) != 0) {
+                log.warn("赛事名称 Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getEventY(), eventY);
+                bibParam.setEventY(eventY);
+            }
+        }
+
+        // 修正缩放参数(范围0.1-5.0)
+        if (bibParam.getLogoScale() != null) {
+            double logoScale = Math.max(0.1, Math.min(5.0, bibParam.getLogoScale()));
+            if (Double.compare(logoScale, bibParam.getLogoScale()) != 0) {
+                log.warn("Logo缩放参数超出边界,从 {} 修正为 {}", bibParam.getLogoScale(), logoScale);
+                bibParam.setLogoScale(logoScale);
+            }
+        }
+
+        if (bibParam.getBarcodeScale() != null) {
+            double barcodeScale = Math.max(0.1, Math.min(5.0, bibParam.getBarcodeScale()));
+            if (Double.compare(barcodeScale, bibParam.getBarcodeScale()) != 0) {
+                log.warn("二维码缩放参数超出边界,从 {} 修正为 {}", bibParam.getBarcodeScale(), barcodeScale);
+                bibParam.setBarcodeScale(barcodeScale);
+            }
+        }
+
+        if (bibParam.getNumberScale() != null) {
+            double numberScale = Math.max(0.1, Math.min(5.0, bibParam.getNumberScale()));
+            if (Double.compare(numberScale, bibParam.getNumberScale()) != 0) {
+                log.warn("号码缩放参数超出边界,从 {} 修正为 {}", bibParam.getNumberScale(), numberScale);
+                bibParam.setNumberScale(numberScale);
+            }
+        }
+
+        if (bibParam.getEventScale() != null) {
+            double eventScale = Math.max(0.1, Math.min(5.0, bibParam.getEventScale()));
+            if (Double.compare(eventScale, bibParam.getEventScale()) != 0) {
+                log.warn("赛事名称缩放参数超出边界,从 {} 修正为 {}", bibParam.getEventScale(), eventScale);
+                bibParam.setEventScale(eventScale);
+            }
+        }
+
+        // 修正字体大小(范围8-200)
+        if (bibParam.getFontSize() != null) {
+            int fontSize = Math.max(8, Math.min(200, bibParam.getFontSize()));
+            if (Integer.compare(fontSize, bibParam.getFontSize()) != 0) {
+                log.warn("字体大小超出边界,从 {} 修正为 {}", bibParam.getFontSize(), fontSize);
+                bibParam.setFontSize(fontSize);
+            }
+        }
+
+        log.info("参赛证参数边界检查完成");
+    }
+
+    /**
+     * 生成画布模版
+     */
+    private byte[] generateCanvasTemplate(String bgImagePath, String logoImagePath, GenerateBibBo bibParam) {
+        try {
+            // 读取背景图片
+            BufferedImage bgImage = ImageIO.read(new File(bgImagePath));
+            log.info("背景图片尺寸: {}x{}", bgImage.getWidth(), bgImage.getHeight());
+
+            Graphics2D g2d = bgImage.createGraphics();
+
+            // 设置抗锯齿
+            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+            // 绘制Logo(如果存在)
+            if (logoImagePath != null && new File(logoImagePath).exists()) {
+                log.info("开始绘制Logo - 路径: {}", logoImagePath);
+                drawLogoOnTemplate(g2d, logoImagePath, bibParam, bgImage.getWidth(), bgImage.getHeight());
+            } else {
+                log.warn("Logo图片不存在或路径为空: {}", logoImagePath);
+            }
+
+            // 绘制赛事名称(如果存在)
+            if (StringUtils.isNotBlank(bibParam.getEventName())) {
+                log.info("开始绘制赛事名称: {}", bibParam.getEventName());
+                drawEventNameOnTemplate(g2d, bibParam.getEventName(), bibParam, bgImage.getWidth(), bgImage.getHeight());
+            } else {
+                log.warn("赛事名称为空");
+            }
+
+            g2d.dispose();
+
+            // 转换为字节数组
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(bgImage, "PNG", baos);
+            log.info("画布模版生成完成,大小: {} bytes", baos.size());
+            return baos.toByteArray();
+
+        } catch (Exception e) {
+            log.error("生成画布模版失败", e);
+            throw new RuntimeException("生成画布模版失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 在模版上绘制Logo
+     */
+    private void drawLogoOnTemplate(Graphics2D g2d, String logoImagePath, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            BufferedImage logoImage = ImageIO.read(new File(logoImagePath));
+            log.info("Logo图片尺寸: {}x{}", logoImage.getWidth(), logoImage.getHeight());
+
+            // 预览框尺寸(固定)
+            final int previewWidth = 600;
+            final int previewHeight = 400;
+
+            // 计算Logo位置(从百分比坐标转换为实际图片坐标)
+            Double logoX = bibParam.getLogoX();
+            Double logoY = bibParam.getLogoY();
+
+            int x, y;
+            if (logoX != null && logoY != null) {
+                // 将百分比坐标转换为实际图片像素坐标
+                x = (int) (logoX * canvasWidth / 100.0);
+                y = (int) (logoY * canvasHeight / 100.0);
+                log.info("Logo坐标转换 - 百分比坐标: ({}, {}), 实际图片坐标: ({}, {})",
+                    logoX, logoY, x, y);
+            } else {
+                // 使用默认位置(左上角)
+                x = (int) (4.17 * canvasWidth / 100.0);  // 4.17%
+                y = (int) (6.25 * canvasHeight / 100.0); // 6.25%
+                log.info("使用默认Logo位置: ({}, {})", x, y);
+            }
+
+            // 应用缩放(考虑前端预览的相对比例和用户设置的缩放)
+            Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+
+            // 前端预览时Logo被限制为最大80px,相对于预览框600x400的比例
+            final int previewMaxSize = 80;
+
+            // 计算预览时的相对比例(相对于预览框的宽度)
+            double previewRelativeSize = (double) previewMaxSize / previewWidth;
+
+            // 计算实际图片中Logo应该占的相对比例
+            double actualRelativeSize = previewRelativeSize * canvasWidth;
+
+            // 计算Logo的实际缩放比例
+            double previewScale = Math.min(1.0, actualRelativeSize / Math.max(logoImage.getWidth(), logoImage.getHeight()));
+
+            // 计算最终缩放:预览缩放 × 用户缩放
+            double finalScale = previewScale * logoScale;
+            int scaledWidth = (int) (logoImage.getWidth() * finalScale);
+            int scaledHeight = (int) (logoImage.getHeight() * finalScale);
+
+            // 边界检查,确保Logo不会超出图片范围
+            if (x < 0) x = 0;
+            if (y < 0) y = 0;
+            if (x + scaledWidth > canvasWidth) x = canvasWidth - scaledWidth;
+            if (y + scaledHeight > canvasHeight) y = canvasHeight - scaledHeight;
+
+            // 绘制Logo
+            g2d.drawImage(logoImage, x, y, scaledWidth, scaledHeight, null);
+            log.info("Logo绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终缩放: {}, 画布尺寸: {}x{}",
+                x, y, scaledWidth, scaledHeight, previewRelativeSize, actualRelativeSize, previewScale, logoScale, finalScale, canvasWidth, canvasHeight);
+
+        } catch (Exception e) {
+            log.error("绘制Logo失败", e);
+        }
+    }
+
+    /**
+     * 在模版上绘制赛事名称
+     */
+    private void drawEventNameOnTemplate(Graphics2D g2d, String eventName, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            // 计算赛事名称位置(基于百分比)
+            int x = (int) (canvasWidth * bibParam.getEventX() / 100);
+            int y = (int) (canvasHeight * bibParam.getEventY() / 100);
+
+            // 设置字体(考虑前端预览的相对比例和用户设置的缩放)
+            int baseFontSize = bibParam.getFontSize();
+            // 前端预览时赛事名称字体被限制为最大28px,相对于预览框600x400的比例
+            final int previewMaxFontSize = 28;
+
+            // 计算预览时的相对比例(相对于预览框的宽度)
+            double previewRelativeFontSize = (double) previewMaxFontSize / previewWidth;
+
+            // 计算实际图片中字体应该占的相对比例
+            double actualRelativeFontSize = previewRelativeFontSize * canvasWidth;
+
+            // 计算字体的实际缩放比例
+            double fontScale = Math.min(1.0, actualRelativeFontSize / baseFontSize);
+            Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
+            int fontSize = (int) (baseFontSize * fontScale * eventScale);
+
+            Font font = new Font(bibParam.getFontName(), Font.BOLD, fontSize);
+            g2d.setFont(font);
+
+            // 设置颜色
+            Color color = new Color(bibParam.getFontColor());
+            g2d.setColor(color);
+
+            // 绘制赛事名称
+            FontMetrics fm = g2d.getFontMetrics();
+            int textWidth = fm.stringWidth(eventName);
+            int textHeight = fm.getHeight();
+
+            // 居中绘制
+            g2d.drawString(eventName, x - textWidth / 2, y + textHeight / 4);
+            log.info("赛事名称绘制完成 - 位置: ({}, {}), 基础字体: {}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终字体: {}",
+                x, y, baseFontSize, previewRelativeFontSize, actualRelativeFontSize, fontScale, eventScale, fontSize);
+
+        } catch (Exception e) {
+            log.error("绘制赛事名称失败", e);
+        }
+    }
+
 }

+ 299 - 299
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/TestPoi.java

@@ -20,308 +20,308 @@ public class TestPoi {
      * @return
      */
     public static void main(String[] args) throws IOException {
-        System.out.println(importData());
+//        System.out.println(importData());
     }
 
-    private static List<EnrollProjectVo> importData() throws IOException {
-        List<EnrollProjectVo> enrolls = new ArrayList<>();
-        File file = new File("D:\\poi_demo.xlsx");
-
-        try (FileInputStream fis = new FileInputStream(file);
-             Workbook workbook = new XSSFWorkbook(fis)) {
-
-            Sheet sheet = workbook.getSheetAt(0);
-
-            // 获取第一行(表头),用于获取项目名称
-            Row headerRow = sheet.getRow(2);
-            // 动态确定有效最大列数(去除表头末尾的空列)
-            int lastCellIndex = findValidLastColumnIndex(headerRow);
-            List<String> projectNames = new ArrayList<>();
-
-            // 从第6列(F列,索引5)开始收集项目名称
-            for (int i = 6; i < lastCellIndex; i++) {
-                Cell cell = headerRow.getCell(i);
-                if (cell != null && cell.getStringCellValue() != null && !cell.getStringCellValue().trim().isEmpty()) {
-                    projectNames.add(cell.getStringCellValue().trim());
-                } else {
-                    projectNames.add("项目_" + i); // 防止空标题
-                }
-            }
-
-            // 从第3行开始读数据
-            for (int i = 3; i <= sheet.getLastRowNum(); i++) {
-                Row row = sheet.getRow(i);
-                if (row == null) continue;
-                if (isRowEmpty(row, 0, lastCellIndex)) {
-                    continue;
-                }
-
-                EnrollProjectVo enroll = new EnrollProjectVo();
-                Map<String, Boolean> selections = new LinkedHashMap<>(); // 保持顺序
-
-                // A列:姓名
-                Cell nameCell = row.getCell(0);
-                if (nameCell != null) {
-                    enroll.setName(getCellValueAsString(nameCell));
-                }
-
-                // B列:性别
-                Cell sexCell = row.getCell(1);
-                if (sexCell != null) {
-                    enroll.setSex(getCellValueAsString(sexCell));
-                }
-
-                // C列:年龄
-                Cell ageCell = row.getCell(2);
-                if (ageCell != null) {
-                    enroll.setAge(getCellValueAsString(ageCell));
-                }
-
-                // D列:队伍名称
-                Cell teamCell = row.getCell(3);
-                if (teamCell != null) {
-                    enroll.setTeamName(getCellValueAsString(teamCell));
-                }
-
-                // E列:领队
-                Cell leaderCell = row.getCell(4);
-                if (leaderCell != null) {
-                    enroll.setLeader(getCellValueAsString(leaderCell));
-                }
-
-                // F列:联系方式
-                Cell phoneCell = row.getCell(5);
-                if (phoneCell != null) {
-                    enroll.setPhone(getCellValueAsString(phoneCell));
-                }
-
-                // 从第6列开始读取项目名称
-                for (int j = 6; j < lastCellIndex; j++) {
-                    Cell cell = row.getCell(j);
-                    String projectName = projectNames.get(j - 6); // 对应项目名
-                    boolean selected = isCellSelected(cell);
-                    selections.put(projectName, selected);
-                }
-
-                enroll.setProjectSelections(selections);
-                enrolls.add(enroll);
-            }
-        }
-        return enrolls;
-    }
+//    private static List<EnrollProjectVo> importData() throws IOException {
+//        List<EnrollProjectVo> enrolls = new ArrayList<>();
+//        File file = new File("D:\\poi_demo.xlsx");
+//
+//        try (FileInputStream fis = new FileInputStream(file);
+//             Workbook workbook = new XSSFWorkbook(fis)) {
+//
+//            Sheet sheet = workbook.getSheetAt(0);
+//
+//            // 获取第一行(表头),用于获取项目名称
+//            Row headerRow = sheet.getRow(2);
+//            // 动态确定有效最大列数(去除表头末尾的空列)
+//            int lastCellIndex = findValidLastColumnIndex(headerRow);
+//            List<String> projectNames = new ArrayList<>();
+//
+//            // 从第6列(F列,索引5)开始收集项目名称
+//            for (int i = 6; i < lastCellIndex; i++) {
+//                Cell cell = headerRow.getCell(i);
+//                if (cell != null && cell.getStringCellValue() != null && !cell.getStringCellValue().trim().isEmpty()) {
+//                    projectNames.add(cell.getStringCellValue().trim());
+//                } else {
+//                    projectNames.add("项目_" + i); // 防止空标题
+//                }
+//            }
+//
+//            // 从第3行开始读数据
+//            for (int i = 3; i <= sheet.getLastRowNum(); i++) {
+//                Row row = sheet.getRow(i);
+//                if (row == null) continue;
+//                if (isRowEmpty(row, 0, lastCellIndex)) {
+//                    continue;
+//                }
+//
+//                EnrollProjectVo enroll = new EnrollProjectVo();
+//                Map<String, Boolean> selections = new LinkedHashMap<>(); // 保持顺序
+//
+//                // A列:姓名
+//                Cell nameCell = row.getCell(0);
+//                if (nameCell != null) {
+//                    enroll.setName(getCellValueAsString(nameCell));
+//                }
+//
+//                // B列:性别
+//                Cell sexCell = row.getCell(1);
+//                if (sexCell != null) {
+//                    enroll.setSex(getCellValueAsString(sexCell));
+//                }
+//
+//                // C列:年龄
+//                Cell ageCell = row.getCell(2);
+//                if (ageCell != null) {
+//                    enroll.setAge(getCellValueAsString(ageCell));
+//                }
+//
+//                // D列:队伍名称
+//                Cell teamCell = row.getCell(3);
+//                if (teamCell != null) {
+//                    enroll.setTeamName(getCellValueAsString(teamCell));
+//                }
+//
+//                // E列:领队
+//                Cell leaderCell = row.getCell(4);
+//                if (leaderCell != null) {
+//                    enroll.setLeader(getCellValueAsString(leaderCell));
+//                }
+//
+//                // F列:联系方式
+//                Cell phoneCell = row.getCell(5);
+//                if (phoneCell != null) {
+//                    enroll.setPhone(getCellValueAsString(phoneCell));
+//                }
+//
+//                // 从第6列开始读取项目名称
+//                for (int j = 6; j < lastCellIndex; j++) {
+//                    Cell cell = row.getCell(j);
+//                    String projectName = projectNames.get(j - 6); // 对应项目名
+//                    boolean selected = isCellSelected(cell);
+//                    selections.put(projectName, selected);
+//                }
+//
+//                enroll.setProjectSelections(selections);
+//                enrolls.add(enroll);
+//            }
+//        }
+//        return enrolls;
+//    }
 
     // 辅助方法:将 Cell 转为字符串
-    private static String getCellValueAsString(Cell cell) {
-        if (cell == null) return "";
-
-        switch (cell.getCellType()) {
-            case Cell.CELL_TYPE_STRING:
-                return cell.getStringCellValue().trim();
-            case Cell.CELL_TYPE_NUMERIC:
-                if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
-                    return cell.getDateCellValue().toString();
-                } else {
-                    return String.valueOf((int) cell.getNumericCellValue()); // 或者保留小数用 double
-                }
-            case Cell.CELL_TYPE_BOOLEAN:
-                return String.valueOf(cell.getBooleanCellValue());
-            default:
-                return "";
-        }
-    }
-
-    /**
-     * 判断单元格是否表示“已选择”
-     * 支持:是、yes、true、1、✔、✅、√ 等
-     */
-    private static boolean isCellSelected(Cell cell) {
-        if (cell == null) return false;
-
-        // 先检查单元格类型
-        if (cell.getCellType() == Cell.CELL_TYPE_BOOLEAN) {
-            return cell.getBooleanCellValue();
-        }
-        if (cell.getCellType() == Cell.CELL_TYPE_NUMERIC) {
-            return cell.getNumericCellValue() == 1;
-        }
-
-        String value = getCellValueAsString(cell).trim().toLowerCase();
-        return !value.isEmpty() &&
-            (value.equals("是") || value.equals("yes") || value.equals("true") ||
-                value.equals("1") || value.contains("✔") || value.contains("✅") ||
-                value.contains("√") || value.equals("×"));
-    }
-
-    /**
-     * 判断某行在指定范围内是否为空(无有效数据)
-     *
-     * @param row      行对象
-     * @param startCol 起始列索引
-     * @param endCol   结束列索引(不包含)
-     * @return true 表示该行为空
-     */
-    private static boolean isRowEmpty(Row row, int startCol, int endCol) {
-        if (row == null) return true;
-
-        for (int i = startCol; i < endCol; i++) {
-            Cell cell = row.getCell(i);
-            if (cell != null) {
-                // 如果单元格不为null,检查其是否有非空值
-                String value = getCellValueAsString(cell);
-                if (value != null && !value.trim().isEmpty()) {
-                    return false; // 有有效数据
-                }
-            }
-        }
-        return true; // 所有列都为空
-    }
-
-    /**
-     * 找到行中最后一个“有效”单元格的索引(从右往左找第一个非空单元格)
-     *
-     * @param row 行对象
-     * @return 有效最大列索引(不包含),即 getLastCellNum() 的合理值
-     */
-    private static int findValidLastColumnIndex(Row row) {
-        if (row == null) return 0;
-
-        int lastCellNum = row.getLastCellNum(); // 物理最后一列
-        if (lastCellNum <= 0) return 0;
-
-        // 从右往左扫描,找到第一个非空单元格
-        for (int i = lastCellNum - 1; i >= 0; i--) {
-            Cell cell = row.getCell(i);
-            String value = getCellValueAsString(cell);
-            if (value != null && !value.trim().isEmpty()) {
-                return i + 1; // 返回有效列数(索引+1)
-            }
-        }
-        return 0; // 全为空
-    }
-
-    private static void export() {
-        //1.加载Excel模板文件
-        // String template = "template/enroll_template.xlsx";
-        String template = "D:\\enroll_template.xlsx";
-        System.out.println(template);
-        XSSFSheet sheet = null;
-        try (
-            // InputStream inputStream = TestPoi.class.getClassLoader().getResourceAsStream(template);
-            InputStream inputStream = new FileInputStream(template);
-            XSSFWorkbook xwb = new XSSFWorkbook(inputStream)) {
-            sheet = xwb.getSheetAt(0);
-            assert sheet != null;
-            //7列3行开始横着渲染 excel表对应6行2列
-            // 2. 获取默认赛事动态项目(赛事项目)
-            // Map<String, List<String>> projectMap = gameEventProjectService.mapProjectTypeAndProject(eventId);
-            Map<String, List<String>> projectMap = new HashMap<>();
-            projectMap.put("径赛项目", List.of("4*100米接力", "4*400米接力", "跳高"));
-            projectMap.put("田赛项目", List.of("跨栏", "接力", "50米"));
-
-            // 3. 渲染分类
-            int currentColumnIndex = 6;
-            Row row = sheet.getRow(2);
-            if (row == null) {
-                row = sheet.createRow(2);
-            }
-
-            for (Map.Entry<String, List<String>> entry : projectMap.entrySet()) {
-                String categoryName = entry.getKey();
-                List<String> projectList = entry.getValue();
-                int projectCount = projectList.size();
-                // 3.1 创建单元格并设置分类名称
-                Cell cell = row.createCell(currentColumnIndex);
-                cell.setCellValue(categoryName);
-
-                // 3.2 合并单元格:从 currentColumnIndex 开始,合并 projectCount 个单元格
-                int lastColumnIndex = currentColumnIndex + projectCount - 1;
-                CellRangeAddress region = new CellRangeAddress(2, 2, currentColumnIndex, lastColumnIndex);
-                sheet.addMergedRegion(region);
-
-                // 3.3 创建样式:边框 + 居中 + 加粗
-                CellStyle style = xwb.createCellStyle();
-                style.setBorderTop(BorderStyle.THIN);
-                style.setBorderBottom(BorderStyle.THIN);
-                style.setBorderLeft(BorderStyle.THIN);
-                style.setBorderRight(BorderStyle.THIN);
-                style.setTopBorderColor(IndexedColors.BLACK.getIndex());
-                style.setBottomBorderColor(IndexedColors.BLACK.getIndex());
-                style.setLeftBorderColor(IndexedColors.BLACK.getIndex());
-                style.setRightBorderColor(IndexedColors.BLACK.getIndex());
-
-                style.setAlignment(HorizontalAlignment.CENTER);
-                style.setVerticalAlignment(VerticalAlignment.CENTER);
-
-                // 字体加粗
-                Font font = xwb.createFont();
-                font.setBold(true);
-                style.setFont(font);
-
-                // 应用样式
-                cell.setCellStyle(style);
-
-                // 3.4  更新起始列:为下一个分类腾出位置
-                currentColumnIndex = lastColumnIndex + 1;
-            }
-
-            // 4. 渲染项目(在第4行,索引为3)
-            currentColumnIndex = 6;
-            Row projectRow = sheet.getRow(3);
-            if (projectRow == null) {
-                projectRow = sheet.createRow(3); // 如果第4行不存在,创建它
-            }
-            for (Map.Entry<String, List<String>> entry : projectMap.entrySet()) {
-                List<String> projectList = entry.getValue();
-                // 4.1 遍历当前分类下的每个项目
-                for (String projectName : projectList) {
-                    Cell cell = projectRow.createCell(currentColumnIndex);
-                    cell.setCellValue(projectName);
-
-                    // 3.3 创建样式:边框 + 居中 + 加粗
-                    CellStyle style = xwb.createCellStyle();
-                    style.setBorderTop(BorderStyle.THIN);
-                    style.setBorderBottom(BorderStyle.THIN);
-                    style.setBorderLeft(BorderStyle.THIN);
-                    style.setBorderRight(BorderStyle.THIN);
-                    style.setTopBorderColor(IndexedColors.BLACK.getIndex());
-                    style.setBottomBorderColor(IndexedColors.BLACK.getIndex());
-                    style.setLeftBorderColor(IndexedColors.BLACK.getIndex());
-                    style.setRightBorderColor(IndexedColors.BLACK.getIndex());
-
-                    style.setAlignment(HorizontalAlignment.CENTER);
-                    style.setVerticalAlignment(VerticalAlignment.CENTER);
-
-                    // 字体加粗
-                    Font font = xwb.createFont();
-                    font.setBold(true);
-                    style.setFont(font);
-
-                    // 应用样式
-                    cell.setCellStyle(style);
-
-                    // 移动到下一列
-                    currentColumnIndex++;
-                }
-            }
-
-            //3.将Excel文件通过Response输出
-            // 设置Excel文件路径
-            File target = new File("D:\\poi_demo.xlsx");
-            try {
-                // 创建指向该路径的输出流
-                FileOutputStream stream = new FileOutputStream(target);
-                // 将数据导出到Excel表格
-                xwb.write(stream);
-                // 关闭输出流
-                stream.close();
-            } catch (FileNotFoundException e) {
-                e.printStackTrace();
-            } catch (IOException e) {
-                e.printStackTrace();
-            }
-        } catch (IOException e) {
-            log.error("下载Excel模板异常,异常信息为:【{}】", e.getMessage(), e);
-        }
-    }
+//    private static String getCellValueAsString(Cell cell) {
+//        if (cell == null) return "";
+//
+//        switch (cell.getCellType()) {
+//            case Cell.CELL_TYPE_STRING:
+//                return cell.getStringCellValue().trim();
+//            case Cell.CELL_TYPE_NUMERIC:
+//                if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
+//                    return cell.getDateCellValue().toString();
+//                } else {
+//                    return String.valueOf((int) cell.getNumericCellValue()); // 或者保留小数用 double
+//                }
+//            case Cell.CELL_TYPE_BOOLEAN:
+//                return String.valueOf(cell.getBooleanCellValue());
+//            default:
+//                return "";
+//        }
+//    }
+//
+//    /**
+//     * 判断单元格是否表示“已选择”
+//     * 支持:是、yes、true、1、✔、✅、√ 等
+//     */
+//    private static boolean isCellSelected(Cell cell) {
+//        if (cell == null) return false;
+//
+//        // 先检查单元格类型
+//        if (cell.getCellType() == Cell.CELL_TYPE_BOOLEAN) {
+//            return cell.getBooleanCellValue();
+//        }
+//        if (cell.getCellType() == Cell.CELL_TYPE_NUMERIC) {
+//            return cell.getNumericCellValue() == 1;
+//        }
+//
+//        String value = getCellValueAsString(cell).trim().toLowerCase();
+//        return !value.isEmpty() &&
+//            (value.equals("是") || value.equals("yes") || value.equals("true") ||
+//                value.equals("1") || value.contains("✔") || value.contains("✅") ||
+//                value.contains("√") || value.equals("×"));
+//    }
+
+//    /**
+//     * 判断某行在指定范围内是否为空(无有效数据)
+//     *
+//     * @param row      行对象
+//     * @param startCol 起始列索引
+//     * @param endCol   结束列索引(不包含)
+//     * @return true 表示该行为空
+//     */
+//    private static boolean isRowEmpty(Row row, int startCol, int endCol) {
+//        if (row == null) return true;
+//
+//        for (int i = startCol; i < endCol; i++) {
+//            Cell cell = row.getCell(i);
+//            if (cell != null) {
+//                // 如果单元格不为null,检查其是否有非空值
+//                String value = getCellValueAsString(cell);
+//                if (value != null && !value.trim().isEmpty()) {
+//                    return false; // 有有效数据
+//                }
+//            }
+//        }
+//        return true; // 所有列都为空
+//    }
+//
+//    /**
+//     * 找到行中最后一个“有效”单元格的索引(从右往左找第一个非空单元格)
+//     *
+//     * @param row 行对象
+//     * @return 有效最大列索引(不包含),即 getLastCellNum() 的合理值
+//     */
+//    private static int findValidLastColumnIndex(Row row) {
+//        if (row == null) return 0;
+//
+//        int lastCellNum = row.getLastCellNum(); // 物理最后一列
+//        if (lastCellNum <= 0) return 0;
+//
+//        // 从右往左扫描,找到第一个非空单元格
+//        for (int i = lastCellNum - 1; i >= 0; i--) {
+//            Cell cell = row.getCell(i);
+//            String value = getCellValueAsString(cell);
+//            if (value != null && !value.trim().isEmpty()) {
+//                return i + 1; // 返回有效列数(索引+1)
+//            }
+//        }
+//        return 0; // 全为空
+//    }
+//
+//    private static void export() {
+//        //1.加载Excel模板文件
+//        // String template = "template/enroll_template.xlsx";
+//        String template = "D:\\enroll_template.xlsx";
+//        System.out.println(template);
+//        XSSFSheet sheet = null;
+//        try (
+//            // InputStream inputStream = TestPoi.class.getClassLoader().getResourceAsStream(template);
+//            InputStream inputStream = new FileInputStream(template);
+//            XSSFWorkbook xwb = new XSSFWorkbook(inputStream)) {
+//            sheet = xwb.getSheetAt(0);
+//            assert sheet != null;
+//            //7列3行开始横着渲染 excel表对应6行2列
+//            // 2. 获取默认赛事动态项目(赛事项目)
+//            // Map<String, List<String>> projectMap = gameEventProjectService.mapProjectTypeAndProject(eventId);
+//            Map<String, List<String>> projectMap = new HashMap<>();
+//            projectMap.put("径赛项目", List.of("4*100米接力", "4*400米接力", "跳高"));
+//            projectMap.put("田赛项目", List.of("跨栏", "接力", "50米"));
+//
+//            // 3. 渲染分类
+//            int currentColumnIndex = 6;
+//            Row row = sheet.getRow(2);
+//            if (row == null) {
+//                row = sheet.createRow(2);
+//            }
+//
+//            for (Map.Entry<String, List<String>> entry : projectMap.entrySet()) {
+//                String categoryName = entry.getKey();
+//                List<String> projectList = entry.getValue();
+//                int projectCount = projectList.size();
+//                // 3.1 创建单元格并设置分类名称
+//                Cell cell = row.createCell(currentColumnIndex);
+//                cell.setCellValue(categoryName);
+//
+//                // 3.2 合并单元格:从 currentColumnIndex 开始,合并 projectCount 个单元格
+//                int lastColumnIndex = currentColumnIndex + projectCount - 1;
+//                CellRangeAddress region = new CellRangeAddress(2, 2, currentColumnIndex, lastColumnIndex);
+//                sheet.addMergedRegion(region);
+//
+//                // 3.3 创建样式:边框 + 居中 + 加粗
+//                CellStyle style = xwb.createCellStyle();
+//                style.setBorderTop(BorderStyle.THIN);
+//                style.setBorderBottom(BorderStyle.THIN);
+//                style.setBorderLeft(BorderStyle.THIN);
+//                style.setBorderRight(BorderStyle.THIN);
+//                style.setTopBorderColor(IndexedColors.BLACK.getIndex());
+//                style.setBottomBorderColor(IndexedColors.BLACK.getIndex());
+//                style.setLeftBorderColor(IndexedColors.BLACK.getIndex());
+//                style.setRightBorderColor(IndexedColors.BLACK.getIndex());
+//
+//                style.setAlignment(HorizontalAlignment.CENTER);
+//                style.setVerticalAlignment(VerticalAlignment.CENTER);
+//
+//                // 字体加粗
+//                Font font = xwb.createFont();
+//                font.setBold(true);
+//                style.setFont(font);
+//
+//                // 应用样式
+//                cell.setCellStyle(style);
+//
+//                // 3.4  更新起始列:为下一个分类腾出位置
+//                currentColumnIndex = lastColumnIndex + 1;
+//            }
+//
+//            // 4. 渲染项目(在第4行,索引为3)
+//            currentColumnIndex = 6;
+//            Row projectRow = sheet.getRow(3);
+//            if (projectRow == null) {
+//                projectRow = sheet.createRow(3); // 如果第4行不存在,创建它
+//            }
+//            for (Map.Entry<String, List<String>> entry : projectMap.entrySet()) {
+//                List<String> projectList = entry.getValue();
+//                // 4.1 遍历当前分类下的每个项目
+//                for (String projectName : projectList) {
+//                    Cell cell = projectRow.createCell(currentColumnIndex);
+//                    cell.setCellValue(projectName);
+//
+//                    // 3.3 创建样式:边框 + 居中 + 加粗
+//                    CellStyle style = xwb.createCellStyle();
+//                    style.setBorderTop(BorderStyle.THIN);
+//                    style.setBorderBottom(BorderStyle.THIN);
+//                    style.setBorderLeft(BorderStyle.THIN);
+//                    style.setBorderRight(BorderStyle.THIN);
+//                    style.setTopBorderColor(IndexedColors.BLACK.getIndex());
+//                    style.setBottomBorderColor(IndexedColors.BLACK.getIndex());
+//                    style.setLeftBorderColor(IndexedColors.BLACK.getIndex());
+//                    style.setRightBorderColor(IndexedColors.BLACK.getIndex());
+//
+//                    style.setAlignment(HorizontalAlignment.CENTER);
+//                    style.setVerticalAlignment(VerticalAlignment.CENTER);
+//
+//                    // 字体加粗
+//                    Font font = xwb.createFont();
+//                    font.setBold(true);
+//                    style.setFont(font);
+//
+//                    // 应用样式
+//                    cell.setCellStyle(style);
+//
+//                    // 移动到下一列
+//                    currentColumnIndex++;
+//                }
+//            }
+//
+//            //3.将Excel文件通过Response输出
+//            // 设置Excel文件路径
+//            File target = new File("D:\\poi_demo.xlsx");
+//            try {
+//                // 创建指向该路径的输出流
+//                FileOutputStream stream = new FileOutputStream(target);
+//                // 将数据导出到Excel表格
+//                xwb.write(stream);
+//                // 关闭输出流
+//                stream.close();
+//            } catch (FileNotFoundException e) {
+//                e.printStackTrace();
+//            } catch (IOException e) {
+//                e.printStackTrace();
+//            }
+//        } catch (IOException e) {
+//            log.error("下载Excel模板异常,异常信息为:【{}】", e.getMessage(), e);
+//        }
+//    }
 }
 

+ 13 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/EnrollImportResult.java

@@ -0,0 +1,13 @@
+package org.dromara.system.domain;
+
+import lombok.Data;
+
+@Data
+public class EnrollImportResult {
+    private boolean success;                    // 是否成功
+    private EnrollValidationResult validationResult;  // 校验结果
+    private int totalCount;                     // 总数据条数
+    private int validCount;                     // 有效数据条数
+    private int errorCount;                     // 错误数据条数
+    private String message;                     // 结果消息
+}

+ 14 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/EnrollValidationError.java

@@ -0,0 +1,14 @@
+package org.dromara.system.domain;
+
+import lombok.Data;
+
+// 校验错误信息
+@Data
+public class EnrollValidationError {
+    private int rowIndex;           // 行号
+    private String athleteName;     // 运动员姓名
+    private String teamName;        // 队伍名称
+    private String errorType;       // 错误类型:PERSON_LIMIT_EXCEEDED, PROJECT_LIMIT_EXCEEDED
+    private String errorMessage;    // 错误信息
+    private String projectName;     // 相关项目名称(如果是项目限制错误)
+}

+ 14 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/EnrollValidationResult.java

@@ -0,0 +1,14 @@
+package org.dromara.system.domain;
+
+import lombok.Data;
+import org.dromara.system.domain.vo.EnrollProjectVo;
+
+import java.util.List;
+
+// 校验结果VO
+@Data
+public class EnrollValidationResult {
+    private boolean valid;
+    private List<EnrollValidationError> errors;
+    private List<EnrollProjectVo> validData;
+}

+ 100 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameBibTask.java

@@ -0,0 +1,100 @@
+package org.dromara.system.domain;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 参赛证生成任务对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("game_bib_task")
+public class GameBibTask implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 任务ID
+     */
+    @TableId(value = "task_id", type = IdType.AUTO)
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    private Long eventId;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    private String status;
+
+    /**
+     * 进度百分比
+     */
+    private Integer progress;
+
+    /**
+     * 总数量
+     */
+    private Integer totalCount;
+
+    /**
+     * 已完成数量
+     */
+    private Integer completedCount;
+
+    /**
+     * 背景图片路径
+     */
+    private String bgImagePath;
+
+    /**
+     * Logo图片路径
+     */
+    private String logoImagePath;
+
+    /**
+     * 参赛证参数JSON
+     */
+    private String bibParams;
+
+    /**
+     * 结果文件路径
+     */
+    private String resultFilePath;
+
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+
+    /**
+     * 完成时间
+     */
+    private Date finishTime;
+}

+ 5 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameEvent.java

@@ -66,6 +66,11 @@ public class GameEvent extends TenantEntity {
      */
     private Date endTime;
 
+    /**
+     * 每人限报项目数
+     */
+    private Integer limitApplication;
+
     /**
      * 赛事链接
      */

+ 5 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameEventProject.java

@@ -79,6 +79,11 @@ public class GameEventProject extends TenantEntity {
      */
     private Long participateNum;
 
+    /**
+     * 项目限报人数
+     */
+    private Integer limitPerson;
+
     /**
      * 录取名次
      */

+ 3 - 3
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameAthleteBo.java

@@ -57,13 +57,13 @@ public class GameAthleteBo extends BaseEntity {
     /**
      * 运动员编号
      */
-    @NotBlank(message = "运动员号不能为空", groups = { AddGroup.class, EditGroup.class })
+    @NotBlank(message = "运动员号不能为空", groups = { AddGroup.class, EditGroup.class })
     private String athleteCode;
 
     /**
      * 姓名
      */
-    @NotBlank(message = "姓名不能为空", groups = { AddGroup.class, EditGroup.class })
+//    @NotBlank(message = "姓名不能为空", groups = { AddGroup.class, EditGroup.class })
     private String name;
 
     /**
@@ -75,7 +75,7 @@ public class GameAthleteBo extends BaseEntity {
     /**
      * 年龄
      */
-    @NotNull(message = "年龄不能为空", groups = { AddGroup.class, EditGroup.class })
+//    @NotNull(message = "年龄不能为空", groups = { AddGroup.class, EditGroup.class })
     private Long age;
 
     /**

+ 122 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameBibTaskBo.java

@@ -0,0 +1,122 @@
+package org.dromara.system.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import org.dromara.system.domain.GameBibTask;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 参赛证生成任务业务对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Schema(description = "参赛证生成任务")
+@AutoMapper(target = GameBibTask.class, reverseConvertGenerate = false)
+public class GameBibTaskBo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 任务ID
+     */
+    @Schema(description = "任务ID", example = "1")
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    @Schema(description = "任务名称", example = "参赛证生成_2025-01-27")
+    @NotBlank(message = "任务名称不能为空", groups = { AddGroup.class, EditGroup.class })
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    @Schema(description = "赛事ID", example = "1")
+    @NotNull(message = "赛事ID不能为空", groups = { AddGroup.class, EditGroup.class })
+    private Long eventId;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    @Schema(description = "任务状态", example = "0")
+    private String status;
+
+    /**
+     * 进度百分比
+     */
+    @Schema(description = "进度百分比", example = "50")
+    private Integer progress;
+
+    /**
+     * 总数量
+     */
+    @Schema(description = "总数量", example = "100")
+    private Integer totalCount;
+
+    /**
+     * 已完成数量
+     */
+    @Schema(description = "已完成数量", example = "50")
+    private Integer completedCount;
+
+    /**
+     * 背景图片路径
+     */
+    @Schema(description = "背景图片路径")
+    private String bgImagePath;
+
+    /**
+     * Logo图片路径
+     */
+    @Schema(description = "Logo图片路径")
+    private String logoImagePath;
+
+    /**
+     * 参赛证参数JSON
+     */
+    @Schema(description = "参赛证参数JSON")
+    private String bibParams;
+
+    /**
+     * 结果文件路径
+     */
+    @Schema(description = "结果文件路径")
+    private String resultFilePath;
+
+    /**
+     * 错误信息
+     */
+    @Schema(description = "错误信息")
+    private String errorMessage;
+
+    /**
+     * 创建时间
+     */
+    @Schema(description = "创建时间")
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    @Schema(description = "更新时间")
+    private Date updateTime;
+
+    /**
+     * 完成时间
+     */
+    @Schema(description = "完成时间")
+    private Date finishTime;
+}

+ 5 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameEventBo.java

@@ -68,6 +68,11 @@ public class GameEventBo extends BaseEntity {
     @NotNull(message = "结束时间不能为空", groups = { AddGroup.class, EditGroup.class })
     private Date endTime;
 
+    /**
+     * 每人限报项目数
+     */
+    private Integer limitApplication;
+
     /**
      * 赛事链接
      */

+ 5 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameEventProjectBo.java

@@ -101,6 +101,11 @@ public class GameEventProjectBo extends BaseEntity {
      */
     private Long participateNum;
 
+    /**
+     * 项目限报人数
+     */
+    private Integer limitPerson;
+
     /**
      * 录取名次
      */

+ 36 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GenerateBibBo.java

@@ -1,5 +1,6 @@
 package org.dromara.system.domain.bo;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
 
 import java.io.Serial;
@@ -11,13 +12,17 @@ public class GenerateBibBo implements Serializable {
     /**
      * logo位置
      */
+    @JsonProperty("logoX")
     private Double logoX;
+    @JsonProperty("logoY")
     private Double logoY;
 
     /**
      * 二维码位置
      */
+    @JsonProperty("qRCodeX")
     private Double qRCodeX;
+    @JsonProperty("qRCodeY")
     private Double qRCodeY;
 
     /**
@@ -27,6 +32,37 @@ public class GenerateBibBo implements Serializable {
     private Integer fontSize;
     private Integer fontColor;
 
+    /**
+     * 号码位置(百分比)
+     */
+    @JsonProperty("numberX")
+    private Double numberX;
+    @JsonProperty("numberY")
+    private Double numberY;
+
+    /**
+     * 赛事名称位置(百分比)
+     */
+    @JsonProperty("eventX")
+    private Double eventX;
+    @JsonProperty("eventY")
+    private Double eventY;
+
+    /**
+     * 元素缩放参数
+     */
+    @JsonProperty("logoScale")
+    private Double logoScale;
+    @JsonProperty("barcodeScale")
+    private Double barcodeScale;
+    @JsonProperty("numberScale")
+    private Double numberScale;
+    @JsonProperty("eventScale")
+    private Double eventScale;
+
+    @JsonProperty("eventName")
+    private String eventName;
+
 
     @Serial
     private static final long serialVersionUID = 1L;

+ 15 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/ProjectSelectionValidationBo.java

@@ -0,0 +1,15 @@
+package org.dromara.system.domain.bo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 参赛项目选择表单验证
+ */
+@Data
+public class ProjectSelectionValidationBo {
+//    private Long eventId;
+    private Long athleteId; // 编辑时传入,新增时为null
+    private List<Long> selectedProjectIds;
+}

+ 42 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/query/GameBibTaskQuery.java

@@ -0,0 +1,42 @@
+package org.dromara.system.domain.query;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 参赛证生成任务查询对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "参赛证生成任务查询")
+public class GameBibTaskQuery extends BaseEntity {
+
+    /**
+     * 任务ID
+     */
+    @Schema(description = "任务ID", example = "1")
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    @Schema(description = "任务名称", example = "参赛证生成")
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    @Schema(description = "赛事ID", example = "1")
+    private Long eventId;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    @Schema(description = "任务状态", example = "0")
+    private String status;
+}

+ 7 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/EnrollProjectVo.java

@@ -17,6 +17,12 @@ import java.util.Map;
 @AutoMapper(target = GameAthlete.class)
 public class EnrollProjectVo {
 
+    /**
+     * 号码
+     */
+    @ExcelProperty(value = "号码")
+    private String athleteCode;
+
     /**
      * 姓名
      */
@@ -50,7 +56,7 @@ public class EnrollProjectVo {
     /**
      * 联系方式
      */
-    @ExcelProperty(value = "联系方式")
+    @ExcelProperty(value = "电话")
     private String phone;
 
     private Map<String, Boolean> ProjectSelections;

+ 30 - 14
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameAthleteVo.java

@@ -31,7 +31,7 @@ public class GameAthleteVo implements Serializable {
     /**
      * 主键
      */
-    // @ExcelProperty(value = "主键")
+//     @ExcelProperty(value = "Id")
     private Long athleteId;
 
     /**
@@ -55,13 +55,19 @@ public class GameAthleteVo implements Serializable {
     /**
      * 队伍ID
      */
-    @ExcelProperty(value = "队伍ID")
+//    @ExcelProperty(value = "队伍ID")
     private Long teamId;
 
+    /**
+     * 序号
+     */
+    @ExcelProperty(value = "序号")
+    private Integer rowNumber;
+
     /**
      * 运动员编号
      */
-    @ExcelProperty(value = "运动员编号")
+    @ExcelProperty(value = "号")
     private String athleteCode;
 
     /**
@@ -73,65 +79,75 @@ public class GameAthleteVo implements Serializable {
     /**
      * 性别
      */
-    @ExcelProperty(value = "性别")
+    @ExcelProperty(value = "性别", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "sys_user_sex")
     private String gender;
 
+    /**
+     * 队伍名称
+     */
+    @ExcelProperty(value = "队伍")
+    private String teamName;
+
     /**
      * 年龄
      */
-    @ExcelProperty(value = "年龄")
+//    @ExcelProperty(value = "年龄")
     private Long age;
 
     /**
      * 单位
      */
-    @ExcelProperty(value = "单位")
+//    @ExcelProperty(value = "单位")
     private String unit;
 
     /**
      * 证件号
      */
-    @ExcelProperty(value = "证件号")
+//    @ExcelProperty(value = "证件号")
     private String idCard;
 
     /**
      * 芯片号
      */
-    @ExcelProperty(value = "芯片号")
+//    @ExcelProperty(value = "芯片号")
     private String chipCode;
 
     /**
      * 手机号
      */
-    @ExcelProperty(value = "手机号")
+//    @ExcelProperty(value = "手机号")
     private String phone;
 
     /**
      * 居住地址
      */
-    @ExcelProperty(value = "居住地址")
+//    @ExcelProperty(value = "居住地址")
     private String location;
 
     /**
      * T恤尺码
      */
-    @ExcelProperty(value = "T恤尺码")
+//    @ExcelProperty(value = "T恤尺码")
     private String tshirtSize;
 
     /**
      * 组别
      */
-    @ExcelProperty(value = "组别")
+//    @ExcelProperty(value = "组别")
     private String groupType;
 
     /**
      * 参与项目列表
      */
-    @ExcelProperty(value = "参与项目列表")
+//    @ExcelProperty(value = "参与项目列表")
     private String projectValue;
-
     private List<Long> projectList;
+    /**
+     * 项目名称列表(用于导出)
+     */
+    @ExcelProperty(value = "参与项目")
+    private String projectNames;
 
     /**
      * 状态(0正常 1停用)

+ 108 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameBibTaskVO.java

@@ -0,0 +1,108 @@
+package org.dromara.system.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.system.domain.GameBibTask;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 参赛证生成任务视图对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@AutoMapper(target = GameBibTask.class)
+public class GameBibTaskVO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    private Long eventId;
+
+    /**
+     * 赛事名称
+     */
+    private String eventName;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    private String status;
+
+    /**
+     * 状态文本
+     */
+    private String statusText;
+
+    /**
+     * 进度百分比
+     */
+    private Integer progress;
+
+    /**
+     * 总数量
+     */
+    private Integer totalCount;
+
+    /**
+     * 已完成数量
+     */
+    private Integer completedCount;
+
+    /**
+     * 背景图片路径
+     */
+    private String bgImagePath;
+
+    /**
+     * Logo图片路径
+     */
+    private String logoImagePath;
+
+    /**
+     * 参赛证参数JSON
+     */
+    private String bibParams;
+
+    /**
+     * 结果文件路径
+     */
+    private String resultFilePath;
+
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+
+    /**
+     * 完成时间
+     */
+    private Date finishTime;
+}

+ 35 - 21
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameEventProjectVo.java

@@ -1,6 +1,8 @@
 package org.dromara.system.domain.vo;
 
 import java.util.Date;
+
+import cn.idev.excel.annotation.format.DateTimeFormat;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import org.dromara.system.domain.GameEventProject;
 import org.dromara.system.domain.bo.GameEventProjectBo;
@@ -40,13 +42,13 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 赛事ID
      */
-    @ExcelProperty(value = "赛事ID")
+//    @ExcelProperty(value = "赛事ID")
     private Long eventId;
 
     /**
      * 赛事ID
      */
-    @ExcelProperty(value = "赛事名称")
+//    @ExcelProperty(value = "赛事名称")
     private String eventName;
 
     /**
@@ -65,7 +67,7 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 归类(0个人项目/1团体项目)
      */
-    @ExcelProperty(value = "归类")
+    @ExcelProperty(value = "归类", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "game_project_classification")
     private String classification;
 
@@ -89,30 +91,53 @@ public class GameEventProjectVo implements Serializable {
      * 开始时间
      */
     @ExcelProperty(value = "开始时间")
+    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
     private Date startTime;
 
     /**
      * 结束时间
      */
     @ExcelProperty(value = "结束时间")
+    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
     private Date endTime;
 
+    /**
+     * 比赛轮次
+     */
+    @ExcelProperty(value = "比赛轮次", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(dictType = "game_round")
+    private String gameRound;
+
+    /**
+     * 比赛阶段
+     */
+    @ExcelProperty(value = "比赛阶段", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(dictType = "game_stage")
+    private String gameStage;
+
     /**
      * 更新时间
      */
-    @ExcelProperty(value = "更新时间")
+//    @ExcelProperty(value = "更新时间")
+    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
 
+    /**
+     * 项目限报人数
+     */
+    @ExcelProperty(value = "项目限报人数")
+    private Integer limitPerson;
+
     /**
      * 参赛组数
      */
-    @ExcelProperty(value = "参赛组数")
+//    @ExcelProperty(value = "参赛组数")
     private Long groupNum;
 
     /**
      * 参赛人数
      */
-    @ExcelProperty(value = "参赛人数")
+//    @ExcelProperty(value = "参赛人数")
     private Long participateNum;
 
     /**
@@ -124,13 +149,14 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 排序方式
      */
-//    @ExcelProperty(value = "排序方式")
+    @ExcelProperty(value = "排序方式", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "0=升序,1=降序")
     private String orderType;
 
     /**
      * 计算规则
      */
-    @ExcelProperty(value = "计算规则")
+    @ExcelProperty(value = "计算规则", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "game_score_type")
     private String scoreRule;
 
@@ -143,21 +169,9 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 奖项
      */
-    @ExcelProperty(value = "奖项")
+//    @ExcelProperty(value = "奖项")
     private String award;
 
-    /**
-     * 比赛轮次
-     */
-    @ExcelProperty(value = "比赛轮次")
-    private String gameRound;
-
-    /**
-     * 比赛阶段
-     */
-    @ExcelProperty(value = "比赛阶段")
-    private String gameStage;
-
     /**
      * 状态(0正常 1停用)
      */

+ 10 - 3
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameEventVo.java

@@ -61,7 +61,7 @@ public class GameEventVo implements Serializable {
     /**
      * 用途
      */
-    @ExcelProperty(value = "用途")
+    @ExcelProperty(value = "用途", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "game_event_purpose")
     private String purpose;
 
@@ -77,6 +77,11 @@ public class GameEventVo implements Serializable {
     @ExcelProperty(value = "结束时间")
     private Date endTime;
 
+    /**
+     * 每人限报项目数
+     */
+    private Integer limitApplication;
+
     /**
      * 赛事链接
      */
@@ -86,17 +91,19 @@ public class GameEventVo implements Serializable {
     /**
      * 赛事链接Url
      */
+//    @ExcelProperty(value = "赛事链接")
     @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "eventUrl")
     private String eventUrlUrl;
     /**
      * 裁判码
      */
-    @ExcelProperty(value = "裁判码")
+//    @ExcelProperty(value = "裁判码")
     private String refereeUrl;
 
     /**
      * 裁判码Url
      */
+//    @ExcelProperty(value = "裁判码")
     @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "refereeUrl")
     private String refereeUrlUrl;
     /**
@@ -120,7 +127,7 @@ public class GameEventVo implements Serializable {
      * 是否默认赛事(0=是,1=否)
      */
     @ExcelProperty(value = "是否默认赛事", converter = ExcelDictConvert.class)
-    @ExcelDictFormat(dictType = "sys_yes_no")
+    @ExcelDictFormat(readConverterExp = "0=是,1=否")
     private String isDefault;
 
     /**

+ 3 - 2
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameTeamVo.java

@@ -28,7 +28,7 @@ public class GameTeamVo implements Serializable {
     @Serial
     private static final long serialVersionUID = 1L;
 
-//   @ExcelProperty(value = "主键")
+   @ExcelProperty(value = "队伍Id")
     private Long teamId;
 
     /**
@@ -45,12 +45,13 @@ public class GameTeamVo implements Serializable {
     /**
      * 排名分组名
      */
+    @ExcelProperty(value = "分组名")
     private String rgName;
 
     /**
      * 赛事名称
      */
-    @ExcelProperty(value = "赛事名称")
+//    @ExcelProperty(value = "赛事名称")
     private String eventName;
 
     /**

+ 19 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameAthleteMapper.java

@@ -1,10 +1,14 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 import org.dromara.system.domain.GameAthlete;
+import org.dromara.system.domain.bo.GameAthleteBo;
 import org.dromara.system.domain.vo.GameAthleteVo;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
 
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
@@ -14,6 +18,7 @@ import java.util.Map;
  * @author zlt
  * @date 2025-07-30
  */
+@Mapper
 public interface GameAthleteMapper extends BaseMapperPlus<GameAthlete, GameAthleteVo> {
 
     @Select("select * from game_athlete where user_id = #{userId} AND del_flag = '0'")
@@ -24,4 +29,18 @@ public interface GameAthleteMapper extends BaseMapperPlus<GameAthlete, GameAthle
 
     @Select("select athlete_id,name,team_id from game_athlete where event_id = #{eventId} and del_flag = '0'")
     List<GameAthleteVo> queryAthleteIdAndName(Long eventId);
+
+    /**
+     * 根据项目id列表查询运动员(批量查询)
+     * @param projectIds 项目id列表
+     * @return 运动员列表
+     */
+    @Select("<script>" +
+        "SELECT * FROM game_athlete " +
+        "WHERE del_flag = '0' and(" +
+        "<foreach collection='projectIds' item='projectId' separator=' OR '>" +
+        "JSON_CONTAINS(project_value, CAST(#{projectId} AS JSON))" +
+        "</foreach>" +")"+
+        "</script>")
+    List<GameAthleteBo> findByProjectIds(@Param("projectIds") Collection<Long> projectIds);
 }

+ 15 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameBibTaskMapper.java

@@ -0,0 +1,15 @@
+package org.dromara.system.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.system.domain.GameBibTask;
+import org.dromara.system.domain.vo.GameBibTaskVO;
+
+/**
+ * 参赛证生成任务Mapper接口
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+public interface GameBibTaskMapper extends BaseMapperPlus<GameBibTask, GameBibTaskVO> {
+
+}

+ 2 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameEventMapper.java

@@ -1,5 +1,6 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Mapper;
 import org.dromara.system.domain.GameEvent;
 import org.dromara.system.domain.vo.GameEventVo;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
@@ -10,6 +11,7 @@ import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
  * @author zlt
  * @date 2025-07-30
  */
+@Mapper
 public interface GameEventMapper extends BaseMapperPlus<GameEvent, GameEventVo> {
 
 }

+ 2 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameEventProjectMapper.java

@@ -1,5 +1,6 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Mapper;
 import org.dromara.system.domain.GameEventProject;
 import org.dromara.system.domain.vo.GameEventProjectVo;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
@@ -10,6 +11,7 @@ import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
  * @author zlt
  * @date 2025-07-30
  */
+@Mapper
 public interface GameEventProjectMapper extends BaseMapperPlus<GameEventProject, GameEventProjectVo> {
 
 }

+ 20 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameRefereeMapper.java

@@ -1,10 +1,16 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 import org.dromara.system.domain.GameReferee;
+import org.dromara.system.domain.bo.GameRefereeBo;
 import org.dromara.system.domain.vo.GameRefereeVo;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
 import org.apache.ibatis.annotations.Mapper;
 
+import java.util.Collection;
+import java.util.List;
+
 /**
  * 裁判Mapper接口
  *
@@ -14,4 +20,17 @@ import org.apache.ibatis.annotations.Mapper;
 @Mapper
 public interface GameRefereeMapper extends BaseMapperPlus<GameReferee, GameRefereeVo> {
 
-}
+    /**
+     * 根据项目id列表查询裁判(批量查询)
+     * @param projectIds 项目id列表
+     * @return 裁判列表
+     */
+    @Select("<script>" +
+        "SELECT * FROM game_referee " +
+        "WHERE del_flag = '0' and (" +
+        "<foreach collection='projectIds' item='projectId' separator=' OR '>" +
+        "JSON_CONTAINS(project_list, CAST(#{projectId} AS JSON))" +
+        "</foreach>" + ")"+
+        "</script>")
+    List<GameRefereeBo> findByProjectIds(@Param("projectIds") Collection<Long> projectIds);
+}

+ 18 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameTeamMapper.java

@@ -1,10 +1,13 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Select;
 import org.dromara.system.domain.GameTeam;
+import org.dromara.system.domain.bo.GameTeamBo;
 import org.dromara.system.domain.vo.GameTeamVo;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
 
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -13,6 +16,7 @@ import java.util.List;
  * @author zlt
  * @date 2025-07-30
  */
+@Mapper
 public interface GameTeamMapper extends BaseMapperPlus<GameTeam, GameTeamVo> {
 
     @Select("""
@@ -30,4 +34,18 @@ public interface GameTeamMapper extends BaseMapperPlus<GameTeam, GameTeamVo> {
      * @return
      */
     boolean batchUpdateRg(Long rgId, List<Long> teamIds);
+
+    /**
+     * 根据运动员id列表查询队伍(批量查询)
+     * @param athleteIds 运动员id列表
+     * @return 队伍列表
+     */
+    @Select("<script>" +
+        "SELECT * FROM game_team " +
+        "WHERE del_flag = '0' and(" +
+        "<foreach collection='athleteIds' item='athleteId' separator=' OR '>" +
+        "JSON_CONTAINS(athlete_value, CAST(#{athleteId} AS JSON))" +
+        "</foreach>" +")"+
+        "</script>")
+    List<GameTeamBo> findByAthleteIds(Collection<Long> athleteIds);
 }

+ 21 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IEnrollService.java

@@ -2,9 +2,14 @@ package org.dromara.system.service;
 
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import org.dromara.system.domain.EnrollImportResult;
+import org.dromara.system.domain.EnrollValidationResult;
 import org.dromara.system.domain.bo.EnrollBo;
+import org.dromara.system.domain.vo.EnrollProjectVo;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.util.List;
+
 public interface IEnrollService {
 
     /**
@@ -23,4 +28,20 @@ public interface IEnrollService {
     Boolean importDataForPoi(MultipartFile file, Long eventId);
 
     Boolean enroll(EnrollBo enrollBo);
+
+    /**
+     * 校验导入数据
+     * @param enrollList 导入的报名数据
+     * @param eventId 赛事ID
+     * @return 校验结果
+     */
+    EnrollValidationResult validateEnrollData(List<EnrollProjectVo> enrollList, Long eventId);
+
+    /**
+     * 导入报名表(带校验)
+     * @param file 文件
+     * @param eventId 赛事ID
+     * @return 导入结果
+     */
+    EnrollImportResult importDataWithValidation(MultipartFile file, Long eventId);
 }

+ 18 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameAthleteService.java

@@ -4,11 +4,13 @@ import org.dromara.common.mybatis.core.page.PageQuery;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.system.domain.GameAthlete;
 import org.dromara.system.domain.bo.GameAthleteBo;
+import org.dromara.system.domain.bo.ProjectSelectionValidationBo;
 import org.dromara.system.domain.vo.AthleteScoreVo;
 import org.dromara.system.domain.vo.GameAthleteVo;
 
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 参赛队员Service接口
@@ -51,6 +53,8 @@ public interface IGameAthleteService {
      */
     List<GameAthleteVo> queryList(GameAthleteBo bo);
 
+    List<GameAthleteVo> exportData(GameAthleteBo bo);
+
     /**
      * 新增参赛队员
      *
@@ -97,4 +101,18 @@ public interface IGameAthleteService {
     List<GameAthleteVo> queryListByEventIdAndProjectId(Long eventId, Long projectId, String searchValue);
 
     String queryMaxNumber(Long teamId);
+
+    /**
+     * 根据项目ID查询运动员列表
+     * @param projectIds 项目ID列表
+     * @return 运动员列表
+     */
+    List<GameAthleteBo> findByProjectIds(Collection<Long> projectIds);
+
+    /**
+     * 验证项目选择
+     * @param bo 验证参数
+     * @return 验证结果
+     */
+    Map<String, Object> validateProjectSelection(ProjectSelectionValidationBo bo);
 }

+ 152 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameBibTaskService.java

@@ -0,0 +1,152 @@
+package org.dromara.system.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.system.domain.bo.GameBibTaskBo;
+import org.dromara.system.domain.bo.GenerateBibBo;
+import org.dromara.system.domain.query.GameBibTaskQuery;
+import org.dromara.system.domain.vo.GameBibTaskVO;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Collection;
+
+/**
+ * 参赛证生成任务Service接口
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+public interface IGameBibTaskService {
+
+    /**
+     * 查询参赛证生成任务
+     *
+     * @param taskId 主键
+     * @return 参赛证生成任务
+     */
+    GameBibTaskVO queryById(Long taskId);
+
+    /**
+     * 分页查询参赛证生成任务列表
+     *
+     * @param bo 查询条件
+     * @return 参赛证生成任务分页列表
+     */
+    TableDataInfo<GameBibTaskVO> queryPageList(GameBibTaskBo bo, PageQuery query);
+
+    /**
+     * 查询符合条件的参赛证生成任务列表
+     *
+     * @param bo 查询条件
+     * @return 参赛证生成任务列表
+     */
+    java.util.List<GameBibTaskVO> queryList(GameBibTaskBo bo);
+
+    /**
+     * 新增参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否新增成功
+     */
+    Boolean insertByBo(GameBibTaskBo bo);
+
+    /**
+     * 修改参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否修改成功
+     */
+    Boolean updateByBo(GameBibTaskBo bo);
+
+    /**
+     * 校验并批量删除参赛证生成任务信息
+     *
+     * @param ids     待删除的主键集合
+     * @param isValid 是否进行有效性校验
+     * @return 是否删除成功
+     */
+    Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
+
+    /**
+     * 创建参赛证生成任务
+     *
+     * @param taskName 任务名称
+     * @param eventId  赛事ID
+     * @param bgImage  背景图片
+     * @param logo     Logo图片
+     * @param bibParam 参赛证参数
+     * @return 任务ID
+     */
+    Long createTask(String taskName, Long eventId, MultipartFile bgImage,
+                    MultipartFile logo, GenerateBibBo bibParam);
+
+    /**
+     * 创建参赛证生成任务(使用已保存的图片路径)
+     *
+     * @param taskName      任务名称
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     * @return 任务ID
+     */
+    Long createTaskWithImagePaths(String taskName, Long eventId, String bgImagePath,
+                                  String logoImagePath, GenerateBibBo bibParam);
+
+    /**
+     * 保存图片文件
+     *
+     * @param file     图片文件
+     * @param fileName 文件名
+     * @return 保存后的文件路径
+     */
+    String saveImageFile(MultipartFile file, String fileName);
+
+    /**
+     * 更新任务状态
+     *
+     * @param taskId      任务ID
+     * @param status      状态
+     * @param errorMessage 错误信息
+     */
+    void updateTaskStatus(Long taskId, String status, String errorMessage);
+
+    /**
+     * 更新任务进度
+     *
+     * @param taskId         任务ID
+     * @param progress       进度
+     * @param completedCount 已完成数量
+     */
+    void updateTaskProgress(Long taskId, Integer progress, Integer completedCount);
+
+    /**
+     * 完成任务
+     *
+     * @param taskId         任务ID
+     * @param resultFilePath 结果文件路径
+     */
+    void completeTask(Long taskId, String resultFilePath);
+
+    /**
+     * 暂停任务
+     *
+     * @param taskId 任务ID
+     */
+    void pauseTask(Long taskId);
+
+    /**
+     * 删除任务
+     *
+     * @param taskId 任务ID
+     */
+    void deleteTask(Long taskId);
+
+    /**
+     * 下载任务结果
+     *
+     * @param taskId   任务ID
+     * @param response 响应对象
+     */
+    void downloadTaskResult(Long taskId, jakarta.servlet.http.HttpServletResponse response);
+}

+ 23 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameEventService.java

@@ -14,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 赛事基本信息Service接口
@@ -122,6 +123,17 @@ public interface IGameEventService {
 
     void generateNumberBib(HttpServletResponse response, MultipartFile bgImage, MultipartFile logo, GenerateBibBo bibParam);
 
+    /**
+     * 异步生成参赛证
+     *
+     * @param taskId        任务ID
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     */
+    void generateNumberBibAsync(Long taskId, Long eventId, String bgImagePath, String logoImagePath, GenerateBibBo bibParam);
+
     /**
      * 获取赛事项目进度信息
      *
@@ -129,4 +141,15 @@ public interface IGameEventService {
      * @return 项目进度列表
      */
     List<ProjectProgressVo> getProjectProgress(Long eventId);
+
+    /**
+     * 基于画布模版生成参赛证(异步)
+     *
+     * @param taskId         任务ID
+     * @param eventId        赛事ID
+     * @param templateImage  模版图片
+     * @param bibParam       参赛证参数
+     */
+    void generateBibFromTemplateAsync(Long taskId, Long eventId, byte[] templateImage, GenerateBibBo bibParam);
+
 }

+ 7 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameRefereeService.java

@@ -71,4 +71,11 @@ public interface IGameRefereeService {
      * @return
      */
     Long countReferee();
+
+    /**
+     * 根据项目id列表查询裁判(批量查询)
+     * @param projectIds 项目id列表
+     * @return 裁判列表
+     */
+    List<GameRefereeBo> findByProjectIds(Collection<Long> projectIds);
 }

+ 7 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameScoreService.java

@@ -139,4 +139,11 @@ public interface IGameScoreService {
      * 导出加分Excel
      */
     void exportBonusExcel(Map<String, Object> data, HttpServletResponse response);
+
+    /**
+     * 导出成绩详情数据(全部)
+     * @param eventId 赛事ID
+     * @param response HTTP响应对象
+     */
+    void exportScoresDetail(Long eventId, HttpServletResponse response);
 }

+ 7 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameTeamService.java

@@ -137,4 +137,11 @@ public interface IGameTeamService {
     Boolean moveGroup(List<Long> teamIds, Long rgId);
 
     List<GameTeam> queryTeamByEventId(Long eventId);
+
+    /**
+     * 根据运动员id列表查询队伍信息
+     * @param athleteIds 运动员id列表
+     * @return 队伍信息
+     */
+    List<GameTeamBo> findByAthleteIds(Collection<Long> athleteIds);
 }

+ 214 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameAthleteServiceImpl.java

@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.utils.MapstructUtils;
 import org.dromara.common.core.utils.StringUtils;
 import org.dromara.common.mybatis.core.page.PageQuery;
@@ -21,6 +22,7 @@ import org.dromara.system.domain.GameTeam;
 import org.dromara.system.domain.bo.GameAthleteBo;
 import org.dromara.system.domain.bo.GameEventBo;
 import org.dromara.system.domain.bo.GameTeamBo;
+import org.dromara.system.domain.bo.ProjectSelectionValidationBo;
 import org.dromara.system.domain.constant.GameEventConstant;
 import org.dromara.system.domain.vo.*;
 import org.dromara.system.mapper.GameAthleteMapper;
@@ -36,6 +38,7 @@ import org.springframework.stereotype.Service;
 
 import java.util.*;
 import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 /**
  * 参赛队员Service业务层处理
@@ -230,9 +233,98 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
                     vo.setProjectList(projects);
                 });
         });
+
         return athleteList;
     }
 
+    /**
+     * 导出数据
+     */
+    @Override
+    public List<GameAthleteVo> exportData(GameAthleteBo bo) {
+        List<GameAthleteVo> athleteList = queryList(bo);
+        // 设置序号
+        setRowNumbers(athleteList);
+        // 转换队伍ID为队伍名称
+        convertTeamIdToName(athleteList);
+
+        // 转换项目ID列表为项目名称列表
+        convertProjectIdsToNames(athleteList);
+
+        return athleteList;
+    }
+
+    /**
+     * 设置序号
+     */
+    private void setRowNumbers(List<GameAthleteVo> athleteList) {
+        IntStream.range(0, athleteList.size())
+        .forEach(i -> athleteList.get(i).setRowNumber(i + 1));
+    }
+
+    /**
+     * 将队伍ID转换为队伍名称
+     */
+    private void convertTeamIdToName(List<GameAthleteVo> athleteList) {
+        // 1. 收集所有队伍ID
+        Set<Long> teamIds = athleteList.stream()
+            .map(GameAthleteVo::getTeamId)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toSet());
+
+        // 2. 批量查询队伍信息
+        Map<Long, String> teamIdToNameMap = new HashMap<>();
+        if (CollUtil.isNotEmpty(teamIds)) {
+            LambdaQueryWrapper<GameTeam> wrapper = new LambdaQueryWrapper<>();
+            wrapper.in(GameTeam::getTeamId, teamIds);
+            List<GameTeam> teams = gameTeamMapper.selectList(wrapper);
+            teamIdToNameMap = teams.stream()
+                .collect(Collectors.toMap(GameTeam::getTeamId, GameTeam::getTeamName));
+        }
+
+        // 3. 设置队伍名称
+        final Map<Long, String> finalTeamMap = teamIdToNameMap;
+        athleteList.forEach(athlete -> {
+            athlete.setTeamName(finalTeamMap.getOrDefault(athlete.getTeamId(), "未分配队伍"));
+        });
+    }
+
+    /**
+     * 将项目ID列表转换为项目名称列表
+     */
+    private void convertProjectIdsToNames(List<GameAthleteVo> athleteList) {
+        // 1. 收集所有项目ID
+        Set<Long> allProjectIds = new HashSet<>();
+        athleteList.forEach(athlete -> {
+            if (CollUtil.isNotEmpty(athlete.getProjectList())) {
+                allProjectIds.addAll(athlete.getProjectList());
+            }
+        });
+
+        // 2. 批量查询项目信息
+        Map<Long, String> projectIdToNameMap = new HashMap<>();
+        if (CollUtil.isNotEmpty(allProjectIds)) {
+            LambdaQueryWrapper<GameEventProject> wrapper = new LambdaQueryWrapper<>();
+            wrapper.in(GameEventProject::getProjectId, allProjectIds);
+            List<GameEventProject> projects = gameEventProjectMapper.selectList(wrapper);
+            projectIdToNameMap = projects.stream()
+                .collect(Collectors.toMap(GameEventProject::getProjectId, GameEventProject::getProjectName));
+        }
+
+        // 3. 设置项目名称列表
+        final Map<Long, String> finalProjectMap = projectIdToNameMap;
+        athleteList.forEach(athlete -> {
+            if (CollUtil.isNotEmpty(athlete.getProjectList())) {
+                List<String> projectNames = athlete.getProjectList().stream()
+                    .map(projectId -> finalProjectMap.getOrDefault(projectId, "未知项目"))
+                    .collect(Collectors.toList());
+                athlete.setProjectNames(String.join(",", projectNames));
+            } else {
+                athlete.setProjectNames("");
+            }
+        });
+    }
+
     private LambdaQueryWrapper<GameAthlete> buildQueryWrapper(GameAthleteBo bo) {
         Map<String, Object> params = bo.getParams();
         LambdaQueryWrapper<GameAthlete> lqw = Wrappers.lambdaQuery();
@@ -337,6 +429,16 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
      */
     private void validEntityBeforeSave(GameAthlete entity) {
         //TODO 做一些数据校验,如唯一约束
+        //校验运动员编号是否存在重复值
+        if (entity.getAthleteCode() != null){
+            List<GameAthlete> list = baseMapper.selectList(new LambdaQueryWrapper<GameAthlete>()
+                .eq(GameAthlete::getAthleteCode, entity.getAthleteCode())
+                .ne( entity.getAthleteId() != null, GameAthlete::getAthleteId, entity.getAthleteId())
+            );
+            if (!list.isEmpty()){
+                throw new ServiceException(entity.getAthleteCode()+"编号已存在!");
+            }
+        }
     }
 
     /**
@@ -351,9 +453,43 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
         if (isValid) {
             //TODO 做一些业务上的校验,判断是否需要校验
         }
+        //批量删除前,删除队伍中的关联数据
+        removeAthleteFromTeams(ids);
         return baseMapper.deleteByIds(ids) > 0;
     }
 
+    /**
+     * 从所有队伍的项目列表中移除指定运动员
+     *
+     * @param athleteIds 要移除的项目ID列表
+     */
+    private void removeAthleteFromTeams(Collection<Long> athleteIds) {
+        try {
+            if (CollectionUtils.isEmpty(athleteIds)) {
+                return;
+            }
+
+            // 查询所有包含这些项目的运动员
+            List<GameTeamBo> teams = gameTeamService.findByAthleteIds(athleteIds);
+
+            log.info("找到 {} 个队伍需要更新运动员关联,运动员ID列表: {}", teams.size(), athleteIds);
+
+            for (GameTeamBo team : teams) {
+                for (Long athleteId : athleteIds){
+                    if (team.getAthleteList() != null) {
+                        team.getAthleteList().remove(athleteId);
+                        log.info("已从队伍 {} 的运动员列表中移除项目 {}", team.getTeamName(), athleteId);
+                    }
+                }
+                gameTeamService.updateByBo(team);
+            }
+
+            log.info("队伍与运动员的关联清理完成,共处理 {} 个队伍", teams.size());
+        } catch (Exception e) {
+            log.error("清理队伍与运动员的关联失败,运动员ID列表: {}", athleteIds, e);
+            throw e;
+        }
+    }
 
     /**
      * 批量保存更新参赛队员信息
@@ -494,4 +630,82 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
         );
         return gameAthlete != null ? gameAthlete.getAthleteCode() : "0";
     }
+
+    @Override
+    public List<GameAthleteBo> findByProjectIds(Collection<Long> projectIds) {
+        return baseMapper.findByProjectIds(projectIds);
+    }
+
+    /**
+     * 项目选择验证
+     * @param bo 验证参数
+     * @return
+     */
+    @Override
+    public Map<String, Object> validateProjectSelection(ProjectSelectionValidationBo bo) {
+        Map<String, Object> result = new HashMap<>();
+        List<String> errors = new ArrayList<>();
+        List<String> warnings = new ArrayList<>();
+
+        // 1. 校验每人限报项目数
+        GameEventVo event = gameEventService.getDefaultEvent();
+        if (event.getLimitApplication() != null && event.getLimitApplication() > 0) {
+            if (bo.getSelectedProjectIds().size() > event.getLimitApplication()) {
+                errors.add(String.format("每人限报项目数为 %d 个,您已选择 %d 个项目",
+                    event.getLimitApplication(), bo.getSelectedProjectIds().size()));
+            }
+        }
+
+        // 2. 校验各项目的限报人数
+        for (Long projectId : bo.getSelectedProjectIds()) {
+            GameEventProjectVo project = gameEventProjectService.queryById(projectId);
+            if (project.getLimitPerson() != null && project.getLimitPerson() > 0) {
+                // 统计当前项目已报名人数
+                int currentCount = countAthletesByProjectId(projectId, bo.getAthleteId());
+
+                if (currentCount >= project.getLimitPerson()) {
+                    errors.add(String.format("项目「%s」报名人数已满(%d/%d),无法添加",
+                        project.getProjectName(), currentCount, project.getLimitPerson()));
+                } else if (currentCount >= project.getLimitPerson() * 0.9) {
+                    warnings.add(String.format("项目「%s」报名人数接近上限(%d/%d)",
+                        project.getProjectName(), currentCount, project.getLimitPerson()));
+                }
+            }
+        }
+
+        result.put("valid", errors.isEmpty());
+        result.put("errors", errors);
+        result.put("warnings", warnings);
+
+        return result;
+    }
+
+    // 统计项目报名人数的方法
+    private int countAthletesByProjectId(Long projectId, Long excludeAthleteId) {
+        LambdaQueryWrapper<GameAthlete> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(GameAthlete::getProjectValue, projectId.toString());
+
+        if (excludeAthleteId != null) {
+            wrapper.ne(GameAthlete::getAthleteId, excludeAthleteId);
+        }
+
+        List<GameAthlete> athletes = baseMapper.selectList(wrapper);
+
+        // 需要解析JSON格式的projectValue字段
+        int count = 0;
+        for (GameAthlete athlete : athletes) {
+            if (StringUtils.isNotBlank(athlete.getProjectValue())) {
+                try {
+                    List<Long> projectIds = JSONUtil.toList(athlete.getProjectValue(), Long.class);
+                    if (projectIds.contains(projectId)) {
+                        count++;
+                    }
+                } catch (Exception e) {
+                    log.warn("解析运动员项目列表失败: {}", athlete.getProjectValue());
+                }
+            }
+        }
+
+        return count;
+    }
 }

+ 420 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameBibTaskServiceImpl.java

@@ -0,0 +1,420 @@
+package org.dromara.system.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.system.domain.GameBibTask;
+import org.dromara.system.domain.bo.GameBibTaskBo;
+import org.dromara.system.domain.bo.GenerateBibBo;
+import org.dromara.system.domain.query.GameBibTaskQuery;
+import org.dromara.system.domain.vo.GameBibTaskVO;
+import org.dromara.system.config.FileUploadConfig;
+import org.dromara.system.mapper.GameBibTaskMapper;
+import org.dromara.system.service.IGameBibTaskService;
+import org.dromara.system.utils.EventNameUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 参赛证生成任务Service业务层处理
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class GameBibTaskServiceImpl implements IGameBibTaskService {
+
+    private final GameBibTaskMapper baseMapper;
+    private final FileUploadConfig fileUploadConfig;
+    private final EventNameUtils eventNameUtils;
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 查询参赛证生成任务
+     *
+     * @param taskId 主键
+     * @return 参赛证生成任务
+     */
+    @Override
+    public GameBibTaskVO queryById(Long taskId) {
+        return baseMapper.selectVoById(taskId);
+    }
+
+    /**
+     * 分页查询参赛证生成任务列表
+     *
+     * @param query 查询条件
+     * @return 参赛证生成任务分页列表
+     */
+    @Override
+    public TableDataInfo<GameBibTaskVO> queryPageList(GameBibTaskBo bo, PageQuery query) {
+        LambdaQueryWrapper<GameBibTask> lqw = buildQueryWrapper(bo);
+        Page<GameBibTaskVO> result = baseMapper.selectVoPage(query.build(), lqw);
+        
+        // 填充赛事名称
+        if (result.getRecords() != null && !result.getRecords().isEmpty()) {
+            fillEventNames(result.getRecords());
+        }
+        
+        return TableDataInfo.build(result);
+    }
+    
+    /**
+     * 填充赛事名称
+     */
+    private void fillEventNames(List<GameBibTaskVO> records) {
+        // 获取所有赛事ID
+        Set<Long> eventIds = records.stream()
+            .map(GameBibTaskVO::getEventId)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toSet());
+        
+        if (eventIds.isEmpty()) {
+            return;
+        }
+        
+        // 查询赛事信息
+        Map<Long, String> eventNameMap = eventNameUtils.getEventNameMap(eventIds);
+        
+        // 填充赛事名称
+        records.forEach(record -> {
+            if (record.getEventId() != null) {
+                String eventName = eventNameMap.get(record.getEventId());
+                record.setEventName(eventName != null ? eventName : "未知赛事");
+            }
+        });
+    }
+
+    /**
+     * 查询符合条件的参赛证生成任务列表
+     *
+     * @param bo 查询条件
+     * @return 参赛证生成任务列表
+     */
+    @Override
+    public List<GameBibTaskVO> queryList(GameBibTaskBo bo) {
+        LambdaQueryWrapper<GameBibTask> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    private LambdaQueryWrapper<GameBibTask> buildQueryWrapper(GameBibTaskBo bo) {
+        LambdaQueryWrapper<GameBibTask> lqw = Wrappers.lambdaQuery();
+        lqw.like(StringUtils.isNotBlank(bo.getTaskName()), GameBibTask::getTaskName, bo.getTaskName());
+        lqw.eq(ObjectUtil.isNotNull(bo.getEventId()), GameBibTask::getEventId, bo.getEventId());
+        lqw.eq(StringUtils.isNotBlank(bo.getStatus()), GameBibTask::getStatus, bo.getStatus());
+        lqw.orderByDesc(GameBibTask::getCreateTime);
+        return lqw;
+    }
+
+    /**
+     * 新增参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否新增成功
+     */
+    @Override
+    public Boolean insertByBo(GameBibTaskBo bo) {
+        GameBibTask add = MapstructUtils.convert(bo, GameBibTask.class);
+        validEntityBeforeSave(add);
+        boolean flag = baseMapper.insert(add) > 0;
+        if (flag) {
+            bo.setTaskId(add.getTaskId());
+        }
+        return flag;
+    }
+
+    /**
+     * 修改参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否修改成功
+     */
+    @Override
+    public Boolean updateByBo(GameBibTaskBo bo) {
+        GameBibTask update = MapstructUtils.convert(bo, GameBibTask.class);
+        validEntityBeforeSave(update);
+        return baseMapper.updateById(update) > 0;
+    }
+
+    /**
+     * 保存前的数据校验
+     */
+    private void validEntityBeforeSave(GameBibTask entity) {
+        // TODO 做一些数据校验,如唯一约束
+    }
+
+    /**
+     * 校验并批量删除参赛证生成任务信息
+     *
+     * @param ids     待删除的主键集合
+     * @param isValid 是否进行有效性校验
+     * @return 是否删除成功
+     */
+    @Override
+    public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
+        if (isValid) {
+            // TODO 做一些业务上的校验,判断是否需要校验
+        }
+        return baseMapper.deleteByIds(ids) > 0;
+    }
+
+    /**
+     * 创建参赛证生成任务
+     *
+     * @param taskName 任务名称
+     * @param eventId  赛事ID
+     * @param bgImage  背景图片
+     * @param logo     Logo图片
+     * @param bibParam 参赛证参数
+     * @return 任务ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long createTask(String taskName, Long eventId, MultipartFile bgImage,
+                          MultipartFile logo, GenerateBibBo bibParam) {
+        try {
+            // 保存图片文件
+            String bgImagePath = saveImageFile(bgImage, "bg_" + System.currentTimeMillis());
+            String logoImagePath = null;
+            if (logo != null && !logo.isEmpty()) {
+                logoImagePath = saveImageFile(logo, "logo_" + System.currentTimeMillis());
+            }
+
+            return createTaskWithImagePaths(taskName, eventId, bgImagePath, logoImagePath, bibParam);
+        } catch (Exception e) {
+            log.error("创建参赛证生成任务失败", e);
+            throw new RuntimeException("创建任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 创建参赛证生成任务(使用已保存的图片路径)
+     *
+     * @param taskName      任务名称
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     * @return 任务ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long createTaskWithImagePaths(String taskName, Long eventId, String bgImagePath,
+                                        String logoImagePath, GenerateBibBo bibParam) {
+        try {
+            // 创建任务记录
+            GameBibTaskBo taskBo = new GameBibTaskBo();
+            taskBo.setTaskName(taskName);
+            taskBo.setEventId(eventId);
+            taskBo.setStatus("0"); // 运行中
+            taskBo.setProgress(0);
+            taskBo.setTotalCount(0);
+            taskBo.setCompletedCount(0);
+            taskBo.setBgImagePath(bgImagePath);
+            taskBo.setLogoImagePath(logoImagePath);
+            taskBo.setBibParams(objectMapper.writeValueAsString(bibParam));
+
+            boolean success = insertByBo(taskBo);
+            if (success) {
+                return taskBo.getTaskId();
+            }
+            return null;
+        } catch (Exception e) {
+            log.error("创建参赛证生成任务失败", e);
+            throw new RuntimeException("创建任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 保存图片文件
+     *
+     * @param file     图片文件
+     * @param fileName 文件名
+     * @return 保存后的文件路径
+     */
+    @Override
+    public String saveImageFile(MultipartFile file, String fileName) {
+        try {
+            String imagePath = fileUploadConfig.getBibImagePath();
+            String originalFilename = file.getOriginalFilename();
+            if (originalFilename == null || !originalFilename.contains(".")) {
+                throw new RuntimeException("文件名无效: " + originalFilename);
+            }
+            
+            String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
+            String filePath = imagePath + fileName + extension;
+
+            File destFile = new File(filePath);
+            file.transferTo(destFile);
+
+            return filePath;
+        } catch (Exception e) {
+            log.error("保存图片文件失败", e);
+            throw new RuntimeException("保存图片文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 更新任务状态
+     *
+     * @param taskId       任务ID
+     * @param status       状态
+     * @param errorMessage 错误信息
+     */
+    @Override
+    public void updateTaskStatus(Long taskId, String status, String errorMessage) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setStatus(status);
+        updateBo.setErrorMessage(errorMessage);
+        if ("2".equals(status) || "3".equals(status)) {
+            updateBo.setFinishTime(Date.from(Instant.now()));
+        }
+        updateByBo(updateBo);
+    }
+
+    /**
+     * 更新任务进度
+     *
+     * @param taskId         任务ID
+     * @param progress       进度
+     * @param completedCount 已完成数量
+     */
+    @Override
+    public void updateTaskProgress(Long taskId, Integer progress, Integer completedCount) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setProgress(progress);
+        updateBo.setCompletedCount(completedCount);
+        updateByBo(updateBo);
+    }
+
+    /**
+     * 完成任务
+     *
+     * @param taskId         任务ID
+     * @param resultFilePath 结果文件路径
+     */
+    @Override
+    public void completeTask(Long taskId, String resultFilePath) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setStatus("2"); // 完成
+        updateBo.setProgress(100);
+        updateBo.setResultFilePath(resultFilePath);
+        updateBo.setFinishTime(Date.from(Instant.now()));
+        updateByBo(updateBo);
+    }
+
+    /**
+     * 暂停任务
+     *
+     * @param taskId 任务ID
+     */
+    @Override
+    public void pauseTask(Long taskId) {
+        updateTaskStatus(taskId, "1", null); // 暂停
+    }
+
+    /**
+     * 删除任务
+     *
+     * @param taskId 任务ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteTask(Long taskId) {
+        // 删除相关文件
+        GameBibTaskVO task = queryById(taskId);
+        if (task != null) {
+            deleteFileIfExists(task.getBgImagePath());
+            deleteFileIfExists(task.getLogoImagePath());
+            deleteFileIfExists(task.getResultFilePath());
+        }
+
+        // 删除任务记录
+        baseMapper.deleteById(taskId);
+    }
+
+    /**
+     * 下载任务结果
+     *
+     * @param taskId   任务ID
+     * @param response 响应对象
+     */
+    @Override
+    public void downloadTaskResult(Long taskId, jakarta.servlet.http.HttpServletResponse response) {
+        GameBibTaskVO task = queryById(taskId);
+        if (task == null || StringUtils.isBlank(task.getResultFilePath())) {
+            log.error("任务不存在或结果文件不存在,taskId: {}, task: {}", taskId, task);
+            throw new RuntimeException("任务不存在或结果文件不存在");
+        }
+
+        File file = new File(task.getResultFilePath());
+        log.info("尝试下载文件,taskId: {}, 文件路径: {}, 文件存在: {}", taskId, task.getResultFilePath(), file.exists());
+        if (!file.exists()) {
+            log.error("结果文件不存在,taskId: {}, 文件路径: {}", taskId, task.getResultFilePath());
+            throw new RuntimeException("结果文件不存在");
+        }
+
+        try {
+            response.setContentType("application/zip");
+            response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
+
+            try (FileInputStream fis = new FileInputStream(file);
+                 OutputStream os = response.getOutputStream()) {
+                byte[] buffer = new byte[1024];
+                int length;
+                while ((length = fis.read(buffer)) != -1) {
+                    os.write(buffer, 0, length);
+                }
+                os.flush();
+            }
+        } catch (IOException e) {
+            log.error("下载任务结果失败", e);
+            throw new RuntimeException("下载失败: " + e.getMessage());
+        }
+    }
+
+
+    /**
+     * 删除文件(如果存在)
+     */
+    private void deleteFileIfExists(String filePath) {
+        if (StringUtils.isNotBlank(filePath)) {
+            File file = new File(filePath);
+            if (file.exists()) {
+                file.delete();
+            }
+        }
+    }
+}

+ 1 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventConfigServiceImpl.java

@@ -93,7 +93,7 @@ public class GameEventConfigServiceImpl implements IGameEventConfigService {
         LambdaQueryWrapper<GameEventConfig> lqw = Wrappers.lambdaQuery();
         lqw.orderByAsc(GameEventConfig::getConfigId);
         lqw.eq(bo.getEventId() != null, GameEventConfig::getEventId, bo.getEventId());
-        lqw.eq(StringUtils.isNotBlank(bo.getConfigType()), GameEventConfig::getConfigType, bo.getConfigType());
+        lqw.like(StringUtils.isNotBlank(bo.getConfigType()), GameEventConfig::getConfigType, bo.getConfigType());
         lqw.like(StringUtils.isNotBlank(bo.getConfigDesc()), GameEventConfig::getConfigDesc, bo.getConfigDesc());
         lqw.eq(StringUtils.isNotBlank(bo.getIsEnabled()), GameEventConfig::getIsEnabled, bo.getIsEnabled());
         lqw.eq(StringUtils.isNotBlank(bo.getStatus()), GameEventConfig::getStatus, bo.getStatus());

+ 2 - 2
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventConfigTypeServiceImpl.java

@@ -76,9 +76,9 @@ public class GameEventConfigTypeServiceImpl implements IGameEventConfigTypeServi
         Map<String, Object> params = bo.getParams();
         LambdaQueryWrapper<GameEventConfigType> lqw = Wrappers.lambdaQuery();
         lqw.orderByAsc(GameEventConfigType::getTypeId);
-        lqw.eq(StringUtils.isNotBlank(bo.getTypeCode()), GameEventConfigType::getTypeCode, bo.getTypeCode());
+        lqw.like(StringUtils.isNotBlank(bo.getTypeCode()), GameEventConfigType::getTypeCode, bo.getTypeCode());
         lqw.like(StringUtils.isNotBlank(bo.getTypeName()), GameEventConfigType::getTypeName, bo.getTypeName());
-        lqw.eq(StringUtils.isNotBlank(bo.getTypeDesc()), GameEventConfigType::getTypeDesc, bo.getTypeDesc());
+        lqw.like(StringUtils.isNotBlank(bo.getTypeDesc()), GameEventConfigType::getTypeDesc, bo.getTypeDesc());
         lqw.eq(StringUtils.isNotBlank(bo.getSortField()), GameEventConfigType::getSortField, bo.getSortField());
         lqw.eq(StringUtils.isNotBlank(bo.getIsEnabled()), GameEventConfigType::getIsEnabled, bo.getIsEnabled());
         lqw.eq(StringUtils.isNotBlank(bo.getStatus()), GameEventConfigType::getStatus, bo.getStatus());

+ 210 - 12
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventProjectServiceImpl.java

@@ -1,7 +1,6 @@
 package org.dromara.system.service.impl;
 
 import cn.hutool.core.util.ObjectUtil;
-import cn.hutool.core.util.StrUtil;
 import cn.hutool.json.JSONUtil;
 import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
 import org.dromara.common.core.utils.MapstructUtils;
@@ -14,26 +13,25 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.dromara.common.redis.utils.RedisUtils;
-import org.dromara.system.domain.GameAthlete;
-import org.dromara.system.domain.GameEvent;
-import org.dromara.system.domain.bo.GameEventBo;
+import org.dromara.system.domain.*;
+import org.dromara.system.domain.bo.GameAthleteBo;
 import org.dromara.system.domain.constant.GameEventConstant;
-import org.dromara.system.domain.vo.GameEventVo;
 import org.dromara.system.domain.vo.SysDictDataVo;
-import org.dromara.system.mapper.GameEventMapper;
-import org.dromara.system.service.IGameEventService;
+import org.dromara.system.mapper.*;
+import org.dromara.system.service.IGameAthleteService;
+import org.dromara.system.service.IGameRefereeService;
 import org.dromara.system.service.ISysDictTypeService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.dromara.system.domain.bo.GameEventProjectBo;
+import org.dromara.system.domain.bo.GameRefereeBo;
 import org.dromara.system.domain.vo.GameEventProjectVo;
-import org.dromara.system.domain.GameEventProject;
-import org.dromara.system.mapper.GameEventProjectMapper;
 import org.dromara.system.service.IGameEventProjectService;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.*;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 
 /**
  * 赛事项目Service业务层处理
@@ -42,13 +40,35 @@ import java.util.stream.Stream;
  * @date 2025-07-30
  */
 @Slf4j
-@RequiredArgsConstructor
+//@RequiredArgsConstructor
 @Service
 public class GameEventProjectServiceImpl implements IGameEventProjectService {
 
     private final GameEventProjectMapper baseMapper;
     private final GameEventMapper gameEventMapper;
     private final ISysDictTypeService sysDictTypeService;
+    private final GameTeamMapper gameTeamMapper;
+    private final GameAthleteMapper gameAthleteMapper;
+    private final GameEventGroupMapper gameEventGroupMapper;
+
+    @Autowired
+    @Lazy
+    private IGameRefereeService gameRefereeService;
+
+    @Autowired
+    @Lazy
+    private IGameAthleteService gameAthleteService;
+
+    public GameEventProjectServiceImpl(GameEventProjectMapper baseMapper, GameEventMapper gameEventMapper,
+        ISysDictTypeService sysDictTypeService, GameTeamMapper gameTeamMapper, GameAthleteMapper gameAthleteMapper,
+        GameEventGroupMapper gameEventGroupMapper) {
+        this.baseMapper = baseMapper;
+        this.gameEventMapper = gameEventMapper;
+        this.sysDictTypeService = sysDictTypeService;
+        this.gameTeamMapper = gameTeamMapper;
+        this.gameAthleteMapper = gameAthleteMapper;
+        this.gameEventGroupMapper = gameEventGroupMapper;
+    }
 
     /**
      * 查询赛事项目
@@ -59,7 +79,14 @@ public class GameEventProjectServiceImpl implements IGameEventProjectService {
     @Override
     public GameEventProjectVo queryById(Long projectId) {
         GameEventProjectVo vo = baseMapper.selectVoById(projectId);
-        vo.setRefereeGroups(JSONUtil.toList(vo.getRefereeGroup(), Long.class));
+        if (vo == null) {
+            return null;
+        }
+        if (vo.getRefereeGroup() != null && !"".equals(vo.getRefereeGroup())){
+            vo.setRefereeGroups(JSONUtil.toList(vo.getRefereeGroup(), Long.class));
+        }else{
+            vo.setRefereeGroups(new ArrayList<>());
+        }
         return vo;
     }
 
@@ -186,6 +213,74 @@ public class GameEventProjectServiceImpl implements IGameEventProjectService {
         return list;
     }
 
+    /**
+     * 计算项目的参赛人数
+     * @param projectId 项目ID
+     * @return 参赛人数
+     */
+    private Long calculateParticipateNum(Long projectId) {
+        if (projectId == null) {
+            return 0L;
+        }
+
+        // 查询所有运动员,筛选出参与该项目的运动员
+        List<GameAthlete> allAthletes = gameAthleteMapper.selectList(
+            Wrappers.lambdaQuery(GameAthlete.class)
+                .select(GameAthlete::getProjectValue)
+        );
+
+        long count = allAthletes.stream()
+            .filter(athlete -> {
+                if (StringUtils.isNotBlank(athlete.getProjectValue())) {
+                    try {
+                        List<Long> projectList = JSONUtil.toList(athlete.getProjectValue(), Long.class);
+                        return projectList.contains(projectId);
+                    } catch (Exception e) {
+                        log.warn("解析运动员项目列表失败: {}", athlete.getProjectValue(), e);
+                        return false;
+                    }
+                }
+                return false;
+            })
+            .count();
+
+        return count;
+    }
+
+    /**
+     * 计算项目的参赛队伍数
+     * @param projectId 项目ID
+     * @return 参赛队伍数
+     */
+    private Long calculateGroupNum(Long projectId) {
+        if (projectId == null) {
+            return 0L;
+        }
+
+        // 查询所有队伍,筛选出参与该项目的队伍
+        List<GameTeam> allTeams = gameTeamMapper.selectList(
+            Wrappers.lambdaQuery(GameTeam.class)
+                .select(GameTeam::getProjectValue)
+        );
+
+        long count = allTeams.stream()
+            .filter(team -> {
+                if (StringUtils.isNotBlank(team.getProjectValue())) {
+                    try {
+                        List<Long> projectList = JSONUtil.toList(team.getProjectValue(), Long.class);
+                        return projectList.contains(projectId);
+                    } catch (Exception e) {
+                        log.warn("解析队伍项目列表失败: {}", team.getProjectValue(), e);
+                        return false;
+                    }
+                }
+                return false;
+            })
+            .count();
+
+        return count;
+    }
+
     private LambdaQueryWrapper<GameEventProject> buildQueryWrapper(GameEventProjectBo bo) {
         Map<String, Object> params = bo.getParams();
         LambdaQueryWrapper<GameEventProject> lqw = Wrappers.lambdaQuery();
@@ -348,10 +443,113 @@ public class GameEventProjectServiceImpl implements IGameEventProjectService {
     public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
         if (isValid) {
             //TODO 做一些业务上的校验,判断是否需要校验
+
         }
+        //删除项目前批量删除裁判的关联信息
+        removeProjectFromReferees(ids);
+        //删除项目前批量删除运动员的关联信息
+        removeProjectFromAthletes(ids);
+        //删除项目前批量删除分组表game_event_group中的关联信息
+        removeProjectFromGroups(ids);
         return baseMapper.deleteByIds(ids) > 0;
     }
 
+    /**
+     * 从所有运动员的项目列表中移除指定项目
+     *
+     * @param projectIds 要移除的项目ID列表
+     */
+    private void removeProjectFromAthletes(Collection<Long> projectIds) {
+        try {
+            if (CollectionUtils.isEmpty(projectIds)) {
+                return;
+            }
+
+            // 查询所有包含这些项目的运动员
+            List<GameAthleteBo> athletes = gameAthleteService.findByProjectIds(projectIds);
+
+            log.info("找到 {} 名运动员需要更新项目关联,项目ID列表: {}", athletes.size(), projectIds);
+
+            for (GameAthleteBo athlete : athletes) {
+                for (Long projectId : projectIds){
+                    if (athlete.getProjectList() != null) {
+                        athlete.getProjectList().remove(projectId);
+                        log.info("已从运动员 {} 的项目列表中移除项目 {}", athlete.getName(), projectId);
+                    }
+                }
+                gameAthleteService.updateByBo(athlete);
+            }
+
+            log.info("运动员项目关联清理完成,共处理 {} 名运动员", athletes.size());
+        } catch (Exception e) {
+            log.error("清理运动员项目关联失败,项目ID列表: {}", projectIds, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 从所有裁判的项目列表中移除指定项目
+     *
+     * @param ids 要移除的项目ID集合
+     */
+    private void removeProjectFromReferees(Collection<Long> ids) {
+        try {
+            // 查询所有包含该项目的裁判
+            List<GameRefereeBo> referees = gameRefereeService.findByProjectIds(ids);
+
+            for (GameRefereeBo referee : referees) {
+                for (Long projectId : ids) {
+                    if (referee.getProjectList2() != null) {
+                        // 从项目列表中移除该项目
+                        referee.getProjectList2().remove(projectId);
+                        log.info("已从裁判 {} 的项目列表中移除项目 {}", referee.getName(), projectId);
+                    }
+                }
+                gameRefereeService.updateByBo(referee);
+            }
+        } catch (Exception e) {
+            log.error("从裁判项目列表中移除项目失败,项目ID: {}", ids, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 从所有分组中移除指定项目的关联信息
+     *
+     * @param projectIds 要移除的项目ID集合
+     */
+    private void removeProjectFromGroups(Collection<Long> projectIds) {
+        try {
+            if (CollectionUtils.isEmpty(projectIds)) {
+                return;
+            }
+
+            // 查询所有包含这些项目的分组
+            List<GameEventGroup> groups = gameEventGroupMapper.selectList(
+                Wrappers.lambdaQuery(GameEventGroup.class)
+                    .in(GameEventGroup::getProjectId, projectIds)
+            );
+
+            log.info("找到 {} 个分组需要删除,项目ID列表: {}", groups.size(), projectIds);
+
+            if (CollectionUtils.isNotEmpty(groups)) {
+                // 提取分组ID列表
+                List<Long> groupIds = groups.stream()
+                    .map(GameEventGroup::getGroupId)
+                    .collect(Collectors.toList());
+
+                // 批量删除分组
+                int deletedCount = gameEventGroupMapper.deleteBatchIds(groupIds);
+                log.info("成功删除 {} 个分组,分组ID列表: {}", deletedCount, groupIds);
+            }
+
+            log.info("分组关联清理完成,共处理 {} 个分组", groups.size());
+        } catch (Exception e) {
+            log.error("清理分组关联失败,项目ID列表: {}", projectIds, e);
+            throw e;
+        }
+    }
+
     @Override
     public List<GameEventProjectVo> listProjectsByEventIdAndProjectIndex(Long eventId, List<Long> projectIds) {
         if (CollectionUtils.isEmpty(projectIds)) {

+ 775 - 32
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventServiceImpl.java

@@ -41,17 +41,25 @@ import org.dromara.system.domain.bo.*;
 import org.dromara.system.domain.constant.GameEventConstant;
 import org.dromara.system.domain.vo.*;
 import org.dromara.system.mapper.GameEventMapper;
+import org.dromara.system.config.FileUploadConfig;
 import org.dromara.system.service.*;
 import org.springframework.context.annotation.Lazy;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.Color;
+import java.awt.Font;
 import java.awt.image.BufferedImage;
 import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.util.*;
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -81,6 +89,10 @@ public class GameEventServiceImpl implements IGameEventService {
     private IGameEventProjectService gameEventProjectService;
     @Resource
     private IGameEventGroupService gameEventGroupService;
+    @Resource
+    private IGameBibTaskService gameBibTaskService;
+    @Resource
+    private FileUploadConfig fileUploadConfig;
     private static final ExecutorService PDF_GENERATION_EXECUTOR =
         Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2);
     // 常见图片类型的文件头(前几个字节)
@@ -92,6 +104,11 @@ public class GameEventServiceImpl implements IGameEventService {
         "4D4D002A", // TIFF (big endian)
         "424D"      // BMP
     };
+
+    // 预览框尺寸(固定)
+    private static final int previewWidth = 600;
+    private static final int previewHeight = 400;
+
     /**
      * 查询赛事基本信息
      *
@@ -133,7 +150,7 @@ public class GameEventServiceImpl implements IGameEventService {
         Map<String, Object> params = bo.getParams();
         LambdaQueryWrapper<GameEvent> lqw = Wrappers.lambdaQuery();
         lqw.orderByAsc(GameEvent::getEventId);
-        lqw.eq(StringUtils.isNotBlank(bo.getEventCode()), GameEvent::getEventCode, bo.getEventCode());
+        lqw.like(StringUtils.isNotBlank(bo.getEventCode()), GameEvent::getEventCode, bo.getEventCode());
         lqw.like(StringUtils.isNotBlank(bo.getEventName()), GameEvent::getEventName, bo.getEventName());
         lqw.eq(StringUtils.isNotBlank(bo.getEventType()), GameEvent::getEventType, bo.getEventType());
         lqw.eq(StringUtils.isNotBlank(bo.getLocation()), GameEvent::getLocation, bo.getLocation());
@@ -591,8 +608,9 @@ public class GameEventServiceImpl implements IGameEventService {
         //4.查询赛事组别
         GameEventGroup gameEventGroup = gameEventGroupService.queryByEventId(defaultEventId);
         //5.根据参数生成号码布
-        GameEventVo eventVo = baseMapper.selectVoById(defaultEventId);
-        generateBib(response, bgImage, logo, eventVo.getEventName(), gameEventGroup.getGroupName(), athleteVoList, teamNameMap, projectMap, bibParam);
+//        GameEventVo eventVo = baseMapper.selectVoById(defaultEventId);
+//        generateBib(response, bgImage, logo, eventVo.getEventName(), gameEventGroup.getGroupName(), athleteVoList, teamNameMap, projectMap, bibParam);
+        generateBib(response, bgImage, logo, bibParam.getEventName(), gameEventGroup.getGroupName(), athleteVoList, teamNameMap, projectMap, bibParam);
     }
 
     /**
@@ -665,7 +683,21 @@ public class GameEventServiceImpl implements IGameEventService {
             backgroundImageBytes = backgroundImage.getBytes();
 
             if (logo != null && !logo.isEmpty()) {
+                // 验证logo文件类型
+                String contentType = logo.getContentType();
+                if (contentType == null || !contentType.startsWith("image/")) {
+                    throw new IllegalArgumentException("Logo文件必须是图片格式");
+                }
+
+                // 验证logo文件大小(例如限制为2MB)
+                // if (logo.getSize() > 2 * 1024 * 1024) {
+                //     throw new IllegalArgumentException("Logo文件大小不能超过2MB");
+                // }
+
                 logoImageBytes = logo.getBytes();
+//                log.info("Logo图片上传成功,大小: {} bytes", logoImageBytes.length);
+            } else {
+                log.info("未上传Logo图片,将跳过Logo处理");
             }
         } catch (IOException e) {
             throw new RuntimeException("读取上传文件失败", e);
@@ -716,45 +748,137 @@ public class GameEventServiceImpl implements IGameEventService {
 
                         // 添加 Logo(如果存在)
                         if (finalLogoImageBytes != null && logoX != null && logoY != null) {
-                            Image img = Image.getInstance(finalLogoImageBytes);
-                            img.scaleToFit(80, 80);
-                            float logoPositionX = logoX.floatValue();
-                            float logoPositionY = logoY.floatValue();
-                            img.setAbsolutePosition(logoPositionX, logoPositionY);
-                            cb.addImage(img);
+                            try {
+                                Image img = Image.getInstance(finalLogoImageBytes);
+                                // 应用缩放参数
+                                Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+                                img.scaleToFit(80 * logoScale.floatValue(), 80 * logoScale.floatValue());
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float logoPositionX = (float) (logoX * pageWidth / 100.0);
+                                float logoPositionY = (float) (pageHeight - (logoY * pageHeight / 100.0));
+                                img.setAbsolutePosition(logoPositionX, logoPositionY);
+                                cb.addImage(img);
+//                                log.debug("成功添加Logo到PDF");
+                            } catch (Exception e) {
+                                log.warn("添加Logo到PDF失败,跳过Logo处理: {}", e.getMessage());
+                            }
                         }
 
-                        // 添加号码(居中)
-                        float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), finalFontSize);
-                        float textHeight = finalFontSize;
-                        float textPositionX = (pageWidth - textWidth) / 2;
-                        float textPositionY = (pageHeight / 2) + (textHeight / 2);
-                        addText(cb, baseFont, finalFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
+                        // 添加号码(使用前端传递的位置参数)
+                        Double numberScale = bibParam.getNumberScale() != null ? bibParam.getNumberScale() : 1.0;
+                        int scaledFontSize = (int) (finalFontSize * numberScale.floatValue());
+                        float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), scaledFontSize);
+                        float textHeight = scaledFontSize;
+
+                        // 使用前端传递的位置参数(百分比转换为像素)
+                        float textPositionX, textPositionY;
+                        if (bibParam.getNumberX() != null && bibParam.getNumberY() != null) {
+                            textPositionX = (pageWidth * bibParam.getNumberX().floatValue() / 100) - (textWidth / 2);
+                            // PDF Y轴从下到上递增,需要翻转Y坐标
+                            textPositionY = pageHeight - (pageHeight * bibParam.getNumberY().floatValue() / 100) + (textHeight / 2);
+                        } else {
+                            // 默认居中
+                            textPositionX = (pageWidth - textWidth) / 2;
+                            textPositionY = (pageHeight / 2) + (textHeight / 2);
+                        }
+                        addText(cb, baseFont, scaledFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
+
+                        // 添加赛事名称(使用前端传递的位置参数)
+                        String eventNameToShow = eventName;
+                        if (eventNameToShow == null || eventNameToShow.trim().isEmpty()) {
+                            eventNameToShow = "参赛证"; // 默认赛事名称
+                        }
+                        log.debug("同步生成 - 赛事名称检查 - eventName: '{}', 最终显示: '{}'", eventName, eventNameToShow);
 
-                        // 添加赛事名称(在3:2横屏比例下调整位置)
-                        if (eventName != null && !eventName.trim().isEmpty()) {
-                            int eventNameFontSize = Math.min(64, (int) (pageHeight * 0.08)); // 根据页面高度调整字体大小
+                        if (eventNameToShow != null && !eventNameToShow.trim().isEmpty()) {
+                            Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
+                            int baseEventNameFontSize = Math.min(64, (int) (pageHeight * 0.08));
+                            int eventNameFontSize = (int) (baseEventNameFontSize * eventScale.floatValue());
                             cb.beginText();
                             cb.setFontAndSize(baseFont, eventNameFontSize);
                             cb.setColorFill(BaseColor.BLACK);
-                            float textWidth2 = baseFont.getWidthPoint(eventName, eventNameFontSize);
-                            float textX = (pageWidth - textWidth2) / 2;
-                            float textY = pageHeight - eventNameFontSize - 20; // 增加边距
+                            float textWidth2 = baseFont.getWidthPoint(eventNameToShow, eventNameFontSize);
+
+                            // 使用前端传递的位置参数(百分比转换为像素)
+                            float textX, textY;
+                            if (bibParam.getEventX() != null && bibParam.getEventY() != null) {
+                                textX = (pageWidth * bibParam.getEventX().floatValue() / 100) - (textWidth2 / 2);
+                                // PDF Y轴从下到上递增,需要翻转Y坐标
+                                textY = pageHeight - (pageHeight * bibParam.getEventY().floatValue() / 100) + (eventNameFontSize / 2);
+                            } else {
+                                // 默认位置(顶部居中)
+                                textX = (pageWidth - textWidth2) / 2;
+                                textY = pageHeight - eventNameFontSize - 20;
+                            }
                             cb.setTextMatrix(textX, textY);
-                            cb.showText(eventName);
+                            cb.showText(eventNameToShow);
                             cb.endText();
                         }
 
-                        // 生成二维码(在3:2横屏比例下调整尺寸)
+                        // 生成二维码(使用前端传递的位置和缩放参数
                         if (qRCodeX != null && qRCodeY != null) {
-                            String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
-                            int qrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15)); // 根据页面尺寸调整二维码大小
-                            byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
-                            Image qrImage = Image.getInstance(qrBytes);
-                            float qrX = qRCodeX.floatValue();
-                            float qrY = qRCodeY.floatValue();
-                            qrImage.setAbsolutePosition(qrX, qrY);
-                            cb.addImage(qrImage);
+                            try {
+                                String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
+                                Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
+                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float qrX = (float) (qRCodeX * pageWidth / 100.0);
+                                float qrY = (float) (pageHeight - (qRCodeY * pageHeight / 100.0));
+
+                                // 检查坐标是否在页面范围内
+                                if (qrX >= 0 && qrY >= 0 && qrX + qrSize <= pageWidth && qrY + qrSize <= pageHeight) {
+                                    log.debug("同步生成二维码 - 位置: ({}, {}), 大小: {}, 数据: {}",
+                                        qrX, qrY, qrSize, qrDataStr);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    qrImage.setAbsolutePosition(qrX, qrY);
+                                    cb.addImage(qrImage);
+                                } else {
+                                    log.warn("同步生成 - 二维码位置超出页面范围 - 位置: ({}, {}), 大小: {}, 页面尺寸: {}x{}",
+                                        qrX, qrY, qrSize, pageWidth, pageHeight);
+                                    // 使用默认位置(右下角)
+                                    float defaultX = pageWidth - qrSize - 20;
+                                    float defaultY = 20;
+                                    log.debug("同步生成 - 使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    qrImage.setAbsolutePosition(defaultX, defaultY);
+                                    cb.addImage(qrImage);
+                                }
+
+                                log.debug("二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("生成二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
+                        } else {
+                            log.warn("同步生成 - 二维码位置参数为空 - 运动员: {}, qRCodeX: {}, qRCodeY: {}",
+                                athlete.getName(), qRCodeX, qRCodeY);
+
+                            // 即使位置参数为空,也生成二维码在默认位置
+                            try {
+                                String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
+                                Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
+                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 使用默认位置(右下角)
+                                float defaultX = pageWidth - qrSize - 20;
+                                float defaultY = 20;
+                                log.debug("同步生成 - 使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                Image qrImage = Image.getInstance(qrBytes);
+                                qrImage.setAbsolutePosition(defaultX, defaultY);
+                                cb.addImage(qrImage);
+
+                                log.debug("同步生成 - 默认位置二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("同步生成 - 生成默认位置二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
                         }
 
                         document.close();
@@ -779,7 +903,7 @@ public class GameEventServiceImpl implements IGameEventService {
                 .toList();
 
             // 4. 设置响应头,开始写 ZIP
-            response.setContentType("application/zip");
+//            response.setContentType("application/zip");
             response.setHeader("Content-Disposition", "attachment; filename=\"athlete_bibs.zip\"");
 
             // 5. 写入 ZIP 文件
@@ -888,6 +1012,10 @@ public class GameEventServiceImpl implements IGameEventService {
         // 方式1:使用系统字体(Windows)
         // return BaseFont.createFont("C:/Windows/Fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
         String filePath;
+        // 处理null值,使用默认字体
+        if (fontName == null) {
+            fontName = "yahei";
+        }
         switch (fontName) {
             case "simhei" -> filePath = "fonts/simhei.ttf";
             case "simsun" -> filePath = "fonts/simsun.ttf";
@@ -1124,4 +1252,619 @@ public class GameEventServiceImpl implements IGameEventService {
         // 如果组别时间不存在,返回项目时间
         return earliestGroupTime != null ? earliestGroupTime : project.getStartTime();
     }
+
+    /**
+     * 异步生成参赛证
+     *
+     * @param taskId        任务ID
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     */
+    @Override
+    @Async
+    public void generateNumberBibAsync(Long taskId, Long eventId, String bgImagePath,
+                                     String logoImagePath, GenerateBibBo bibParam) {
+        try {
+            // 更新任务状态为运行中
+            gameBibTaskService.updateTaskStatus(taskId, "0", null);
+
+            // 查询运动员数据
+            GameAthleteBo gameAthleteBo = new GameAthleteBo();
+            gameAthleteBo.setEventId(eventId);
+            List<GameAthleteVo> athleteVoList = gameAthleteService.queryList(gameAthleteBo);
+
+            if (CollectionUtil.isEmpty(athleteVoList)) {
+                gameBibTaskService.updateTaskStatus(taskId, "3", "当前赛事没有队员数据");
+                return;
+            }
+
+            // 更新总数量
+            gameBibTaskService.updateTaskProgress(taskId, 0, 0);
+
+            // 生成参赛证文件
+            String resultFilePath = generateBibFilesAsync(taskId, eventId, bgImagePath, logoImagePath, bibParam, athleteVoList);
+
+            // 完成任务
+            gameBibTaskService.completeTask(taskId, resultFilePath);
+
+        } catch (Exception e) {
+            log.error("参赛证生成失败,taskId: {}", taskId, e);
+            gameBibTaskService.updateTaskStatus(taskId, "3", e.getMessage());
+        }
+    }
+
+    /**
+     * 异步生成参赛证文件
+     */
+    private String generateBibFilesAsync(Long taskId, Long eventId, String bgImagePath, String logoImagePath,
+                                       GenerateBibBo bibParam, List<GameAthleteVo> athleteVoList) {
+        try {
+            // 创建结果目录
+            String resultDir = fileUploadConfig.getBibResultPath() + taskId + File.separator;
+            File resultDirFile = new File(resultDir);
+            if (!resultDirFile.exists()) {
+                boolean created = resultDirFile.mkdirs();
+                if (!created) {
+                    throw new RuntimeException("无法创建结果目录: " + resultDirFile.getAbsolutePath());
+                }
+            }
+
+            // 图片路径已经传入,直接使用
+
+            // 查询队伍名称缓存
+            Set<Long> teamIds = athleteVoList.stream()
+                .map(GameAthleteVo::getTeamId)
+                .collect(Collectors.toSet());
+            Map<Long, String> teamNameMap = gameTeamService.queryTeamIdAndName(teamIds);
+
+            // 查询赛事所有项目缓存
+            Map<Long, String> projectMap = gameEventProjectService.queryListByEventId(eventId)
+                .stream().collect(Collectors.toMap(GameEventProjectVo::getProjectId, GameEventProjectVo::getProjectName));
+
+            // 查询赛事组别
+            GameEventGroup gameEventGroup = gameEventGroupService.queryByEventId(eventId);
+
+            // 生成PDF文件
+            List<PdfEntry> pdfEntries = generatePdfEntries(bgImagePath, logoImagePath, bibParam, athleteVoList, teamNameMap, projectMap, gameEventGroup.getGroupName());
+
+            // 创建ZIP文件
+            String zipFilePath = resultDir + "athlete_bibs.zip";
+            try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFilePath))) {
+                for (PdfEntry entry : pdfEntries) {
+                    zos.putNextEntry(new ZipEntry(entry.getFileName()));
+                    zos.write(entry.getPdfBytes());
+                    zos.closeEntry();
+                }
+                zos.flush();
+            }
+
+            return zipFilePath;
+
+        } catch (Exception e) {
+            log.error("生成参赛证文件失败,taskId: {}", taskId, e);
+            throw new RuntimeException("生成参赛证文件失败: " + e.getMessage());
+        }
+    }
+
+
+    /**
+     * 生成PDF条目列表
+     */
+    private List<PdfEntry> generatePdfEntries(String bgImagePath, String logoImagePath,
+                                            GenerateBibBo bibParam, List<GameAthleteVo> athleteVoList,
+                                            Map<Long, String> teamNameMap, Map<Long, String> projectMap,
+                                            String groupName) {
+        try {
+            // 从文件路径读取图片
+            byte[] backgroundImageBytes = Files.readAllBytes(Paths.get(bgImagePath));
+            byte[] logoImageBytes = logoImagePath != null ? Files.readAllBytes(Paths.get(logoImagePath)) : null;
+
+            // 读取背景图
+            Image bgImageObj = Image.getInstance(backgroundImageBytes);
+            float pageWidth = bgImageObj.getWidth();
+            float pageHeight = bgImageObj.getHeight();
+
+            // 设置字体和颜色,提供默认值
+            String fontName = bibParam.getFontName() != null ? bibParam.getFontName() : "yahei";
+            BaseFont baseFont = getChineseFont(fontName);
+            BaseColor textColor = parseColor(bibParam.getFontColor());
+            Integer fontSize = bibParam.getFontSize() != null ? bibParam.getFontSize() : 14;
+
+            // 并行生成所有PDF
+            List<CompletableFuture<PdfEntry>> pdfFutures = athleteVoList.parallelStream()
+                .map(athlete -> CompletableFuture.supplyAsync(() -> {
+                    try {
+                        ByteArrayOutputStream pdfStream = new ByteArrayOutputStream();
+                        Document document = new Document(new Rectangle(pageWidth, pageHeight));
+                        PdfWriter writer = PdfWriter.getInstance(document, pdfStream);
+                        document.open();
+                        PdfContentByte cb = writer.getDirectContent();
+
+                        // 添加背景图
+                        Image bg = Image.getInstance(backgroundImageBytes);
+                        bg.setAbsolutePosition(0, 0);
+                        document.add(bg);
+
+                        // 添加Logo(如果存在)
+                        if (logoImageBytes != null && bibParam.getLogoX() != null && bibParam.getLogoY() != null) {
+                            try {
+                                Image img = Image.getInstance(logoImageBytes);
+                                // 应用缩放参数
+                                Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+                                img.scaleToFit(80 * logoScale.floatValue(), 80 * logoScale.floatValue());
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float logoPositionX = (float) (bibParam.getLogoX() * pageWidth / 100.0);
+                                float logoPositionY = (float) (pageHeight - (bibParam.getLogoY() * pageHeight / 100.0));
+                                img.setAbsolutePosition(logoPositionX, logoPositionY);
+                                cb.addImage(img);
+                            } catch (Exception e) {
+                                log.warn("添加Logo到PDF失败,跳过Logo处理: {}", e.getMessage());
+                            }
+                        }
+
+                        // 添加号码(使用前端传递的位置参数)
+                        Double numberScale = bibParam.getNumberScale() != null ? bibParam.getNumberScale() : 1.0;
+                        int scaledFontSize = (int) (fontSize * numberScale.floatValue());
+                        float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), scaledFontSize);
+                        float textHeight = scaledFontSize;
+
+                        // 使用前端传递的位置参数(百分比转换为像素)
+                        float textPositionX, textPositionY;
+                        if (bibParam.getNumberX() != null && bibParam.getNumberY() != null) {
+                            textPositionX = (pageWidth * bibParam.getNumberX().floatValue() / 100) - (textWidth / 2);
+                            // PDF Y轴从下到上递增,需要翻转Y坐标
+                            textPositionY = pageHeight - (pageHeight * bibParam.getNumberY().floatValue() / 100) + (textHeight / 2);
+                        } else {
+                            // 默认居中
+                            textPositionX = (pageWidth - textWidth) / 2;
+                            textPositionY = (pageHeight / 2) + (textHeight / 2);
+                        }
+                        addText(cb, baseFont, scaledFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
+
+                        // 添加赛事名称(使用前端传递的位置参数)
+                        String eventNameToShow = bibParam.getEventName();
+                        if (eventNameToShow == null || eventNameToShow.trim().isEmpty()) {
+                            eventNameToShow = "参赛证"; // 默认赛事名称
+                        }
+                        log.debug("赛事名称检查 - eventName: '{}', 最终显示: '{}'", bibParam.getEventName(), eventNameToShow);
+
+                        if (eventNameToShow != null && !eventNameToShow.trim().isEmpty()) {
+                            Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
+                            int baseEventNameFontSize = Math.min(64, (int) (pageHeight * 0.08));
+                            int eventNameFontSize = (int) (baseEventNameFontSize * eventScale.floatValue());
+                            cb.beginText();
+                            cb.setFontAndSize(baseFont, eventNameFontSize);
+                            cb.setColorFill(BaseColor.BLACK);
+                            float textWidth2 = baseFont.getWidthPoint(eventNameToShow, eventNameFontSize);
+
+                            // 使用前端传递的位置参数(百分比转换为像素)
+                            float textX, textY;
+                            if (bibParam.getEventX() != null && bibParam.getEventY() != null) {
+                                textX = (pageWidth * bibParam.getEventX().floatValue() / 100) - (textWidth2 / 2);
+                                // PDF Y轴从下到上递增,需要翻转Y坐标
+                                textY = pageHeight - (pageHeight * bibParam.getEventY().floatValue() / 100) + (eventNameFontSize / 2);
+                            } else {
+                                // 默认位置(顶部居中)
+                                textX = (pageWidth - textWidth2) / 2;
+                                textY = pageHeight - eventNameFontSize - 20;
+                            }
+                            cb.setTextMatrix(textX, textY);
+                            cb.showText(eventNameToShow);
+                            cb.endText();
+                        }
+
+                        // 生成二维码(使用前端传递的位置和缩放参数)
+                        log.debug("二维码参数检查 - qRCodeX: {}, qRCodeY: {}, 是否为空: {}",
+                            bibParam.getQRCodeX(), bibParam.getQRCodeY(),
+                            bibParam.getQRCodeX() == null || bibParam.getQRCodeY() == null);
+                        if (bibParam.getQRCodeX() != null && bibParam.getQRCodeY() != null) {
+                            try {
+                                String qrDataStr = getQrDataStr(bibParam.getEventName(), groupName, teamNameMap, projectMap, athlete);
+                                Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
+                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float qrX = (float) (bibParam.getQRCodeX() * pageWidth / 100.0);
+                                float qrY = (float) (pageHeight - (bibParam.getQRCodeY() * pageHeight / 100.0));
+
+                                // 检查坐标是否在页面范围内
+                                if (qrX >= 0 && qrY >= 0 && qrX + qrSize <= pageWidth && qrY + qrSize <= pageHeight) {
+                                    log.debug("生成二维码 - 位置: ({}, {}), 大小: {}, 数据: {}",
+                                        qrX, qrY, qrSize, qrDataStr);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    // 二维码位置不需要翻转Y轴,因为它是图片元素
+                                    qrImage.setAbsolutePosition(qrX, qrY);
+                                    cb.addImage(qrImage);
+                                } else {
+                                    log.warn("二维码位置超出页面范围 - 位置: ({}, {}), 大小: {}, 页面尺寸: {}x{}",
+                                        qrX, qrY, qrSize, pageWidth, pageHeight);
+                                    // 使用默认位置(右下角)
+                                    float defaultX = pageWidth - qrSize - 20;
+                                    float defaultY = 20;
+                                    log.debug("使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    qrImage.setAbsolutePosition(defaultX, defaultY);
+                                    cb.addImage(qrImage);
+                                }
+
+                                log.debug("二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("生成二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
+                        } else {
+                            log.warn("二维码位置参数为空 - 运动员: {}, qRCodeX: {}, qRCodeY: {}",
+                                athlete.getName(), bibParam.getQRCodeX(), bibParam.getQRCodeY());
+
+                            // 即使位置参数为空,也生成二维码在默认位置
+                            try {
+                                String qrDataStr = getQrDataStr(bibParam.getEventName(), groupName, teamNameMap, projectMap, athlete);
+                                Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
+                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 使用默认位置(右下角)
+                                float defaultX = pageWidth - qrSize - 20;
+                                float defaultY = 20;
+                                log.debug("使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                Image qrImage = Image.getInstance(qrBytes);
+                                qrImage.setAbsolutePosition(defaultX, defaultY);
+                                cb.addImage(qrImage);
+
+                                log.debug("默认位置二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("生成默认位置二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
+                        }
+
+                        document.close();
+                        byte[] pdfBytes = pdfStream.toByteArray();
+
+                        // 构造安全的文件名
+                        String safeName = athlete.getName().replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5]", "_");
+                        String fileName = String.format("%s_%s.pdf", athlete.getAthleteCode(), safeName);
+                        return new PdfEntry(fileName, pdfBytes);
+
+                    } catch (Exception e) {
+                        log.error("生成PDF失败: {} - {}", athlete.getName(), e.getMessage());
+                        throw new RuntimeException("生成PDF失败: " + athlete.getName(), e);
+                    }
+                }, PDF_GENERATION_EXECUTOR))
+                .toList();
+
+            // 等待所有任务完成
+            return pdfFutures.stream()
+                .map(CompletableFuture::join)
+                .toList();
+
+        } catch (Exception e) {
+            log.error("生成PDF条目失败", e);
+            throw new RuntimeException("生成PDF条目失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 基于画布模版生成参赛证(异步)
+     */
+    @Override
+    @Async
+    public void generateBibFromTemplateAsync(Long taskId, Long eventId, byte[] templateImage, GenerateBibBo bibParam) {
+        try {
+            // 更新任务状态为运行中
+            gameBibTaskService.updateTaskStatus(taskId, "0", null);
+
+            // 查询运动员数据
+            GameAthleteBo gameAthleteBo = new GameAthleteBo();
+            gameAthleteBo.setEventId(eventId);
+            List<GameAthleteVo> athleteVoList = gameAthleteService.queryList(gameAthleteBo);
+
+            if (CollectionUtil.isEmpty(athleteVoList)) {
+                gameBibTaskService.updateTaskStatus(taskId, "3", "当前赛事没有队员数据");
+                return;
+            }
+
+            // 更新总数量
+            gameBibTaskService.updateTaskProgress(taskId, 0, 0);
+
+            // 生成参赛证文件
+            String resultFilePath = generateBibFromTemplateFiles(taskId, eventId, templateImage, bibParam, athleteVoList);
+
+            // 完成任务
+            gameBibTaskService.completeTask(taskId, resultFilePath);
+
+        } catch (Exception e) {
+            log.error("基于模版生成参赛证失败,taskId: {}", taskId, e);
+            gameBibTaskService.updateTaskStatus(taskId, "3", e.getMessage());
+        }
+    }
+
+    /**
+     * 基于画布模版生成参赛证文件
+     */
+    private String generateBibFromTemplateFiles(Long taskId, Long eventId, byte[] templateImage,
+                                              GenerateBibBo bibParam, List<GameAthleteVo> athleteVoList) {
+        try {
+            // 创建结果目录
+            String resultDir = fileUploadConfig.getBibResultPath() + taskId + File.separator;
+            File resultDirFile = new File(resultDir);
+            if (!resultDirFile.exists()) {
+                boolean created = resultDirFile.mkdirs();
+                if (!created) {
+                    throw new RuntimeException("无法创建结果目录: " + resultDirFile.getAbsolutePath());
+                }
+            }
+
+            int totalCount = athleteVoList.size();
+            int completedCount = 0;
+
+            // 为每个运动员生成参赛证图片
+            for (GameAthleteVo athlete : athleteVoList) {
+                try {
+                    // 基于模版生成单个参赛证图片
+                    byte[] bibImage = generateSingleBibFromTemplate(templateImage, athlete, bibParam);
+
+                    // 保存图片文件
+                    String fileName = athlete.getAthleteCode() + "_" + athlete.getName() + ".png";
+                    String filePath = resultDir + fileName;
+                    Files.write(Paths.get(filePath), bibImage);
+
+                    completedCount++;
+                    int progress = (completedCount * 100) / totalCount;
+                    gameBibTaskService.updateTaskProgress(taskId, progress, completedCount);
+
+                } catch (Exception e) {
+                    log.error("生成运动员参赛证失败: {}", athlete.getName(), e);
+                }
+            }
+
+            // 创建ZIP文件
+            String zipFilePath = resultDir + "参赛证_" + taskId + ".zip";
+            createZipFile(resultDir, zipFilePath);
+            log.info("ZIP文件创建完成: {}", zipFilePath);
+
+            return zipFilePath;
+
+        } catch (Exception e) {
+            log.error("基于模版生成参赛证文件失败", e);
+            throw new RuntimeException("生成参赛证文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 基于模版生成单个参赛证图片
+     */
+    private byte[] generateSingleBibFromTemplate(byte[] templateImage, GameAthleteVo athlete, GenerateBibBo bibParam) {
+        try {
+            // 使用BufferedImage处理模版图片
+            BufferedImage template = ImageIO.read(new ByteArrayInputStream(templateImage));
+            Graphics2D g2d = template.createGraphics();
+
+            // 设置抗锯齿
+            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+            // 绘制号码
+            drawNumberOnTemplate(g2d, athlete.getAthleteCode().toString(), bibParam, template.getWidth(), template.getHeight());
+
+            // 绘制二维码
+            drawQRCodeOnTemplate(g2d, athlete, bibParam, template.getWidth(), template.getHeight());
+
+            g2d.dispose();
+
+            // 转换为字节数组
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(template, "PNG", baos);
+            return baos.toByteArray();
+
+        } catch (Exception e) {
+            log.error("基于模版生成单个参赛证失败", e);
+            throw new RuntimeException("生成参赛证失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 在模版上绘制号码
+     */
+    private void drawNumberOnTemplate(Graphics2D g2d, String number, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            // 计算号码位置(基于百分比)
+            int x = (int) (canvasWidth * bibParam.getNumberX() / 100);
+            int y = (int) (canvasHeight * bibParam.getNumberY() / 100);
+
+            // 设置字体(考虑前端预览的相对比例)
+            int baseFontSize = bibParam.getFontSize();
+            // 前端预览时号码字体被限制为最大56px,相对于预览框600x400的比例
+            final int previewMaxFontSize = 56;
+
+            // 计算预览时的相对比例(相对于预览框的宽度)
+            double previewRelativeFontSize = (double) previewMaxFontSize / previewWidth;
+
+            // 计算实际图片中字体应该占的相对比例
+            double actualRelativeFontSize = previewRelativeFontSize * canvasWidth;
+
+            // 计算字体的实际缩放比例
+            double fontScale = Math.min(1.0, actualRelativeFontSize / baseFontSize);
+            int fontSize = (int) (baseFontSize * fontScale);
+            Font font = new Font(bibParam.getFontName(), Font.BOLD, fontSize);
+            g2d.setFont(font);
+
+            // 设置颜色
+            Color color = new Color(bibParam.getFontColor());
+            g2d.setColor(color);
+
+            // 绘制号码
+            FontMetrics fm = g2d.getFontMetrics();
+            int textWidth = fm.stringWidth(number);
+            int textHeight = fm.getHeight();
+
+            // 居中绘制
+            g2d.drawString(number, x - textWidth / 2, y + textHeight / 4);
+            log.info("号码绘制完成 - 位置: ({}, {}), 基础字体: {}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 最终字体: {}", x, y, baseFontSize, previewRelativeFontSize, actualRelativeFontSize, fontScale, fontSize);
+
+        } catch (Exception e) {
+            log.error("绘制号码失败", e);
+        }
+    }
+
+    /**
+     * 在模版上绘制二维码
+     */
+    private void drawQRCodeOnTemplate(Graphics2D g2d, GameAthleteVo athlete, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            // 生成二维码数据
+            String qrData = generateQRCodeData(athlete);
+            log.info("生成二维码数据: {}", qrData);
+
+            // 生成二维码图片
+            byte[] qrCodeBytes = generateQRCode(qrData, 100, 100);
+            BufferedImage qrImage = ImageIO.read(new ByteArrayInputStream(qrCodeBytes));
+
+            // 计算二维码位置(从百分比坐标转换为实际图片坐标)
+            Double qrX = bibParam.getQRCodeX();
+            Double qrY = bibParam.getQRCodeY();
+
+            int x, y;
+            if (qrX != null && qrY != null) {
+                // 将百分比坐标转换为实际图片像素坐标
+                x = (int) (qrX * canvasWidth / 100.0);
+                y = (int) (qrY * canvasHeight / 100.0);
+                log.info("二维码坐标转换 - 百分比坐标: ({}, {}), 实际图片坐标: ({}, {})",
+                    qrX, qrY, x, y);
+            } else {
+                // 使用默认位置(右下角)
+                x = (int) (88.33 * canvasWidth / 100.0);  // 88.33%
+                y = (int) (67.5 * canvasHeight / 100.0);  // 67.5%
+                log.info("使用默认二维码位置: ({}, {})", x, y);
+            }
+
+            // 应用缩放(考虑前端预览的相对比例和用户设置的缩放)
+            Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
+
+            // 前端预览时二维码是32x32的SVG图标,相对于预览框600x400的比例
+            final int previewQRSize = 32; // 前端SVG图标大小
+
+            // 计算预览时的相对比例(相对于预览框的宽度)
+            double previewRelativeSize = (double) previewQRSize / previewWidth;
+
+            // 计算实际图片中二维码应该占的相对比例
+            double actualRelativeSize = previewRelativeSize * canvasWidth;
+
+            // 计算二维码的实际缩放比例
+            double previewScale = Math.min(1.0, actualRelativeSize / Math.max(qrImage.getWidth(), qrImage.getHeight()));
+
+            // 计算最终缩放:预览缩放 × 用户缩放
+            double finalScale = previewScale * barcodeScale;
+            int scaledWidth = (int) (qrImage.getWidth() * finalScale);
+            int scaledHeight = (int) (qrImage.getHeight() * finalScale);
+
+            // 边界检查,确保二维码不会超出图片范围
+            if (x < 0) x = 0;
+            if (y < 0) y = 0;
+            if (x + scaledWidth > canvasWidth) x = canvasWidth - scaledWidth;
+            if (y + scaledHeight > canvasHeight) y = canvasHeight - scaledHeight;
+
+            // 绘制二维码
+            g2d.drawImage(qrImage, x, y, scaledWidth, scaledHeight, null);
+            log.info("二维码绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终缩放: {}, 画布尺寸: {}x{}",
+                x, y, scaledWidth, scaledHeight, previewRelativeSize, actualRelativeSize, previewScale, barcodeScale, finalScale, canvasWidth, canvasHeight);
+
+        } catch (Exception e) {
+            log.error("绘制二维码失败", e);
+        }
+    }
+
+    /**
+     * 生成二维码数据
+     */
+    private String generateQRCodeData(GameAthleteVo athlete) {
+        return String.format("运动员编号: %s, 姓名: %s, 队伍: %s",
+            athlete.getAthleteCode(),
+            athlete.getName(),
+            athlete.getTeamName() != null ? athlete.getTeamName() : "未知队伍");
+    }
+
+    /**
+     * 创建ZIP文件
+     */
+    private void createZipFile(String sourceDir, String zipFilePath) {
+        try (FileOutputStream fos = new FileOutputStream(zipFilePath);
+             ZipOutputStream zos = new ZipOutputStream(fos)) {
+
+            File sourceFile = new File(sourceDir);
+            log.info("开始创建ZIP文件 - 源目录: {}, ZIP文件: {}", sourceDir, zipFilePath);
+
+            if (!sourceFile.exists()) {
+                throw new RuntimeException("源目录不存在: " + sourceDir);
+            }
+
+            File[] files = sourceFile.listFiles();
+            if (files == null || files.length == 0) {
+                log.warn("源目录为空: {}", sourceDir);
+                return;
+            }
+
+            log.info("找到 {} 个文件需要打包", files.length);
+
+            // 只添加PNG文件,不包含ZIP文件本身
+            for (File file : files) {
+                if (file.isFile() && file.getName().toLowerCase().endsWith(".png")) {
+                    try (FileInputStream fis = new FileInputStream(file)) {
+                        ZipEntry zipEntry = new ZipEntry(file.getName());
+                        zos.putNextEntry(zipEntry);
+
+                        byte[] buffer = new byte[1024];
+                        int length;
+                        while ((length = fis.read(buffer)) > 0) {
+                            zos.write(buffer, 0, length);
+                        }
+
+                        zos.closeEntry();
+                        log.debug("添加文件到ZIP: {}", file.getName());
+                    }
+                }
+            }
+
+            log.info("ZIP文件创建成功: {}", zipFilePath);
+
+        } catch (Exception e) {
+            log.error("创建ZIP文件失败", e);
+            throw new RuntimeException("创建ZIP文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 递归添加文件到ZIP
+     */
+    private void addFileToZip(File file, String fileName, ZipOutputStream zos) throws IOException {
+        if (file.isDirectory()) {
+            File[] files = file.listFiles();
+            if (files != null) {
+                for (File subFile : files) {
+                    addFileToZip(subFile, fileName + File.separator + subFile.getName(), zos);
+                }
+            }
+        } else {
+            try (FileInputStream fis = new FileInputStream(file)) {
+                ZipEntry zipEntry = new ZipEntry(fileName);
+                zos.putNextEntry(zipEntry);
+
+                byte[] buffer = new byte[1024];
+                int length;
+                while ((length = fis.read(buffer)) > 0) {
+                    zos.write(buffer, 0, length);
+                }
+
+                zos.closeEntry();
+            }
+        }
+    }
+
 }

+ 43 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameRefereeServiceImpl.java

@@ -55,6 +55,9 @@ public class GameRefereeServiceImpl implements IGameRefereeService {
     @Override
     public GameRefereeVo queryById(Long refereeId){
         GameRefereeVo vo = gameRefereeMapper.selectVoById(refereeId);
+        if(vo == null){
+            return null;
+        }
         if (vo.getProjectList() != null) {
             vo.setProjectList2(JSONUtil.toList(vo.getProjectList(), Long.class));
         }
@@ -187,7 +190,7 @@ public class GameRefereeServiceImpl implements IGameRefereeService {
         }
 
         GameReferee update = MapstructUtils.convert(bo, GameReferee.class);
-        validEntityBeforeSave(update);
+        // validEntityBeforeSave(update);
         boolean flag = gameRefereeMapper.updateById(update) > 0;
 
         if (flag) {
@@ -295,6 +298,7 @@ public class GameRefereeServiceImpl implements IGameRefereeService {
      */
     private void validEntityBeforeSave(GameReferee entity){
         //TODO 做一些数据校验,如唯一约束
+        cleanInvalidProjectAssociations(entity.getRefereeId());
     }
 
     /**
@@ -348,4 +352,42 @@ public class GameRefereeServiceImpl implements IGameRefereeService {
             Wrappers.lambdaQuery(GameReferee.class)
         );
     }
+
+    @Override
+    public List<GameRefereeBo> findByProjectIds(Collection<Long> projectIds) {
+        return gameRefereeMapper.findByProjectIds(projectIds);
+    }
+
+    /**
+ * 清理无效的项目关联
+ *
+ * @param refereeId 裁判ID
+ */
+public void cleanInvalidProjectAssociations(Long refereeId) {
+    try {
+        GameRefereeVo referee = queryById(refereeId);
+        if (referee != null && referee.getProjectList2() != null) {
+            List<Long> validProjectIds = new ArrayList<>();
+
+            for (Long projectId : referee.getProjectList2()) {
+                GameEventProjectVo project = gameEventProjectService.queryById(projectId);
+                if (project != null) {
+                    validProjectIds.add(projectId);
+                } else {
+                    log.warn("发现无效的项目关联,裁判ID: {}, 项目ID: {}", refereeId, projectId);
+                }
+            }
+
+            // 如果发现无效关联,更新裁判记录
+            if (!validProjectIds.equals(referee.getProjectList2())) {
+                GameRefereeBo bo = MapstructUtils.convert(referee, GameRefereeBo.class);
+                bo.setProjectList2(validProjectIds);
+                updateByBo(bo);
+                log.info("已清理无效的项目关联,裁判ID: {}", refereeId);
+            }
+        }
+    } catch (Exception e) {
+        log.error("清理无效项目关联失败,裁判ID: {}", refereeId, e);
+    }
+}
 }

+ 374 - 32
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameScoreServiceImpl.java

@@ -357,11 +357,12 @@ public class GameScoreServiceImpl implements IGameScoreService {
             if (athlete.getTeamId() != null) {
                 GameTeamVo team = gameTeamService.queryById(athlete.getTeamId());
                 if (team != null) {
-                    teamName = team.getTeamName();
+                    data.put("teamName", team.getTeamName()); // 添加teamName字段
+                    data.put("teamCode", team.getTeamCode());
                     log.debug("获取到队伍名称: teamId={}, teamName={}", athlete.getTeamId(), teamName);
                 }
             }
-            data.put("teamName", teamName); // 添加teamName字段
+
 
             // 查询成绩信息
             GameScoreVo score = getScoreByAthleteIdAndProjectId(athlete.getAthleteId(), projectId);
@@ -1274,70 +1275,73 @@ public class GameScoreServiceImpl implements IGameScoreService {
             teamQuery.setEventId(eventId);
             List<GameTeamVo> teams = gameTeamService.queryList(teamQuery);
 
-            // 批量查询所有队伍在所有项目中的成绩
+            // 批量查询所有队伍在所有项目中的成绩(当前赛事)
             List<GameScoreVo> allScores = baseMapper.selectVoList(
                 Wrappers.lambdaQuery(GameScore.class)
                     .eq(GameScore::getEventId, eventId)
             );
 
-            // 按队伍ID和项目ID分组成绩数据
-            Map<String, GameScoreVo> scoreMap = new HashMap<>();
+            // 构建 teamId -> (projectId -> 积分累计和)
+            Map<Long, Map<Long, Integer>> teamProjectPointSumMap = new HashMap<>();
             for (GameScoreVo score : allScores) {
-                if (score.getTeamId() != null && score.getProjectId() != null) {
-                    String key = score.getTeamId() + "_" + score.getProjectId();
-                    scoreMap.put(key, score);
+                Long teamId = score.getTeamId();
+                Long projectId = score.getProjectId();
+                if (teamId == null || projectId == null) {
+                    continue;
                 }
+                int points = score.getScorePoint() != null ? score.getScorePoint() : 0;
+
+                Map<Long, Integer> projectPointMap = teamProjectPointSumMap.computeIfAbsent(teamId, k -> new HashMap<>());
+                projectPointMap.merge(projectId, points, Integer::sum);
             }
 
-            // 构建加分数据
+            // 构建导出/前端展示数据
             List<Map<String, Object>> bonusData = new ArrayList<>();
 
-            for (int i = 0; i < teams.size(); i++) {
-                GameTeamVo team = teams.get(i);
+            for (GameTeamVo team : teams) {
                 Map<String, Object> teamData = new HashMap<>();
-
-                // 设置基本信息
                 teamData.put("teamId", team.getTeamId());
                 teamData.put("teamName", team.getTeamName());
-                teamData.put("rank", i + 1);
 
-                // 计算该队伍在各项目中的积分
-                int totalScore = 0;
+                // 项目积分:按项目名称汇总
                 Map<String, Integer> projectScores = new HashMap<>();
-                int leaderPoint = 0;
-                int extraPoint = 0;
+                int totalScore = 0;
 
-                // 设置各项目积分
-                for (GameEventProjectVo project : projects) {
-                    String key = team.getTeamId() + "_" + project.getProjectId();
-                    GameScoreVo score = scoreMap.get(key);
+                Map<Long, Integer> projectPointMap = teamProjectPointSumMap.getOrDefault(team.getTeamId(), Collections.emptyMap());
 
-                    int projectScore = 0;
-                    if (score != null && score.getScorePoint() != null) {
-                        projectScore = score.getScorePoint();
-                        totalScore += projectScore;
+                for (GameEventProjectVo project : projects) {
+                    int projectScore = projectPointMap.getOrDefault(project.getProjectId(), 0);
+                    projectScores.put(project.getProjectName(), projectScore);
+                    totalScore += projectScore;
+                }
 
-                        // 获取加分数据
+                // 读取加分(若有,取队伍该赛事下任一成绩记录中的加分;否则默认为0)
+                int leaderPoint = 0;
+                int extraPoint = 0;
+                for (GameScoreVo score : allScores) {
+                    if (Objects.equals(score.getTeamId(), team.getTeamId())) {
                         if (score.getLeaderPoint() != null) {
                             leaderPoint = score.getLeaderPoint();
                         }
                         if (score.getExtraPoint() != null) {
                             extraPoint = score.getExtraPoint();
                         }
+                        // 找到一条即可(两者若分散在不同记录中,按最后一次非空覆盖)
                     }
-
-                    projectScores.put(project.getProjectName(), projectScore);
                 }
 
+                // 汇总总分(基础项目分 + 加分)
+                int finalTotal = totalScore + leaderPoint + extraPoint;
+
                 teamData.put("projectScores", projectScores);
-                teamData.put("totalScore", totalScore);
+                teamData.put("totalScore", finalTotal);
                 teamData.put("leaderPoint", leaderPoint);
                 teamData.put("extraPoint", extraPoint);
 
                 bonusData.add(teamData);
             }
 
-            // 按总分排序,生成排名
+            // 按总分排序并标注排名(并列名次同分给同名次,下例采用稳定排序+顺序名次,若需并列逻辑可再调整)
             bonusData.sort((a, b) -> Integer.compare((Integer) b.get("totalScore"), (Integer) a.get("totalScore")));
             for (int i = 0; i < bonusData.size(); i++) {
                 bonusData.get(i).put("rank", i + 1);
@@ -1346,7 +1350,6 @@ public class GameScoreServiceImpl implements IGameScoreService {
             Map<String, Object> result = new HashMap<>();
             result.put("rows", bonusData);
             result.put("projects", projects);
-
             return result;
 
         } catch (Exception e) {
@@ -1490,4 +1493,343 @@ public class GameScoreServiceImpl implements IGameScoreService {
             throw new RuntimeException("导出失败:" + e.getMessage());
         }
     }
+
+    /**
+     * 导出积分明细Excel
+     * @param eventId 赛事ID
+     * @param response HTTP响应对象
+     */
+    @Override
+    public void exportScoresDetail(Long eventId, HttpServletResponse response) {
+        try {
+            log.info("开始导出成绩详情,eventId: {}", eventId);
+            
+            // 1. 获取所有项目
+            List<GameEventProjectVo> projects = gameEventProjectService.queryListByEventId(eventId);
+            if (projects.isEmpty()) {
+                throw new RuntimeException("未找到赛事项目");
+            }
+            
+            // 2. 创建Excel工作簿
+            try (Workbook workbook = new XSSFWorkbook()) {
+                
+                // 3. 创建样式
+                CellStyle headerStyle = createHeaderStyle(workbook);
+                CellStyle dataStyleOdd = createDataStyle(workbook, IndexedColors.WHITE);
+                CellStyle dataStyleEven = createDataStyle(workbook, IndexedColors.GREY_25_PERCENT);
+                
+                // 4. 为每个项目创建Sheet页
+                for (GameEventProjectVo project : projects) {
+                    createProjectSheet(workbook, project, eventId, headerStyle, dataStyleOdd, dataStyleEven);
+                }
+                
+                // 5. 设置响应头并输出
+                setResponseHeaders(response, "成绩详情表");
+                workbook.write(response.getOutputStream());
+            }
+            
+            log.info("成绩详情导出完成");
+            
+        } catch (Exception e) {
+            log.error("导出成绩详情失败", e);
+            throw new RuntimeException("导出失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 为单个项目创建Sheet页
+     */
+    private void createProjectSheet(Workbook workbook, GameEventProjectVo project, Long eventId, 
+        CellStyle headerStyle, CellStyle dataStyleOdd, CellStyle dataStyleEven) {
+
+        // 1. 创建Sheet页,名称限制在31个字符内
+        String sheetName = truncateSheetName(project.getProjectName());
+        Sheet sheet = workbook.createSheet(sheetName);
+
+        // 2. 获取项目详细数据
+        List<Map<String, Object>> projectData = getProjectScoreDataAll(
+            eventId, 
+            project.getProjectId(), 
+            project.getClassification()
+        );
+
+        // 3. 创建标题行
+        createProjectHeaderRow(sheet, project, headerStyle);
+
+        // 4. 填充数据行
+        fillProjectDataRows(sheet, projectData, project, dataStyleOdd, dataStyleEven);
+
+        // 5. 自动调整列宽
+        autoSizeColumns(sheet);
+    }
+
+    /**
+     * 获取项目成绩数据(全部,不分页)
+     */
+    private List<Map<String, Object>> getProjectScoreDataAll(Long eventId, Long projectId, String classification) {
+        log.info("获取项目全部数据: eventId={}, projectId={}, classification={}", eventId, projectId, classification);
+        
+        if ("0".equals(classification)) {
+            // 个人项目:获取所有运动员数据
+            return getIndividualProjectDataAll(eventId, projectId);
+        } else {
+            // 团体项目:获取所有队伍数据
+            return getTeamProjectDataAll(eventId, projectId);
+        }
+    }
+
+    /**
+     * 获取个人项目全部数据(不分页)
+     */
+    private List<Map<String, Object>> getIndividualProjectDataAll(Long eventId, Long projectId) {
+        List<Map<String, Object>> resultList = new ArrayList<>();
+        
+        // 查询参与该项目的所有运动员(不分页)
+        List<GameAthleteVo> athletes = gameAthleteService.queryListByEventIdAndProjectId(eventId, projectId, null);
+        
+        for (GameAthleteVo athlete : athletes) {
+            Map<String, Object> data = new HashMap<>();
+            
+            // 基础信息
+            data.put("athleteId", athlete.getAthleteId());
+            data.put("userId", athlete.getUserId());
+            data.put("eventId", eventId);
+            data.put("projectId", projectId);
+            data.put("teamId", athlete.getTeamId());
+            data.put("athleteCode", athlete.getAthleteCode());
+            data.put("name", athlete.getName());
+            data.put("unit", athlete.getUnit());
+            data.put("groupType", athlete.getGroupType());
+            
+            // 队伍信息
+            if (athlete.getTeamId() != null) {
+                GameTeamVo team = gameTeamService.queryById(athlete.getTeamId());
+                if (team != null) {
+                    data.put("teamName", team.getTeamName());
+                    data.put("teamCode", team.getTeamCode());
+                }
+            }
+            
+            // 成绩信息
+            GameScoreVo score = getScoreByAthleteIdAndProjectId(athlete.getAthleteId(), projectId);
+            if (score != null) {
+                data.put("scoreId", score.getScoreId());
+                data.put("scorePoint", score.getScorePoint());
+                data.put("individualPerformance", score.getIndividualPerformance());
+                data.put("teamPerformance", score.getTeamPerformance());
+                data.put("scoreRank", score.getScoreRank());
+                data.put("updateTime", score.getUpdateTime());
+            } else {
+                // 设置默认值
+                data.put("scoreId", 0);
+                data.put("scorePoint", 0);
+                data.put("individualPerformance", 0.0);
+                data.put("teamPerformance", 0.0);
+                data.put("scoreRank", 0);
+                data.put("updateTime", "");
+            }
+            
+            resultList.add(data);
+        }
+        
+        return resultList;
+    }
+
+    /**
+     * 获取团体项目全部数据(不分页)
+     */
+    private List<Map<String, Object>> getTeamProjectDataAll(Long eventId, Long projectId) {
+        List<Map<String, Object>> resultList = new ArrayList<>();
+        Set<Long> processedTeamIds = new HashSet<>();
+        
+        // 查询参与该项目的所有运动员
+        List<GameAthleteVo> athletes = gameAthleteService.queryListByEventIdAndProjectId(eventId, projectId, null);
+        
+        for (GameAthleteVo athlete : athletes) {
+            if (athlete.getTeamId() == null || processedTeamIds.contains(athlete.getTeamId())) {
+                continue;
+            }
+            
+            processedTeamIds.add(athlete.getTeamId());
+            
+            // 查询队伍信息
+            GameTeamVo team = gameTeamService.queryById(athlete.getTeamId());
+            if (team == null) {
+                continue;
+            }
+            
+            Map<String, Object> data = new HashMap<>();
+            data.put("teamId", team.getTeamId());
+            data.put("teamName", team.getTeamName());
+            data.put("teamCode", team.getTeamCode());
+            data.put("eventId", eventId);
+            data.put("projectId", projectId);
+            
+            // 查询成绩信息
+            GameScoreVo score = getScoreByAthleteIdAndProjectId(athlete.getAthleteId(), projectId);
+            if (score != null) {
+                data.put("scoreId", score.getScoreId());
+                data.put("scorePoint", score.getScorePoint());
+                data.put("individualPerformance", score.getIndividualPerformance());
+                data.put("teamPerformance", score.getTeamPerformance());
+                data.put("scoreRank", score.getScoreRank());
+                data.put("updateTime", score.getUpdateTime());
+            } else {
+                data.put("scoreId", 0);
+                data.put("scorePoint", 0);
+                data.put("individualPerformance", 0.0);
+                data.put("teamPerformance", 0.0);
+                data.put("scoreRank", 0);
+                data.put("updateTime", "");
+            }
+            
+            resultList.add(data);
+        }
+        
+        return resultList;
+    }
+
+    /**
+     * 创建项目Sheet页的标题行
+     */
+    private void createProjectHeaderRow(Sheet sheet, GameEventProjectVo project, CellStyle headerStyle) {
+        Row headerRow = sheet.createRow(0);
+        int colIndex = 0;
+        
+        // 根据项目类型确定列标题
+        if ("0".equals(project.getClassification())) {
+            // 个人项目列标题
+            String[] headers = {
+                "序号", "队伍编号", "队伍名称", "姓名", "号码", 
+                "个人成绩", "积分", "排名", "更新时间"
+            };
+            
+            for (String header : headers) {
+                Cell cell = headerRow.createCell(colIndex++);
+                cell.setCellValue(header);
+                cell.setCellStyle(headerStyle);
+            }
+        } else {
+            // 团体项目列标题
+            String[] headers = {
+                "序号", "队伍编号", "队伍名称", "团队成绩", 
+                "积分", "排名", "更新时间"
+            };
+            
+            for (String header : headers) {
+                Cell cell = headerRow.createCell(colIndex++);
+                cell.setCellValue(header);
+                cell.setCellStyle(headerStyle);
+            }
+        }
+    }
+
+    /**
+     * 填充项目数据行
+     */
+    private void fillProjectDataRows(Sheet sheet, List<Map<String, Object>> projectData, 
+    GameEventProjectVo project, CellStyle dataStyleOdd, CellStyle dataStyleEven) {
+
+        int rowIndex = 1;
+        for (int i = 0; i < projectData.size(); i++) {
+            Map<String, Object> data = projectData.get(i);
+            Row dataRow = sheet.createRow(rowIndex++);
+
+            // 选择行样式
+            CellStyle currentRowStyle = (rowIndex % 2 == 0) ? dataStyleEven : dataStyleOdd;
+
+            int colIndex = 0;
+
+            if ("0".equals(project.getClassification())) {
+                // 个人项目数据
+                createCellWithStyle(dataRow, colIndex++, i + 1, currentRowStyle); // 序号
+                createCellWithStyle(dataRow, colIndex++, data.get("teamCode"), currentRowStyle); // 队伍编号
+                createCellWithStyle(dataRow, colIndex++, data.get("teamName"), currentRowStyle); // 队伍名称
+                createCellWithStyle(dataRow, colIndex++, data.get("name"), currentRowStyle); // 姓名
+                createCellWithStyle(dataRow, colIndex++, data.get("athleteCode"), currentRowStyle); // 号码
+                createCellWithStyle(dataRow, colIndex++, data.get("individualPerformance"), currentRowStyle); // 个人成绩
+                createCellWithStyle(dataRow, colIndex++, data.get("scorePoint"), currentRowStyle); // 积分
+                createCellWithStyle(dataRow, colIndex++, data.get("scoreRank"), currentRowStyle); // 排名
+                createCellWithStyle(dataRow, colIndex++, data.get("updateTime"), currentRowStyle); // 更新时间
+            } else {
+                // 团体项目数据
+                createCellWithStyle(dataRow, colIndex++, i + 1, currentRowStyle); // 序号
+                createCellWithStyle(dataRow, colIndex++, data.get("teamCode"), currentRowStyle); // 队伍编号
+                createCellWithStyle(dataRow, colIndex++, data.get("teamName"), currentRowStyle); // 队伍名称
+                createCellWithStyle(dataRow, colIndex++, data.get("teamPerformance"), currentRowStyle); // 团队成绩
+                createCellWithStyle(dataRow, colIndex++, data.get("scorePoint"), currentRowStyle); // 积分
+                createCellWithStyle(dataRow, colIndex++, data.get("scoreRank"), currentRowStyle); // 排名
+                createCellWithStyle(dataRow, colIndex++, data.get("updateTime"), currentRowStyle); // 更新时间
+            }
+        }
+    }
+
+    /**
+     * 处理Sheet名称(Excel限制31个字符,不能包含特殊字符)
+     */
+    private String truncateSheetName(String projectName) {
+        if (projectName == null) {
+            return "项目";
+        }
+        
+        // 替换Excel不允许的特殊字符
+        String cleanName = projectName
+            .replace("*", "×")     // 星号替换为乘号
+            .replace("\\", "_")    // 反斜杠
+            .replace("/", "_")     // 正斜杠
+            .replace("?", "_")     // 问号
+            .replace("[", "(")     // 左方括号替换为左括号
+            .replace("]", ")")     // 右方括号替换为右括号
+            .replace(":", ":")    // 英文冒号替换为中文冒号
+            .trim();               // 去除首尾空格
+        
+        // 处理单引号问题
+        if (cleanName.startsWith("'") || cleanName.endsWith("'")) {
+            cleanName = cleanName.replace("'", "_");
+        }
+        
+        // 如果处理后为空,使用默认名称
+        if (cleanName.isEmpty()) {
+            cleanName = "项目";
+        }
+        
+        // 限制长度在31个字符内
+        if (cleanName.length() > 31) {
+            cleanName = cleanName.substring(0, 31);
+        }
+        
+        return cleanName;
+    }
+
+    /**
+     * 自动调整列宽
+     */
+    private void autoSizeColumns(Sheet sheet) {
+        if (sheet.getLastRowNum() > 0) {
+            Row firstRow = sheet.getRow(0);
+            if (firstRow != null) {
+                for (int i = 0; i < firstRow.getLastCellNum(); i++) {
+                    sheet.autoSizeColumn(i);
+                    // 设置最小列宽
+                    if (sheet.getColumnWidth(i) < 2000) {
+                        sheet.setColumnWidth(i, 2000);
+                    }
+                    // 设置最大列宽
+                    if (sheet.getColumnWidth(i) > 8000) {
+                        sheet.setColumnWidth(i, 8000);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 设置响应头
+     */
+    private void setResponseHeaders(HttpServletResponse response, String fileName) throws Exception {
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding("utf-8");
+        String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
+        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx");
+    }
 }

+ 34 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameTeamServiceImpl.java

@@ -189,7 +189,7 @@ public class GameTeamServiceImpl implements IGameTeamService {
             });
 
         lqw.like(StringUtils.isNotBlank(bo.getTeamName()), GameTeam::getTeamName, bo.getTeamName());
-        lqw.eq(StringUtils.isNotBlank(bo.getTeamCode()), GameTeam::getTeamCode, bo.getTeamCode());
+        lqw.like(StringUtils.isNotBlank(bo.getTeamCode()), GameTeam::getTeamCode, bo.getTeamCode());
         lqw.eq(StringUtils.isNotBlank(bo.getLeader()), GameTeam::getLeader, bo.getLeader());
         lqw.eq(StringUtils.isNotBlank(bo.getAthleteValue()), GameTeam::getAthleteValue, bo.getAthleteValue());
         lqw.eq(StringUtils.isNotBlank(bo.getProjectValue()), GameTeam::getProjectValue, bo.getProjectValue());
@@ -266,6 +266,17 @@ public class GameTeamServiceImpl implements IGameTeamService {
      */
     private void validEntityBeforeSave(GameTeam entity) {
         //TODO 做一些数据校验,如唯一约束
+        //校验队伍编号的唯一性
+        if (entity.getTeamCode() != null){
+            Long count = baseMapper.selectCount(
+                Wrappers.lambdaQuery(GameTeam.class)
+                    .eq(GameTeam::getTeamCode, entity.getTeamCode())
+                    .ne(entity.getTeamId() != null,GameTeam::getTeamId, entity.getTeamId())
+            );
+            if (count > 0) {
+                throw new ServiceException(entity.getTeamName()+"的编号已存在!");
+            }
+        }
     }
 
     /**
@@ -280,9 +291,26 @@ public class GameTeamServiceImpl implements IGameTeamService {
         if (isValid) {
             //TODO 做一些业务上的校验,判断是否需要校验
         }
+        //批量删除前,删除运动员中的关联数据
+        removeTeamFromAthletes(ids);
         return baseMapper.deleteByIds(ids) > 0;
     }
 
+    private void removeTeamFromAthletes(Collection<Long> teamIds) {
+        if (CollectionUtils.isEmpty(teamIds)) {
+            return;
+        }
+        List<GameAthlete> athleteList = gameAthleteMapper.selectList(
+            Wrappers.<GameAthlete>lambdaQuery()
+                .in(GameAthlete::getTeamId, teamIds)
+        );
+        if (CollectionUtils.isEmpty(athleteList)) {
+            return;
+        }
+        List<GameAthlete> updateAthletes = athleteList.stream().peek(athlete -> athlete.setTeamId(null)).toList();
+        gameAthleteMapper.updateById(updateAthletes);
+    }
+
     /**
      * 批量保存参赛队伍信息
      *
@@ -464,4 +492,9 @@ public class GameTeamServiceImpl implements IGameTeamService {
                 .eq(GameTeam::getEventId, eventId)
         );
     }
+
+    @Override
+    public List<GameTeamBo> findByAthleteIds(Collection<Long> athleteIds) {
+        return baseMapper.findByAthleteIds(athleteIds);
+    }
 }

+ 652 - 133
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/IEnrollServiceImpl.java

@@ -14,16 +14,14 @@ import org.apache.poi.ss.util.CellRangeAddress;
 import org.apache.poi.xssf.usermodel.XSSFSheet;
 import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.dromara.common.core.utils.ObjectUtils;
-import org.dromara.system.domain.GameTeam;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.system.domain.*;
 import org.dromara.system.domain.bo.EnrollBo;
 import org.dromara.system.domain.bo.GameAthleteBo;
 import org.dromara.system.domain.bo.GameTeamBo;
-import org.dromara.system.domain.vo.EnrollProjectVo;
-import org.dromara.system.domain.vo.GameTeamVo;
-import org.dromara.system.service.IEnrollService;
-import org.dromara.system.service.IGameAthleteService;
-import org.dromara.system.service.IGameEventProjectService;
-import org.dromara.system.service.IGameTeamService;
+import org.dromara.system.domain.vo.*;
+import org.dromara.system.service.*;
+import org.dromara.system.utils.GenderDictUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
@@ -44,6 +42,7 @@ public class IEnrollServiceImpl implements IEnrollService {
     private final IGameEventProjectService gameEventProjectService;
     private final IGameTeamService gameTeamService;
     private final IGameAthleteService gameAthleteService;
+    private final IGameEventService gameEventService;
 
     /**
      * 使用poi生成报名表模板
@@ -68,7 +67,7 @@ public class IEnrollServiceImpl implements IEnrollService {
 
             if (!projectMap.isEmpty()) {
                 // 3. 渲染分类
-                int currentColumnIndex = 6;
+                int currentColumnIndex = 7;
                 Row row = sheet.getRow(1);
                 if (row == null) {
                     row = sheet.createRow(1);
@@ -115,7 +114,7 @@ public class IEnrollServiceImpl implements IEnrollService {
                 }
 
                 // 4. 渲染项目(在第4行,索引为3)
-                currentColumnIndex = 6;
+                currentColumnIndex = 7;
                 Row projectRow = sheet.getRow(2);
                 if (projectRow == null) {
                     projectRow = sheet.createRow(2);
@@ -286,8 +285,8 @@ public class IEnrollServiceImpl implements IEnrollService {
                 int lastCellIndex = findValidLastColumnIndex(headerRow);
                 List<String> projectNames = new ArrayList<>();
 
-                // 从第6列(F列,索引5)开始收集项目名称
-                for (int i = 6; i < lastCellIndex; i++) {
+                // 从第7列(G列,索引6)开始收集项目名称(因为新增了号码列)
+                for (int i = 7; i < lastCellIndex; i++) {
                     Cell cell = headerRow.getCell(i);
                     if (cell != null && cell.getStringCellValue() != null && !cell.getStringCellValue().trim().isEmpty()) {
                         projectNames.add(cell.getStringCellValue().trim());
@@ -307,46 +306,54 @@ public class IEnrollServiceImpl implements IEnrollService {
                     EnrollProjectVo enroll = new EnrollProjectVo();
                     Map<String, Boolean> selections = new LinkedHashMap<>(); // 保持顺序
 
-                    // A列:姓名
-                    Cell nameCell = row.getCell(0);
+                    // A列:号码
+                    Cell codeCell = row.getCell(0);
+                    if (codeCell != null) {
+                        enroll.setAthleteCode(getCellValueAsString(codeCell));
+                    }
+
+                    // B列:姓名
+                    Cell nameCell = row.getCell(1);
                     if (nameCell != null) {
                         enroll.setName(getCellValueAsString(nameCell));
                     }
 
-                    // B列:性别
-                    Cell sexCell = row.getCell(1);
+                    // C列:性别
+                    Cell sexCell = row.getCell(2);
                     if (sexCell != null) {
-                        enroll.setSex(getCellValueAsString(sexCell));
+                        String sexText = getCellValueAsString(sexCell);
+                        String sexValue = GenderDictUtils.convertGenderToDictValue(sexText);
+                        enroll.setSex(sexValue);
                     }
 
-                    // C列:年龄
-                    Cell ageCell = row.getCell(2);
+                    // D列:年龄
+                    Cell ageCell = row.getCell(3);
                     if (ageCell != null) {
                         enroll.setAge(getCellValueAsString(ageCell));
                     }
 
-                    // D列:队伍名称
-                    Cell teamCell = row.getCell(3);
+                    // E列:队伍名称
+                    Cell teamCell = row.getCell(4);
                     if (teamCell != null) {
                         enroll.setTeamName(getCellValueAsString(teamCell));
                     }
 
-                    // E列:领队
-                    Cell leaderCell = row.getCell(4);
+                    // F列:领队
+                    Cell leaderCell = row.getCell(5);
                     if (leaderCell != null) {
                         enroll.setLeader(getCellValueAsString(leaderCell));
                     }
 
-                    // F列:联系方式
-                    Cell phoneCell = row.getCell(5);
+                    // G列:联系方式
+                    Cell phoneCell = row.getCell(6);
                     if (phoneCell != null) {
                         enroll.setPhone(getCellValueAsString(phoneCell));
                     }
 
-                    // 从第6列开始读取项目名称
-                    for (int j = 6; j < lastCellIndex; j++) {
+                    // 从第7列开始读取项目名称
+                    for (int j = 7; j < lastCellIndex; j++) {
                         Cell cell = row.getCell(j);
-                        String projectName = projectNames.get(j - 6); // 对应项目名
+                        String projectName = projectNames.get(j - 7); // 对应项目名
                         boolean selected = isCellSelected(cell);
                         selections.put(projectName, selected);
                     }
@@ -357,11 +364,9 @@ public class IEnrollServiceImpl implements IEnrollService {
             }
             return enrolls;
         } catch (Exception e) {
-
-        } finally {
-
+            log.error("解析Excel文件失败", e);
+            return List.of();
         }
-        return List.of();
     }
 
     // 辅助方法:将 Cell 转为字符串
@@ -369,15 +374,15 @@ public class IEnrollServiceImpl implements IEnrollService {
         if (cell == null) return "";
 
         switch (cell.getCellType()) {
-            case Cell.CELL_TYPE_STRING:
+            case STRING:
                 return cell.getStringCellValue().trim();
-            case Cell.CELL_TYPE_NUMERIC:
+            case NUMERIC:
                 if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
                     return cell.getDateCellValue().toString();
                 } else {
                     return String.valueOf((int) cell.getNumericCellValue()); // 或者保留小数用 double
                 }
-            case Cell.CELL_TYPE_BOOLEAN:
+            case BOOLEAN:
                 return String.valueOf(cell.getBooleanCellValue());
             default:
                 return "";
@@ -392,10 +397,10 @@ public class IEnrollServiceImpl implements IEnrollService {
         if (cell == null) return false;
 
         // 先检查单元格类型
-        if (cell.getCellType() == Cell.CELL_TYPE_BOOLEAN) {
+        if (cell.getCellType() == CellType.BOOLEAN) {
             return cell.getBooleanCellValue();
         }
-        if (cell.getCellType() == Cell.CELL_TYPE_NUMERIC) {
+        if (cell.getCellType() == CellType.NUMERIC) {
             return cell.getNumericCellValue() == 1;
         }
 
@@ -460,87 +465,6 @@ public class IEnrollServiceImpl implements IEnrollService {
      * @param eventId
      * @return
      */
-    // @Transactional(rollbackFor = Exception.class)
-    // public boolean saveEnrollData(List<EnrollProjectVo> dataList, Long eventId) {
-    //     // 1. 根据队伍分类成Map
-    //     Map<String, List<EnrollProjectVo>> groupedByTeam = dataList.stream()
-    //         .collect(Collectors.groupingBy(EnrollProjectVo::getTeamName));
-    //     // 查询当前赛事下的项目 名称和id映射关系
-    //     Map<String, Long> projectList = gameEventProjectService.mapProjectAndProjectId(eventId);
-    //     // 1.2 根据队伍生成号码段
-    //     Map<String, String> numberRanges = new HashMap<>();
-    //     Map<String, AtomicInteger> currentNumbers = new HashMap<>(); // 记录每个队伍当前分配到的号码
-    //     AtomicInteger teamIndex = new AtomicInteger(1);
-    //     Snowflake snowflake = IdUtil.createSnowflake(1, 1);
-    //
-    //     for (Map.Entry<String, List<EnrollProjectVo>> entry : groupedByTeam.entrySet()) {
-    //         String teamName = entry.getKey();
-    //         String range = generateNumberRange(dataList.size(), teamIndex.getAndIncrement());
-    //         numberRanges.put(teamName, range);
-    //
-    //         // 解析起始号码作为初始值
-    //         int startNum = Integer.parseInt(range.split("-")[0]);
-    //         currentNumbers.put(teamName, new AtomicInteger(startNum));
-    //     }
-    //
-    //     // 2. 保存参赛队伍 & 队员
-    //     for (Map.Entry<String, List<EnrollProjectVo>> entry : groupedByTeam.entrySet()) {
-    //         String teamName = entry.getKey();
-    //         List<EnrollProjectVo> athletes = entry.getValue();
-    //         GameTeamBo gameTeamBo = new GameTeamBo();
-    //         gameTeamBo.setTeamId(snowflake.nextId());
-    //         Long teamId = gameTeamBo.getTeamId();
-    //         // 获取该队伍的当前编号计数器
-    //         AtomicInteger currentNumber = currentNumbers.get(teamName);
-    //         int width = (dataList.size() > 100) ? 5 : 4; // 决定格式化宽度
-    //         List<Long> athletesId = new ArrayList<>();
-    //         // 3. 保存参赛队员
-    //         for (EnrollProjectVo enrollInfo : athletes) {
-    //             GameAthleteBo gameAthleteBo = new GameAthleteBo();
-    //             gameAthleteBo.setEventId(eventId);
-    //             gameAthleteBo.setTeamId(teamId);
-    //             gameAthleteBo.setTeamName(teamName);
-    //             // 分配编号:从当前计数器获取并递增
-    //             int assignedNumber = currentNumber.getAndIncrement();
-    //             String formattedCode = String.format("%0" + width + "d", assignedNumber);
-    //             gameAthleteBo.setAthleteCode(formattedCode);
-    //
-    //             gameAthleteBo.setName(enrollInfo.getName());
-    //             gameAthleteBo.setGender(enrollInfo.getSex());
-    //             gameAthleteBo.setAge(Long.valueOf(enrollInfo.getAge()));
-    //             gameAthleteBo.setPhone(enrollInfo.getPhone());
-    //             gameAthleteBo.setUnit(teamName);
-    //             Map<String, Boolean> selectProjects = enrollInfo.getProjectSelections();
-    //             //查询出对应的赛事id
-    //             List<Long> selectionProjectIds = new ArrayList<>();
-    //             for (Map.Entry<String, Boolean> selectProject : selectProjects.entrySet()) {
-    //                 Long projectId = projectList.get(selectProject.getKey());
-    //                 if (ObjectUtils.isNotEmpty(projectId)) {
-    //                     selectionProjectIds.add(projectId);
-    //                 }
-    //             }
-    //             gameAthleteBo.setProjectValue(JSONUtil.toJsonStr(selectionProjectIds));
-    //
-    //             gameAthleteBo.setStatus("0");
-    //
-    //             gameAthleteService.insertByBo(gameAthleteBo);
-    //             athletesId.add(gameAthleteBo.getAthleteId());
-    //         }
-    //
-    //         gameTeamBo.setEventId(eventId);
-    //         gameTeamBo.setTeamName(teamName);
-    //         // gameTeamBo.setTeamCode("");
-    //         gameTeamBo.setLeader(athletes.get(0).getLeader());
-    //         gameTeamBo.setAthleteValue(JSONUtil.toJsonStr(athletesId));
-    //         gameTeamBo.setAthleteNum(Long.valueOf(athletes.size()));
-    //         gameTeamBo.setNumberRange(numberRanges.get(teamName));
-    //         gameTeamBo.setStatus("0");
-    //         gameTeamService.insertByBo(gameTeamBo);
-    //
-    //
-    //     }
-    //     return true;
-    // }
     @Transactional(rollbackFor = Exception.class)
     public boolean saveEnrollData(List<EnrollProjectVo> dataList, Long eventId) {
         // 1. 根据队伍分类成Map
@@ -603,8 +527,13 @@ public class IEnrollServiceImpl implements IEnrollService {
                         // 成功获取到有效编号,续着分配
                         currentNumber = new AtomicInteger(maxNumber + 1);
                     }
+                } catch (NumberFormatException e) {
+                    log.warn("解析队伍{}的号码段失败: {},从号码段起始开始分配", teamName, numberRange, e);
+                    String[] rangeParts = numberRange.split("-");
+                    int startNumber = Integer.valueOf(rangeParts[0]);
+                    currentNumber = new AtomicInteger(startNumber);
                 } catch (Exception e) {
-                    // 出现异常,从号码段起始开始分配
+                    log.error("查询队伍{}的最大队员编号时发生异常", teamName, e);
                     String[] rangeParts = numberRange.split("-");
                     int startNumber = Integer.valueOf(rangeParts[0]);
                     currentNumber = new AtomicInteger(startNumber);
@@ -614,14 +543,39 @@ public class IEnrollServiceImpl implements IEnrollService {
                 isNewTeam = true;
                 teamId = snowflake.nextId();
 
-                // 为新队伍生成300个号码段
-                int teamIndex = teamIndexCounter.getAndIncrement();
-                numberRange = generateNumberRange(dataList.size(), teamIndex);
-
-                // 解析号码段起始号码
-                String[] rangeParts = numberRange.split("-");
-                int startNumber = Integer.valueOf(rangeParts[0]);
-                currentNumber = new AtomicInteger(startNumber);
+                // 检查是否有填写的号码
+                List<String> providedCodes = athletes.stream()
+                .map(EnrollProjectVo::getAthleteCode)
+                .filter(StringUtils::isNotBlank)
+                .map(code -> code.trim())
+                .filter(code -> StringUtils.isNotBlank(code))
+                .collect(Collectors.toList());
+
+                if (!providedCodes.isEmpty()) {
+                    // 有填写的号码,使用ASCII值比较来确定号码段
+                    numberRange = generateCustomNumberRangeWithChars(providedCodes, eventId, null);
+
+                    if (numberRange != null) {
+                        // 成功生成自定义号码段
+                        currentNumber = null; // 对于字符号码,不使用数字计数器
+                        log.info("队伍 {} 使用自定义字符号码段: {}", teamName, numberRange);
+                    } else {
+                        // 生成失败,使用默认方式
+                        int teamIndex = teamIndexCounter.getAndIncrement();
+                        numberRange = generateNumberRange(dataList.size(), teamIndex);
+                        String[] rangeParts = numberRange.split("-");
+                        int startNumber = Integer.valueOf(rangeParts[0]);
+                        currentNumber = new AtomicInteger(startNumber);
+                        log.warn("队伍 {} 自定义号码段生成失败,使用默认号码段: {}", teamName, numberRange);
+                    }
+                } else {
+                    // 没有填写的号码,使用默认的300号码段
+                    int teamIndex = teamIndexCounter.getAndIncrement();
+                    numberRange = generateNumberRange(dataList.size(), teamIndex);
+                    String[] rangeParts = numberRange.split("-");
+                    int startNumber = Integer.valueOf(rangeParts[0]);
+                    currentNumber = new AtomicInteger(startNumber);
+                }
             }
 
             // 保存队员信息
@@ -654,6 +608,49 @@ public class IEnrollServiceImpl implements IEnrollService {
         return true;
     }
 
+    /**
+     * 检查字符号码段是否与现有队伍冲突
+     *
+     * @param minCode 最小号码
+     * @param maxCode 最大号码
+     * @param eventId 赛事ID
+     * @param excludeTeamId 排除的队伍ID
+     * @return 是否冲突
+     */
+    private boolean isNumberRangeConflictWithChars(String minCode, String maxCode, Long eventId, Long excludeTeamId) {
+        List<GameTeam> existingTeams = gameTeamService.queryTeamByEventId(eventId);
+
+        for (GameTeam team : existingTeams) {
+            // 排除当前队伍
+            if (excludeTeamId != null && team.getTeamId().equals(excludeTeamId)) {
+                continue;
+            }
+
+            if (StringUtils.isNotBlank(team.getNumberRange())) {
+                String[] rangeParts = team.getNumberRange().split("-");
+                if (rangeParts.length == 2) {
+                    String existingMin = rangeParts[0];
+                    String existingMax = rangeParts[1];
+
+                    // 使用ASCII比较检查重叠
+                    if (isRangeOverlapWithChars(minCode, maxCode, existingMin, existingMax)) {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * 检查两个号码段是否有重叠
+     */
+    private boolean isRangeOverlapWithChars(String min1, String max1, String min2, String max2) {
+        // 如果max1 < min2 或 max2 < min1,则没有重叠
+        return !(compareCodesByAscii(max1, min2) < 0 || compareCodesByAscii(max2, min1) < 0);
+    }
+
     /**
      * 生成号码段
      * 规则:
@@ -679,21 +676,258 @@ public class IEnrollServiceImpl implements IEnrollService {
         return startStr + "-" + endStr;
     }
 
+    /**
+     * 生成基于填写号码的号码段(支持字符)
+     *
+     * @param providedCodes 填写的号码列表
+     * @param eventId 赛事ID
+     * @param excludeTeamId 排除的队伍ID
+     * @return 号码段字符串
+     */
+    private String generateCustomNumberRangeWithChars(List<String> providedCodes, Long eventId, Long excludeTeamId) {
+        if (providedCodes.isEmpty()) {
+            return null;
+        }
+
+        // 过滤和验证号码
+        List<String> validCodes = providedCodes.stream()
+            .filter(StringUtils::isNotBlank)
+            .map(String::trim)
+            .filter(this::isValidAthleteCode)
+            .collect(Collectors.toList());
+
+        if (validCodes.isEmpty()) {
+            return null;
+        }
+
+        // 按ASCII值排序
+        List<String> sortedCodes = sortCodesByAscii(validCodes);
+        String minCode = sortedCodes.get(0);
+        String maxCode = sortedCodes.get(sortedCodes.size() - 1);
+
+        // 生成号码段
+        String numberRange = generateRangeFromCodes(minCode, maxCode);
+
+        // 检查是否与现有号码段冲突
+        if (isNumberRangeConflictWithChars(minCode, maxCode, eventId, excludeTeamId)) {
+           // 如果冲突,尝试扩展范围
+            numberRange = expandRangeToAvoidConflict(minCode, maxCode, eventId, excludeTeamId);
+            if (numberRange == null) {
+                log.warn("无法为填写的号码生成合适的号码段,使用默认方式");
+                return null; // 返回null,让调用方使用默认方式
+            }
+        }
+
+        log.info("队伍使用自定义号码段: {} (基于填写的号码: {}-{})",
+            numberRange, minCode, maxCode);
+
+        return numberRange;
+    }
+
+    /**
+     * 根据最小和最大号码生成号码段
+     *
+     * @param minCode 最小号码
+     * @param maxCode 最大号码
+     * @return 号码段字符串
+     */
+    private String generateRangeFromCodes(String minCode, String maxCode) {
+        // 简单的实现:直接使用最小和最大号码
+        return minCode + "-" + maxCode;
+    }
+
+    /**
+     * 通过前缀扩展号码段
+     */
+    private String expandByPrefix(String minCode, String maxCode, String prefix) {
+        return prefix + minCode + "-" + prefix + maxCode;
+    }
+
+    /**
+     * 通过后缀扩展号码段
+     */
+    private String expandBySuffix(String minCode, String maxCode, String suffix) {
+        return minCode + suffix + "-" + maxCode + suffix;
+    }
+
+    /**
+     * 通过填充扩展号码段
+     */
+    private String expandByPadding(String minCode, String maxCode, String padding) {
+        // 确保最小长度一致
+        int maxLength = Math.max(minCode.length(), maxCode.length());
+        String paddedMin = String.format("%-" + maxLength + "s", minCode).replace(' ', padding.charAt(0));
+        String paddedMax = String.format("%-" + maxLength + "s", maxCode).replace(' ', padding.charAt(0));
+
+        return paddedMin + "-" + paddedMax;
+    }
+
+    /**
+     * 扩展号码段以避免冲突
+     *
+     * @param minCode 最小号码
+     * @param maxCode 最大号码
+     * @param eventId 赛事ID
+     * @param excludeTeamId 排除的队伍ID
+     * @return 扩展后的号码段
+     */
+    private String expandRangeToAvoidConflict(String minCode, String maxCode, Long eventId, Long excludeTeamId) {
+        // 尝试不同的扩展策略
+        String[] prefixes = {"A", "B", "C", "D", "E"};
+        String[] suffixes = {"A", "B", "C", "D", "E"};
+
+        // 1. 尝试前缀扩展
+        for (String prefix : prefixes) {
+            String expandedRange = expandByPrefix(minCode, maxCode, prefix);
+            if (!isNumberRangeConflictWithChars(
+                expandedRange.split("-")[0], expandedRange.split("-")[1], eventId, excludeTeamId)) {
+                return expandedRange;
+            }
+        }
+
+        // 2. 尝试后缀扩展
+        for (String suffix : suffixes) {
+            String expandedRange = expandBySuffix(minCode, maxCode, suffix);
+            if (!isNumberRangeConflictWithChars(
+                expandedRange.split("-")[0], expandedRange.split("-")[1], eventId, excludeTeamId)) {
+                return expandedRange;
+            }
+        }
+
+        // 3. 尝试数字扩展
+        for (int i = 1; i <= 9; i++) {
+            String expandedRange = expandByPrefix(minCode, maxCode, String.valueOf(i));
+            if (!isNumberRangeConflictWithChars(
+                expandedRange.split("-")[0], expandedRange.split("-")[1], eventId, excludeTeamId)) {
+                return expandedRange;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 比较两个号码的ASCII值大小
+     *
+     * @param code1 号码1
+     * @param code2 号码2
+     * @return 比较结果:负数表示code1<code2,0表示相等,正数表示code1>code2
+     */
+    private int compareCodesByAscii(String code1, String code2) {
+        if (code1 == null && code2 == null) {
+            return 0;
+        }
+        if (code1 == null) {
+            return -1;
+        }
+        if (code2 == null) {
+            return 1;
+        }
+
+        // 按字符逐个比较ASCII值
+        int minLength = Math.min(code1.length(), code2.length());
+
+        for (int i = 0; i < minLength; i++) {
+            char char1 = code1.charAt(i);
+            char char2 = code2.charAt(i);
+
+            if (char1 != char2) {
+                return char1 - char2; // 返回ASCII值差
+            }
+        }
+
+        // 如果前面字符都相同,比较长度
+        return code1.length() - code2.length();
+    }
+
+    /**
+     * 对号码列表按ASCII值排序
+     *
+     * @param codes 号码列表
+     * @return 排序后的号码列表
+     */
+    private List<String> sortCodesByAscii(List<String> codes) {
+        return codes.stream()
+            .filter(StringUtils::isNotBlank)
+            .map(String::trim)
+            .sorted(this::compareCodesByAscii)
+            .collect(Collectors.toList());
+    }
+
+    /**
+     * 验证运动员号码格式(支持字符)
+     *
+     * @param athleteCode 运动员号码
+     * @return 是否有效
+     */
+    private boolean isValidAthleteCode(String athleteCode) {
+        if (StringUtils.isBlank(athleteCode)) {
+            return false;
+        }
+
+        String trimmedCode = athleteCode.trim();
+
+        // 检查长度
+        if (trimmedCode.length() < 1 || trimmedCode.length() > 15) {
+            return false;
+        }
+
+        // 允许字母、数字、连字符、下划线、点号
+        if (!trimmedCode.matches("^[A-Za-z0-9\\-_\\.]+$")) {
+            return false;
+        }
+
+        // 检查是否包含连续的特殊字符
+        if (trimmedCode.matches(".*[\\-_\\.]{2,}.*")) {
+            return false;
+        }
+
+        return true;
+    }
+
     /**
      * 创建运动员BO对象
      */
-    private GameAthleteBo createAthleteBo(EnrollProjectVo enrollInfo, Long eventId, Long teamId,
-                                          String teamName, AtomicInteger currentNumber, int width,
-                                          Map<String, Long> projectList) {
+    private GameAthleteBo createAthleteBo(EnrollProjectVo enrollInfo,
+                                            Long eventId, Long teamId,
+                                            String teamName,
+                                            AtomicInteger currentNumber, int width,
+                                            Map<String, Long> projectList) {
         GameAthleteBo gameAthleteBo = new GameAthleteBo();
         gameAthleteBo.setEventId(eventId);
         gameAthleteBo.setTeamId(teamId);
         gameAthleteBo.setTeamName(teamName);
 
-        // 分配编号
-        int assignedNumber = currentNumber.getAndIncrement();
-        String formattedCode = String.format("%0" + width + "d", assignedNumber);
-        gameAthleteBo.setAthleteCode(formattedCode);
+        // 号码处理逻辑
+        String athleteCode;
+        if (StringUtils.isNotBlank(enrollInfo.getAthleteCode())) {
+            // 如果Excel中有号码,使用Excel中的号码
+            athleteCode = enrollInfo.getAthleteCode().trim();
+
+            // 验证号码格式
+            if (!isValidAthleteCode(athleteCode)) {
+                log.warn("运动员{}的号码格式不正确: {},将使用自动生成的号码", enrollInfo.getName(), athleteCode);
+                // 如果号码格式不正确,使用自动生成的号码
+                if (currentNumber != null) {
+                    int assignedNumber = currentNumber.getAndIncrement();
+                    athleteCode = String.format("%0" + width + "d", assignedNumber);
+                } else {
+                    // 字符号码段情况下,生成一个基于序号的号码
+                    athleteCode = "AUTO_" + (System.currentTimeMillis() % 10000);
+                }
+            }
+        } else {
+            // 如果Excel中没有号码,自动生成
+            if (currentNumber != null) {
+                int assignedNumber = currentNumber.getAndIncrement();
+                athleteCode = String.format("%0" + width + "d", assignedNumber);
+            } else {
+                // 字符号码段情况下,生成一个基于序号的号码
+                athleteCode = "AUTO_" + (System.currentTimeMillis() % 10000);
+            }
+        }
+
+        gameAthleteBo.setAthleteCode(athleteCode);
 
         gameAthleteBo.setName(enrollInfo.getName());
         gameAthleteBo.setGender(enrollInfo.getSex());
@@ -754,4 +988,289 @@ public class IEnrollServiceImpl implements IEnrollService {
     }
 
     // endregion 辅助方法
+
+    /**
+     * 检查号码是否已存在
+     *
+     * @param athleteCode 运动员号码
+     * @param eventId 赛事ID
+     * @return 是否已存在
+     */
+    private boolean isAthleteCodeExists(String athleteCode, Long eventId) {
+        if (StringUtils.isBlank(athleteCode)) {
+            return false;
+        }
+
+        GameAthleteBo bo = new GameAthleteBo();
+        bo.setEventId(eventId);
+        bo.setAthleteCode(athleteCode);
+
+        List<GameAthleteVo> existingAthletes = gameAthleteService.queryList(bo);
+        return existingAthletes.size() > 0;
+    }
+
+    /**
+     * 校验基本数据完整性
+     */
+    private List<EnrollValidationError> validateBasicData(EnrollProjectVo enroll, int rowIndex) {
+        List<EnrollValidationError> errors = new ArrayList<>();
+
+        if (StringUtils.isBlank(enroll.getName())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setErrorType("MISSING_NAME");
+            error.setErrorMessage("运动员姓名不能为空");
+            errors.add(error);
+        }
+
+        if (StringUtils.isBlank(enroll.getTeamName())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setErrorType("MISSING_TEAM");
+            error.setErrorMessage("队伍名称不能为空");
+            errors.add(error);
+        }
+
+        // 性别校验 - 使用字典工具验证
+        if (StringUtils.isBlank(enroll.getSex())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("MISSING_GENDER");
+            error.setErrorMessage("性别不能为空");
+            errors.add(error);
+        } else if (!GenderDictUtils.isValidGender(enroll.getSex())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("INVALID_GENDER");
+            error.setErrorMessage("性别值无效,请填写'男'、'女'、'1'或'2'");
+            errors.add(error);
+        }
+
+        return errors;
+    }
+
+    @Override
+    public EnrollValidationResult validateEnrollData(List<EnrollProjectVo> enrollList, Long eventId) {
+        EnrollValidationResult result = new EnrollValidationResult();
+        result.setValid(true);
+        result.setErrors(new ArrayList<>());
+        result.setValidData(new ArrayList<>());
+
+        if (enrollList == null || enrollList.isEmpty()) {
+            log.warn("导入数据为空,赛事ID: {}", eventId);
+            return result;
+        }
+
+        if (eventId == null) {
+            log.error("赛事ID不能为空");
+            throw new IllegalArgumentException("赛事ID不能为空");
+        }
+
+        try {
+            // 1. 获取赛事配置:每人限报项目数
+            Integer maxProjectsPerPerson = getMaxProjectsPerPerson(eventId);
+
+            // 2. 获取项目限制信息
+            Map<String, Integer> projectLimits = getProjectLimits(eventId);
+
+            // 3. 统计每个项目的当前报名人数
+            Map<String, Integer> currentProjectCounts = getCurrentProjectCounts(eventId);
+
+            // 4. 校验每个运动员
+            for (int i = 0; i < enrollList.size(); i++) {
+                EnrollProjectVo enroll = enrollList.get(i);
+                // 先进行基本数据校验
+                List<EnrollValidationError> basicErrors = validateBasicData(enroll, i);
+                if (!basicErrors.isEmpty()) {
+                    result.setValid(false);
+                    result.getErrors().addAll(basicErrors);
+                    continue; // 基本数据有问题,跳过后续校验
+                }
+
+                List<EnrollValidationError> athleteErrors = validateAthlete(eventId, enroll, i,
+                    maxProjectsPerPerson, projectLimits, currentProjectCounts);
+
+                if (athleteErrors.isEmpty()) {
+                    result.getValidData().add(enroll);
+                    // 更新项目计数(模拟保存后的状态)
+                    updateProjectCounts(enroll, currentProjectCounts);
+                } else {
+                    result.setValid(false);
+                    result.getErrors().addAll(athleteErrors);
+                }
+            }
+        } catch (Exception e) {
+            log.error("校验导入数据时发生异常,赛事ID: {}", eventId, e);
+            result.setValid(false);
+            EnrollValidationError error = new EnrollValidationError();
+            error.setErrorType("SYSTEM_ERROR");
+            error.setErrorMessage("系统校验异常:" + e.getMessage());
+            result.getErrors().add(error);
+        }
+
+        return result;
+    }
+
+    @Override
+    public EnrollImportResult importDataWithValidation(MultipartFile file, Long eventId) {
+        // 1. 解析数据
+        List<EnrollProjectVo> enrollList = parseData(file);
+
+        // 2. 校验数据
+        EnrollValidationResult validationResult = validateEnrollData(enrollList, eventId);
+
+        // 3. 保存有效数据
+        boolean saveSuccess = false;
+        if (!validationResult.getValidData().isEmpty()) {
+            saveSuccess = saveEnrollData(validationResult.getValidData(), eventId);
+        }
+
+        // 4. 返回结果
+        EnrollImportResult result = new EnrollImportResult();
+        result.setSuccess(saveSuccess);
+        result.setValidationResult(validationResult);
+        result.setTotalCount(enrollList.size());
+        result.setValidCount(validationResult.getValidData().size());
+        result.setErrorCount(validationResult.getErrors().size());
+
+        return result;
+    }
+
+    /**
+     * 获取每人限报项目数
+     */
+    private Integer getMaxProjectsPerPerson(Long eventId) {
+        GameEventVo event = gameEventService.queryById(eventId);
+        return event.getLimitApplication() != null ? event.getLimitApplication() : 0; // 默认0个
+    }
+
+    /**
+     * 获取项目限制信息
+     */
+    private Map<String, Integer> getProjectLimits(Long eventId) {
+        List<GameEventProjectVo> projects = gameEventProjectService.queryListByEventId(eventId);
+        return projects.stream()
+            .filter(p -> p.getLimitPerson() != null && p.getLimitPerson() > 0)
+            .collect(Collectors.toMap(
+                GameEventProjectVo::getProjectName,
+                GameEventProjectVo::getLimitPerson
+            ));
+    }
+
+    /**
+     * 获取当前项目报名人数
+     */
+    private Map<String, Integer> getCurrentProjectCounts(Long eventId) {
+        GameAthleteBo bo = new GameAthleteBo();
+        bo.setEventId(eventId);
+        List<GameAthleteVo> athletes = gameAthleteService.queryList(bo);
+        Map<String, Integer> counts = new HashMap<>();
+
+        for (GameAthleteVo athlete : athletes) {
+            if (StringUtils.isNotBlank(athlete.getProjectValue())) {
+                List<Long> projectIds = JSONUtil.toList(athlete.getProjectValue(), Long.class);
+                for (Long projectId : projectIds) {
+                    GameEventProjectVo project = gameEventProjectService.queryById(projectId);
+                    if (project != null) {
+                        counts.merge(project.getProjectName(), 1, Integer::sum);
+                    }
+                }
+            }
+        }
+
+        return counts;
+    }
+
+    /**
+     * 校验单个运动员
+     */
+    private List<EnrollValidationError> validateAthlete(Long eventId, EnrollProjectVo enroll, int rowIndex,
+                                                        Integer maxProjectsPerPerson, Map<String, Integer> projectLimits,
+                                                        Map<String, Integer> currentProjectCounts) {
+
+        List<EnrollValidationError> errors = new ArrayList<>();
+
+        // 号码冲突检查
+        if (StringUtils.isNotBlank(enroll.getAthleteCode())) {
+            if (isAthleteCodeExists(enroll.getAthleteCode(), eventId)) {
+                EnrollValidationError error = new EnrollValidationError();
+                error.setRowIndex(rowIndex + 1);
+                error.setAthleteName(enroll.getName());
+                error.setTeamName(enroll.getTeamName());
+                error.setErrorType("DUPLICATE_ATHLETE_CODE");
+                error.setErrorMessage(String.format("运动员号码'%s'已存在", enroll.getAthleteCode()));
+                errors.add(error);
+            }
+        }
+
+        // 检查项目选择数据是否为空
+        if (enroll.getProjectSelections() == null || enroll.getProjectSelections().isEmpty()) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("NO_PROJECT_SELECTED");
+            error.setErrorMessage("该运动员未选择任何项目");
+            errors.add(error);
+            return errors; // 如果没有选择项目,直接返回
+        }
+
+        // 1. 校验每人限报项目数
+        long selectedProjectCount = enroll.getProjectSelections().values().stream()
+            .mapToLong(selected -> selected ? 1 : 0)
+            .sum();
+
+        if (maxProjectsPerPerson > 0 && selectedProjectCount > maxProjectsPerPerson) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1); // 从1开始计数
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("PERSON_LIMIT_EXCEEDED");
+            error.setErrorMessage(String.format("该运动员选择了%d个项目,超出每人限报%d个项目",
+                selectedProjectCount, maxProjectsPerPerson));
+            errors.add(error);
+        }
+
+        // 2. 校验项目限报人数
+        for (Map.Entry<String, Boolean> entry : enroll.getProjectSelections().entrySet()) {
+            if (entry.getValue()) { // 如果选择了该项目
+                String projectName = entry.getKey();
+                Integer projectLimit = projectLimits.get(projectName);
+
+                if (projectLimit != null && projectLimit > 0) {
+                    int currentCount = currentProjectCounts.getOrDefault(projectName, 0);
+                    if (currentCount >= projectLimit) {
+                        EnrollValidationError error = new EnrollValidationError();
+                        error.setRowIndex(rowIndex + 1);
+                        error.setAthleteName(enroll.getName());
+                        error.setTeamName(enroll.getTeamName());
+                        error.setErrorType("PROJECT_LIMIT_EXCEEDED");
+                        error.setProjectName(projectName);
+                        error.setErrorMessage(String.format("项目'%s'报名人数已达上限%d人",
+                            projectName, projectLimit));
+                        errors.add(error);
+                    }
+                }
+            }
+        }
+
+        return errors;
+    }
+
+    /**
+     * 更新项目计数(模拟保存后的状态)
+     */
+    private void updateProjectCounts(EnrollProjectVo enroll, Map<String, Integer> currentProjectCounts) {
+        for (Map.Entry<String, Boolean> entry : enroll.getProjectSelections().entrySet()) {
+            if (entry.getValue()) {
+                String projectName = entry.getKey();
+                currentProjectCounts.merge(projectName, 1, Integer::sum);
+            }
+        }
+    }
 }

+ 52 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/EventNameUtils.java

@@ -0,0 +1,52 @@
+package org.dromara.system.utils;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.dromara.system.domain.GameEvent;
+import org.dromara.system.domain.vo.GameEventVo;
+import org.dromara.system.mapper.GameEventMapper;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 赛事名称工具类
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Component
+@RequiredArgsConstructor
+public class EventNameUtils {
+
+    private final GameEventMapper gameEventMapper;
+
+    /**
+     * 根据赛事ID集合获取赛事名称映射
+     *
+     * @param eventIds 赛事ID集合
+     * @return 赛事ID到赛事名称的映射
+     */
+    public Map<Long, String> getEventNameMap(Set<Long> eventIds) {
+        if (eventIds == null || eventIds.isEmpty()) {
+            return Map.of();
+        }
+        
+        List<GameEventVo> events = gameEventMapper.selectVoList(
+            Wrappers.lambdaQuery(GameEvent.class)
+                .in(GameEvent::getEventId, eventIds)
+                .select(GameEvent::getEventId, GameEvent::getEventName)
+        );
+        
+        return events.stream()
+            .collect(Collectors.toMap(
+                GameEventVo::getEventId,
+                GameEventVo::getEventName,
+                (existing, replacement) -> existing
+            ));
+    }
+}

+ 123 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/GenderDictUtils.java

@@ -0,0 +1,123 @@
+package org.dromara.system.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.service.DictService;
+import org.dromara.common.core.utils.SpringUtils;
+import org.dromara.common.core.utils.StringUtils;
+
+/**
+ * 性别字典转换工具类
+ */
+@Slf4j
+public class GenderDictUtils {
+    private static final String GENDER_DICT_TYPE = "sys_user_sex";
+
+    /**
+     * 将性别文本转换为字典值
+     * 支持:男/女 -> 1/2,或者直接传入1/2
+     *
+     * @param genderText 性别文本(男、女、1、2等)
+     * @return 字典值(1或2)
+     */
+    public static String convertGenderToDictValue(String genderText) {
+        if (StringUtils.isBlank(genderText)) {
+            return null;
+        }
+
+        String trimmedText = genderText.trim();
+
+        // 如果已经是数字,直接返回
+        if (trimmedText.matches("^[12]$")) {
+            return trimmedText;
+        }
+
+        // 通过字典服务转换
+        try {
+            DictService dictService = SpringUtils.getBean(DictService.class);
+            String dictValue = dictService.getDictValue(GENDER_DICT_TYPE, trimmedText);
+
+            if (StringUtils.isNotBlank(dictValue)) {
+                return dictValue;
+            }
+        } catch (Exception e) {
+            log.warn("通过字典服务转换性别失败,使用默认映射: {}", e.getMessage());
+        }
+
+        // 如果字典服务转换失败,使用默认映射
+        return convertGenderByDefault(trimmedText);
+    }
+
+    /**
+     * 默认性别映射规则
+     *
+     * @param genderText 性别文本
+     * @return 字典值
+     */
+    private static String convertGenderByDefault(String genderText) {
+        switch (genderText) {
+            case "男":
+            case "男性":
+            case "M":
+            case "male":
+            case "Male":
+                return "1";
+            case "女":
+            case "女性":
+            case "F":
+            case "female":
+            case "Female":
+                return "2";
+            default:
+                log.warn("无法识别的性别值: {}", genderText);
+                return null;
+        }
+    }
+
+    /**
+     * 将字典值转换为性别文本
+     *
+     * @param dictValue 字典值(0或1)
+     * @return 性别文本
+     */
+    public static String convertDictValueToGender(String dictValue) {
+        if (StringUtils.isBlank(dictValue)) {
+            return null;
+        }
+
+        try {
+            DictService dictService = SpringUtils.getBean(DictService.class);
+            String dictLabel = dictService.getDictLabel(GENDER_DICT_TYPE, dictValue);
+
+            if (StringUtils.isNotBlank(dictLabel)) {
+                return dictLabel;
+            }
+        } catch (Exception e) {
+            log.warn("通过字典服务转换性别标签失败,使用默认映射: {}", e.getMessage());
+        }
+
+        // 默认映射
+        switch (dictValue) {
+            case "1":
+                return "男";
+            case "2":
+                return "女";
+            default:
+                return dictValue;
+        }
+    }
+
+    /**
+     * 验证性别值是否有效
+     *
+     * @param genderValue 性别值
+     * @return 是否有效
+     */
+    public static boolean isValidGender(String genderValue) {
+        if (StringUtils.isBlank(genderValue)) {
+            return false;
+        }
+
+        String dictValue = convertGenderToDictValue(genderValue);
+        return "1".equals(dictValue) || "2".equals(dictValue);
+    }
+}

+ 21 - 11
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/QRCodeUtils.java

@@ -22,6 +22,8 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.*;
 
+import static cn.dev33.satoken.SaManager.log;
+
 /**
  * 二维码生成工具类
  *
@@ -151,17 +153,25 @@ public class QRCodeUtils {
                 Map<String, Object> projectList = new HashMap<>();
                 for (Long pid : projectIds) {
                     if (pid == null) continue;
-                    GameEventProjectVo pvo = gameEventProjectService.queryById(pid);
-                    if (pvo != null) {
-                        Map<String, Object> project = new HashMap<>();
-                        project.put("projectType", pvo.getProjectType());
-                        project.put("projectName", pvo.getProjectName());
-                        project.put("classification", pvo.getClassification());
-                        project.put("orderType", pvo.getOrderType());
-                        project.put("gradeRule", pvo.getScoreRule());
-                        project.put("admissionRank",pvo.getRoundType());
-                        project.put("admissionPoint",pvo.getScoreValue());
-                        projectList.put(pvo.getProjectId().toString(), project);
+                    try {
+                        GameEventProjectVo pvo = gameEventProjectService.queryById(pid);
+                        if (pvo != null) {
+                            Map<String, Object> project = new HashMap<>();
+                            project.put("projectType", pvo.getProjectType());
+                            project.put("projectName", pvo.getProjectName());
+                            project.put("classification", pvo.getClassification());
+                            project.put("orderType", pvo.getOrderType());
+                            project.put("gradeRule", pvo.getScoreRule());
+                            project.put("admissionRank",pvo.getRoundType());
+                            project.put("admissionPoint",pvo.getScoreValue());
+                            projectList.put(pvo.getProjectId().toString(), project);
+                        }else{
+                            // 记录日志,项目不存在
+                            log.warn("项目ID {} 不存在,跳过处理", pid);
+                        }
+                    } catch (Exception e) {
+                        // 记录异常但不中断处理
+                        log.error("查询项目ID {} 失败: {}", pid, e.getMessage());
                     }
                 }
                 simpleData.put("projectList", projectList);

BIN
ruoyi-modules/ruoyi-game-event/src/main/resources/template/enroll_template.xlsx