4 次代碼提交 5eecec1af4 ... f2c7bc6dea

作者 SHA1 備註 提交日期
  zhou f2c7bc6dea feat(app): 为移动端应用增加赛事和项目管理功能 2 周之前
  zhou 9b4ab35847 feat(app): 为移动端应用增加赛事和项目管理功能 2 周之前
  zhou 1475ade8d9 fix(game): 修复游戏事件配置类型设置问题 2 周之前
  zhou ea4ed2f0b9 feat(game): 新增客户端赛事项目保存功能 2 周之前
共有 21 個文件被更改,包括 1199 次插入64 次删除
  1. 85 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/app_client/ToClientController.java
  2. 6 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameAthlete.java
  3. 9 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameEventProject.java
  4. 134 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/ClientProjectSaveBo.java
  5. 19 13
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameAthleteBo.java
  6. 6 1
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameEventProjectBo.java
  7. 29 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/ScoreSubmitBo.java
  8. 23 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/ScoreSubmitDetailBo.java
  9. 40 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/ScoreSubmitItemBo.java
  10. 16 12
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameAthleteVo.java
  11. 23 17
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameEventProjectVo.java
  12. 33 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/ScoreSheetDetailVo.java
  13. 40 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/ScoreSheetItemVo.java
  14. 47 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/ScoreSheetVo.java
  15. 10 8
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameEventProjectMapper.java
  16. 23 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/app/ToClientMapper.java
  17. 44 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/app/IToClientService.java
  18. 5 3
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameScoreServiceImpl.java
  19. 531 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/app/ToClientServiceImpl.java
  20. 9 6
      ruoyi-modules/ruoyi-game-event/src/main/resources/mapper/system/GameEventProjectMapper.xml
  21. 67 0
      ruoyi-modules/ruoyi-game-event/src/main/resources/mapper/system/app/ToClientMapper.xml

+ 85 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/app_client/ToClientController.java

@@ -0,0 +1,85 @@
+package org.dromara.system.controller.app_client;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.system.domain.GameEvent;
+import org.dromara.system.domain.GameEventProject;
+import org.dromara.system.domain.bo.ClientProjectSaveBo;
+import org.dromara.system.domain.bo.ScoreSubmitBo;
+import org.dromara.system.domain.vo.ScoreSheetVo;
+import org.dromara.system.service.app.IToClientService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 面向app客户端
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/app/toClient")
+public class ToClientController {
+
+    private final IToClientService toClientService;
+
+    /**
+     * 赛事列表项添加/修改接口 (接口4)
+     */
+    @Log(title = "app客户端添加/修改赛事项", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PostMapping("/eventSaveOrUpdate")
+    public R<Void> eventSaveOrUpdate(@Validated @RequestBody ClientProjectSaveBo bo) {
+        toClientService.saveOrUpdateEventFromClient(bo);
+        return R.ok();
+    }
+
+    /**
+     * 赛事列表接口 (接口2)
+     */
+    @GetMapping("/eventList")
+    public R<List<GameEvent>> eventList() {
+        return R.ok(toClientService.getEventList());
+    }
+
+    /**
+     * 赛事列表项删除接口 (接口3)
+     */
+    @Log(title = "app客户端删除赛事项", businessType = BusinessType.DELETE)
+    @DeleteMapping("/eventRemove/{eventId}")
+    public R<Void> eventRemove(@PathVariable Long eventId) {
+        toClientService.removeEvent(eventId);
+        return R.ok("删除任务已提交,正在后台处理");
+    }
+
+    /**
+     * 项目列表接口 (接口5)
+     */
+    @GetMapping("/projectList")
+    public R<List<GameEventProject>> projectList(@RequestParam Long eventId) {
+        return R.ok(toClientService.getProjectList(eventId));
+    }
+
+    /**
+     * 成绩记录单展示 (接口6 展示)
+     */
+    @GetMapping("/scoreSheet")
+    public R<ScoreSheetVo> scoreSheet(@RequestParam Long eventId, @RequestParam Long projectId) {
+        return R.ok(toClientService.getScoreSheet(eventId, projectId));
+    }
+
+    /**
+     * 成绩记录单提交 (接口6 提交)
+     */
+    @Log(title = "app客户端提交成绩单", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PostMapping("/scoreSubmit")
+    public R<Void> scoreSubmit(@Validated @RequestBody ScoreSubmitBo bo) {
+        toClientService.submitScoreSheet(bo);
+        return R.ok();
+    }
+}

+ 6 - 2
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameAthlete.java

@@ -24,7 +24,7 @@ public class GameAthlete extends TenantEntity {
     /**
      * 主键
      */
-    @TableId(value = "athlete_id",type = IdType.AUTO)
+    @TableId(value = "athlete_id", type = IdType.AUTO)
     private Long athleteId;
 
     /**
@@ -102,6 +102,11 @@ public class GameAthlete extends TenantEntity {
      */
     private String projectValue;
 
+    /**
+     * 参赛序号/道次(app端录入成绩时需要存储的字段)
+     */
+    private Long trackIndex;
+
     /**
      * 状态(0正常 1停用)
      */
@@ -118,5 +123,4 @@ public class GameAthlete extends TenantEntity {
      */
     private String remark;
 
-
 }

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

@@ -39,7 +39,9 @@ public class GameEventProject extends TenantEntity {
     private String projectName;
 
     /**
-     * 项目类型(个人/团体)
+     * 项目类型--参考字典game_project_type,一般不修改
+     * (如1-径赛,2-田赛,3-趣味个人,4-趣味集体,5-体侧。6;领导男子,7-领导女子,8-赛前拔河,9-径赛集体)
+     * 如新增的项目类型,值顺延,如10-xxx1,11-xxx2,...
      */
     private String projectType;
 
@@ -162,7 +164,12 @@ public class GameEventProject extends TenantEntity {
     /**
      * 参赛组别
      */
-    private String groups;
+    private String rgName;
+
+    /**
+     * 排名分组ID
+     */
+    private Long rgId;
 
     /**
      * 限报男生人数 (个人项目为总人数 / 团体项目为每队人数)

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

@@ -0,0 +1,134 @@
+package org.dromara.system.domain.bo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 客户端赛事项目保存业务对象
+ *
+ * @author zlt
+ * @date 2025-08-18
+ */
+@Data
+public class ClientProjectSaveBo {
+
+    /**
+     * 赛事名称
+     */
+    @NotBlank(message = "赛事名称不能为空")
+    private String eventName;
+
+    /**
+     * 赛事编号
+     */
+    @NotBlank(message = "赛事编号不能为空")
+    private String eventCode;
+
+    /**
+     * 机器编号
+     */
+    private String machineCode;
+
+    /**
+     * 项目类型--参考字典game_project_type,一般不修改
+     * (如1-径赛,2-田赛,3-趣味个人,4-趣味集体,5-体侧。6;领导男子,7-领导女子,8-赛前拔河,9-径赛集体)
+     * 如新增的项目类型,值顺延,如10-xxx1,11-xxx2,...
+     */
+    private String projectType;
+
+    /**
+     * 归类(0个人项目/1团体项目)
+     */
+    @NotBlank(message = "归类不能为空")
+    private String classification;
+
+    /**
+     * 项目内容(项目名称列表)
+     */
+    @NotEmpty(message = "项目内容不能为空")
+    private List<String> projectNames;
+
+    /**
+     * 成绩类型--字典game_score_type
+     * 1-计时类;2-距离类;3-单次计数类;4-多次计数类;5-排名类;6-远度距离类;7-高度距离类
+     */
+    private String scoreRule;
+    /**
+     * 计时格式 0---00:00:00.000, 1---00:00.00
+     */
+    // @ExcelProperty(value = "计时格式")
+    private String timingFormat;
+
+    /**
+     * 距离模式(0:单轮最高, 1:双轮最高)
+     */
+    // @ExcelProperty(value = "距离模式")
+    private String distanceMode;
+
+    /**
+     * 计数单位
+     */
+    // @ExcelProperty(value = "计数单位")
+    private String countUnit;
+
+    /**
+     * 成绩数量
+     */
+    // @ExcelProperty(value = "成绩数量")
+    private Integer scoreCount;
+
+    /**
+     * 性别---字典sys_group_sex
+     * 1-男;2-女;3-混合;4-其他
+     */
+    private String gender;
+
+    /**
+     * 参赛组别(队伍排名分组)
+     * 没有就新增
+     */
+    private String rgName;
+
+    /**
+     * 组别ID
+     */
+    private Long rgId;
+
+    /**
+     * 比赛阶段--字典game_stage
+     * 1-预赛;2-决赛;3-复赛
+     */
+    private String gameStage;
+
+    /**
+     * 轮次--字典game_round
+     * 1-一轮;2-二轮
+     */
+    private String gameRound;
+
+    /**
+     * 排名方式---字典game_order_type
+     * 0-升序(1...9);1-降序(9...1);2-失误次数A(1...9);3-失误次数B(9...1);4-求和 ;5-最大值;6-最小值;7-平均值
+     * 多个用英文逗号分隔
+     */
+    private String orderType;
+
+    /**
+     * 录取名次(以英文逗号分隔)
+     */
+    private String roundType;
+
+    /**
+     * 赛事提示
+     */
+    private String eventTip;
+
+    /**
+     * 上传成绩路径
+     */
+    private String uploadPath;
+
+}

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

@@ -30,7 +30,7 @@ public class GameAthleteBo extends BaseEntity {
     /**
      * 用户ID
      */
-//    @NotNull(message = "用户ID不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotNull(message = "用户ID不能为空", groups = { AddGroup.class, EditGroup.class })
     private Long userId;
 
     /**
@@ -46,7 +46,7 @@ public class GameAthleteBo extends BaseEntity {
     /**
      * 队伍ID
      */
-//    @NotNull(message = "队伍ID不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotNull(message = "队伍ID不能为空", groups = { AddGroup.class, EditGroup.class })
     private Long teamId;
 
     /**
@@ -63,7 +63,7 @@ public class GameAthleteBo extends BaseEntity {
     /**
      * 姓名
      */
-//    @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;
 
     /**
@@ -86,51 +86,58 @@ public class GameAthleteBo extends BaseEntity {
     /**
      * 证件号
      */
-//    @NotBlank(message = "证件号不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "证件号不能为空", groups = { AddGroup.class, EditGroup.class })
     private String idCard;
 
     /**
      * 芯片号
      */
-//    @NotBlank(message = "芯片号不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "芯片号不能为空", groups = { AddGroup.class, EditGroup.class })
     private String chipCode;
 
     /**
      * 手机号
      */
-//    @NotBlank(message = "手机号不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "手机号不能为空", groups = { AddGroup.class, EditGroup.class })
     private String phone;
 
     /**
      * 居住地址
      */
-//    @NotBlank(message = "居住地址不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "居住地址不能为空", groups = { AddGroup.class, EditGroup.class })
     private String location;
 
     /**
      * T恤尺码
      */
-//    @NotBlank(message = "T恤尺码不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "T恤尺码不能为空", groups = { AddGroup.class, EditGroup.class })
     private String tshirtSize;
 
     /**
      * 组别
      */
-//    @NotBlank(message = "组别不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "组别不能为空", groups = { AddGroup.class, EditGroup.class })
     private String groupType;
 
     /**
      * 参与项目列表
      */
-//    @NotBlank(message = "参与项目列表不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "参与项目列表不能为空", groups = { AddGroup.class, EditGroup.class
+    // })
     private Long projectId;
     private String projectValue;
     private List<Long> projectList;
 
+    /**
+     * 参赛序号/道次(app端录入成绩时需要存储的字段)
+     */
+    private Long trackIndex;
+
     /**
      * 状态(0正常 1停用)
      */
-//    @NotBlank(message = "状态(0正常 1停用)不能为空", groups = { AddGroup.class, EditGroup.class })
+    // @NotBlank(message = "状态(0正常 1停用)不能为空", groups = { AddGroup.class,
+    // EditGroup.class })
     private String status;
 
     /**
@@ -138,5 +145,4 @@ public class GameAthleteBo extends BaseEntity {
      */
     private String remark;
 
-
 }

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

@@ -179,7 +179,12 @@ public class GameEventProjectBo extends BaseEntity {
     /**
      * 参赛组别
      */
-    private String groups;
+    private String rgName;
+
+    /**
+     * 排名分组ID
+     */
+    private Long rgId;
 
     /**
      * 限报男生人数 (个人项目为总人数 / 团体项目为每队人数)

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

@@ -0,0 +1,29 @@
+package org.dromara.system.domain.bo;
+
+import lombok.Data;
+import jakarta.validation.constraints.NotNull;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 成绩记录单提交业务对象
+ */
+@Data
+public class ScoreSubmitBo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 赛事ID */
+    @NotNull(message = "赛事ID不能为空")
+    private Long eventId;
+
+    /** 项目ID */
+    @NotNull(message = "项目ID不能为空")
+    private Long projectId;
+
+    /** 提交项列表 */
+    private List<ScoreSubmitItemBo> items;
+}

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

@@ -0,0 +1,23 @@
+package org.dromara.system.domain.bo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 成绩记录单提交明细业务对象
+ */
+@Data
+public class ScoreSubmitDetailBo implements Serializable {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 明细ID */
+    private Long detailId;
+    /** 尝试序号 */
+    private Integer attemptIndex;
+    /** 成绩值 */
+    private BigDecimal performanceValue;
+}

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

@@ -0,0 +1,40 @@
+package org.dromara.system.domain.bo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 成绩记录单提交项业务对象
+ */
+@Data
+public class ScoreSubmitItemBo implements Serializable {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 运动员ID */
+    private Long athleteId;
+    /** 队伍ID */
+    private Long teamId;
+    /** 队员号码 */
+    private String athleteCode;
+    /** 队员姓名 */
+    private String name;
+    /** 队伍名称 */
+    private String teamName;
+    /** 参赛序号 / 道次 */
+    private Long trackIndex;
+    /** 成绩ID (APP传回已有ID以避免重复创建) */
+    private Long scoreId;
+    /** 成绩 (支持复杂格式字符串) */
+    private String score;
+    /** 失误次数A */
+    private Integer faultA;
+    /** 失误次数B */
+    private Integer faultB;
+
+    /** 成绩明细列表 (APP传回已有明细ID以避免冗余) */
+    private List<ScoreSubmitDetailBo> details;
+}

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

@@ -12,7 +12,6 @@ import java.io.Serial;
 import java.io.Serializable;
 import java.util.List;
 
-
 /**
  * 参赛队员视图对象 game_athlete
  *
@@ -30,7 +29,7 @@ public class GameAthleteVo implements Serializable {
     /**
      * 主键
      */
-//     @ExcelProperty(value = "Id")
+    // @ExcelProperty(value = "Id")
     private Long athleteId;
 
     /**
@@ -54,7 +53,7 @@ public class GameAthleteVo implements Serializable {
     /**
      * 队伍ID
      */
-//    @ExcelProperty(value = "队伍ID")
+    // @ExcelProperty(value = "队伍ID")
     private Long teamId;
 
     /**
@@ -91,19 +90,19 @@ public class GameAthleteVo implements Serializable {
     /**
      * 单位
      */
-//    @ExcelProperty(value = "单位")
+    // @ExcelProperty(value = "单位")
     private String unit;
 
     /**
      * 证件号
      */
-//    @ExcelProperty(value = "证件号")
+    // @ExcelProperty(value = "证件号")
     private String idCard;
 
     /**
      * 芯片号
      */
-//    @ExcelProperty(value = "芯片号")
+    // @ExcelProperty(value = "芯片号")
     private String chipCode;
 
     /**
@@ -115,25 +114,25 @@ public class GameAthleteVo implements Serializable {
     /**
      * 居住地址
      */
-//    @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;
     /**
@@ -148,10 +147,16 @@ public class GameAthleteVo implements Serializable {
     @ExcelProperty(value = "队伍")
     private String teamName;
 
+    /**
+     * 参赛序号/道次(app端录入成绩时需要存储的字段)
+     */
+    // @ExcelProperty(value = "参赛序号/道次")
+    private Long trackIndex;
+
     /**
      * 状态(0正常 1停用)
      */
-//    @ExcelProperty(value = "状态", converter = ExcelDictConvert.class)
+    // @ExcelProperty(value = "状态", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "game_event_status")
     private String status;
 
@@ -161,5 +166,4 @@ public class GameAthleteVo implements Serializable {
     @ExcelProperty(value = "备注")
     private String remark;
 
-
 }

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

@@ -15,7 +15,6 @@ import java.io.Serial;
 import java.io.Serializable;
 import java.util.List;
 
-
 /**
  * 赛事项目视图对象 game_event_project
  *
@@ -39,13 +38,13 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 赛事ID
      */
-//    @ExcelProperty(value = "赛事ID")
+    // @ExcelProperty(value = "赛事ID")
     private Long eventId;
 
     /**
      * 赛事ID
      */
-//    @ExcelProperty(value = "赛事名称")
+    // @ExcelProperty(value = "赛事名称")
     private String eventName;
 
     /**
@@ -55,7 +54,9 @@ public class GameEventProjectVo implements Serializable {
     private String projectName;
 
     /**
-     * 项目类型(个人/团体)
+     * 项目类型--参考字典game_project_type,一般不修改
+     * (如1-径赛,2-田赛,3-趣味个人,4-趣味集体,5-体侧。6;领导男子,7-领导女子,8-赛前拔河,9-径赛集体)
+     * 如新增的项目类型,值顺延,如10-xxx1,11-xxx2,...
      */
     @ExcelProperty(value = "项目类型", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "game_project_type")
@@ -94,8 +95,13 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 参赛组别
      */
-//    @ExcelProperty(value = "参赛组别")
-    private String groups;
+    @ExcelProperty(value = "参赛组别")
+    private String rgName;
+
+    /**
+     * 排名分组ID
+     */
+    private Long rgId;
 
     /**
      * 比赛阶段
@@ -172,69 +178,69 @@ public class GameEventProjectVo implements Serializable {
     /**
      * 更新时间
      */
-//    @ExcelProperty(value = "更新时间")
+    // @ExcelProperty(value = "更新时间")
     @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
 
     /**
      * 项目限报人数
      */
-//    @ExcelProperty(value = "项目限报人数")
+    // @ExcelProperty(value = "项目限报人数")
     private Integer limitPerson;
 
     /**
      * 参赛组数
      */
-//    @ExcelProperty(value = "参赛组数")
+    // @ExcelProperty(value = "参赛组数")
     private Long groupNum;
 
     /**
      * 参赛人数
      */
-//    @ExcelProperty(value = "参赛人数")
+    // @ExcelProperty(value = "参赛人数")
     private Long participateNum;
 
     /**
      * 计时格式
      */
-//    @ExcelProperty(value = "计时格式")
+    // @ExcelProperty(value = "计时格式")
     private String timingFormat;
 
     /**
      * 距离模式
      */
-//    @ExcelProperty(value = "距离模式")
+    // @ExcelProperty(value = "距离模式")
     private String distanceMode;
 
     /**
      * 计数单位
      */
-//    @ExcelProperty(value = "计数单位")
+    // @ExcelProperty(value = "计数单位")
     private String countUnit;
 
     /**
      * 成绩数量
      */
-//    @ExcelProperty(value = "成绩数量")
+    // @ExcelProperty(value = "成绩数量")
     private Integer scoreCount;
 
     /**
      * 奖项
      */
-//    @ExcelProperty(value = "奖项")
+    // @ExcelProperty(value = "奖项")
     private String award;
 
     /**
      * 状态(0正常 1停用)
      */
-//    @ExcelProperty(value = "状态", converter = ExcelDictConvert.class)
+    // @ExcelProperty(value = "状态", converter = ExcelDictConvert.class)
     @ExcelDictFormat(dictType = "game_event_status")
     private String status;
 
     /**
      * 备注
      */
-//    @ExcelProperty(value = "备注")
+    // @ExcelProperty(value = "备注")
     private String remark;
 
     /**

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

@@ -0,0 +1,33 @@
+package org.dromara.system.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 成绩记录明细视图对象
+ */
+@Data
+public class ScoreSheetDetailVo implements Serializable {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 明细ID */
+    private Long detailId;
+    /** 成绩ID */
+    private Long scoreId;
+    /** 第attemptIndex轮的成绩 */
+    private Integer attemptIndex;
+    /** 成绩值 */
+    private String performanceValue;
+    /** 失误A */
+    private Integer faultA;
+    /** 失误B */
+    private Integer faultB;
+    /** 运动员ID (逻辑关联使用) */
+    private Long athleteId;
+    /** 队伍ID (逻辑关联使用) */
+    private Long teamId;
+}

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

@@ -0,0 +1,40 @@
+package org.dromara.system.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 成绩记录单项视图对象
+ */
+@Data
+public class ScoreSheetItemVo implements Serializable {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 运动员ID */
+    private Long athleteId;
+    /** 队伍ID */
+    private Long teamId;
+    /** 队员号码 */
+    private String athleteCode;
+    /** 队员姓名 */
+    private String name;
+    /** 队伍名称 */
+    private String teamName;
+    /** 参赛序号 / 道次 */
+    private Long trackIndex;
+    /** 成绩ID */
+    private Long scoreId;
+    /** 成绩 */
+    private String score;
+    /** 失误次数A */
+    private Integer faultA;
+    /** 失误次数B */
+    private Integer faultB;
+
+    /** 成绩明细列表 */
+    private List<ScoreSheetDetailVo> details;
+}

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

@@ -0,0 +1,47 @@
+package org.dromara.system.domain.vo;
+
+import lombok.Data;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 成绩记录单展示视图对象
+ */
+@Data
+public class ScoreSheetVo implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 赛事名称 */
+    private String eventName;
+    /** 计算规则 */
+    private String scoreRule;
+    /** 项目名称 */
+    private String projectName;
+    /** 项目类型 */
+    private String projectType;
+    /** 项目分类 (0个人 1团体) */
+    private String classification;
+    /** 参赛性别 */
+    private String gender;
+    /** 参赛组别 */
+    private String rgName;
+    /** 比赛阶段 */
+    private String gameStage;
+    /** 比赛轮次 */
+    private String gameRound;
+    /** 完赛人数/对数 */
+    private Integer finishedParticipants;
+    /** 报名人数/对数 */
+    private Integer totalParticipants;
+    /** 比赛时间 */
+    private String startTime;
+    /** 裁判员 */
+    private String refereeName;
+    /** 计时格式 */
+    private String timingFormat;
+    /** 赛事提示 */
+    private String eventTip;
+
+    /** 队员列表 */
+    private List<ScoreSheetItemVo> list;
+}

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

@@ -29,10 +29,11 @@ public interface GameEventProjectMapper extends BaseMapperPlus<GameEventProject,
      * @return 分页的赛事信息
      */
     @DataPermission({
-        @DataColumn(key = "deptName", value = "create_dept"),
-        @DataColumn(key = "userName", value = "create_by")
+            @DataColumn(key = "deptName", value = "create_dept"),
+            @DataColumn(key = "userName", value = "create_by")
     })
-    default Page<GameEventProjectVo> selectPageEventProjectList(Page<GameEventProject> page, Wrapper<GameEventProject> queryWrapper) {
+    default Page<GameEventProjectVo> selectPageEventProjectList(Page<GameEventProject> page,
+            Wrapper<GameEventProject> queryWrapper) {
         return this.selectVoPage(page, queryWrapper);
     }
 
@@ -43,8 +44,8 @@ public interface GameEventProjectMapper extends BaseMapperPlus<GameEventProject,
      * @return 项目列表
      */
     @DataPermission({
-        @DataColumn(key = "deptName", value = "create_dept"),
-        @DataColumn(key = "userName", value = "create_by")
+            @DataColumn(key = "deptName", value = "create_dept"),
+            @DataColumn(key = "userName", value = "create_by")
     })
     default List<GameEventProjectVo> selectEventProjectList(Wrapper<GameEventProject> queryWrapper) {
         return this.selectVoList(queryWrapper);
@@ -54,8 +55,9 @@ public interface GameEventProjectMapper extends BaseMapperPlus<GameEventProject,
      * 分页查询项目列表并包含实时统计(XML 实现)
      */
     @DataPermission({
-        @DataColumn(key = "deptName", value = "create_dept"),
-        @DataColumn(key = "userName", value = "create_by")
+            @DataColumn(key = "deptName", value = "create_dept"),
+            @DataColumn(key = "userName", value = "create_by")
     })
-    Page<GameEventProjectVo> selectPageWithStats(@Param("page") Page<GameEventProjectVo> page, @Param("query") GameEventProjectBo bo);
+    Page<GameEventProjectVo> selectPageWithStats(@Param("page") Page<GameEventProjectVo> page,
+            @Param("query") GameEventProjectBo bo);
 }

+ 23 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/app/ToClientMapper.java

@@ -0,0 +1,23 @@
+package org.dromara.system.mapper.app;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.dromara.system.domain.vo.ScoreSheetItemVo;
+import org.dromara.system.domain.vo.ScoreSheetVo;
+
+import java.util.List;
+
+@Mapper
+public interface ToClientMapper {
+
+
+    /**
+     * 获取成绩记录单元数据 (接口6)
+     */
+    ScoreSheetVo selectScoreSheetMetadata(@Param("eventId") Long eventId, @Param("projectId") Long projectId);
+
+    /**
+     * 获取成绩记录单项列表
+     */
+    List<ScoreSheetItemVo> selectScoreSheetItems(@Param("eventId") Long eventId, @Param("projectId") Long projectId);
+}

+ 44 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/app/IToClientService.java

@@ -0,0 +1,44 @@
+package org.dromara.system.service.app;
+
+import org.dromara.system.domain.GameEvent;
+import org.dromara.system.domain.GameEventProject;
+import org.dromara.system.domain.bo.ClientProjectSaveBo;
+import org.dromara.system.domain.bo.ScoreSubmitBo;
+import org.dromara.system.domain.vo.ScoreSheetVo;
+
+import java.util.List;
+
+public interface IToClientService {
+
+    /**
+     * 客户端同步赛事及项目信息 (接口4)
+     *
+     * @param bo 客户端保存对象
+     */
+    void saveOrUpdateEventFromClient(ClientProjectSaveBo bo);
+
+    /**
+     * 获取赛事列表 (接口2)
+     */
+    List<GameEvent> getEventList();
+
+    /**
+     * 删除赛事 (接口3)
+     */
+    void removeEvent(Long eventId);
+
+    /**
+     * 获取项目列表 (接口5)
+     */
+    List<GameEventProject> getProjectList(Long eventId);
+
+    /**
+     * 获取成绩记录单 (接口6 展示)
+     */
+    ScoreSheetVo getScoreSheet(Long eventId, Long projectId);
+
+    /**
+     * 提交成绩记录单 (接口6 提交)
+     */
+    void submitScoreSheet(ScoreSubmitBo bo);
+}

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

@@ -54,6 +54,8 @@ import org.apache.poi.ss.usermodel.*;
 import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.transaction.annotation.Transactional;
 import org.dromara.common.excel.utils.ExcelUtil;
+import org.springframework.web.multipart.MultipartFile;
+
 import java.io.IOException;
 
 /**
@@ -748,7 +750,7 @@ public class GameScoreServiceImpl implements IGameScoreService {
                         ProjectClassification.SINGLE.getValue()) != 0) {
                     currentRank = i + 1;
                 }
-                
+
                 Integer points = (currentRank <= pointConfig.size()) ? pointConfig.get(currentRank - 1) : 0;
                 Integer finalRank = currentRank;
 
@@ -2340,7 +2342,7 @@ public class GameScoreServiceImpl implements IGameScoreService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public String importScore(Long eventId, Long projectId, String classification, Boolean updateSupport,
-            org.springframework.web.multipart.MultipartFile file) {
+            MultipartFile file) {
         GameEventProjectVo project = gameEventProjectService.queryById(projectId);
         int scoreCount = project.getScoreCount() != null ? project.getScoreCount() : 1;
 
@@ -2599,7 +2601,7 @@ public class GameScoreServiceImpl implements IGameScoreService {
                 } else {
                     throw new IllegalArgumentException("格式非法");
                 }
-                return BigDecimal.valueOf(totalSeconds).setScale(3, java.math.RoundingMode.HALF_UP);
+                return BigDecimal.valueOf(totalSeconds).setScale(3, RoundingMode.HALF_UP);
             } catch (Exception e) {
                 failureMsg.append("<br/>第").append(rowIndex + 1).append("行第").append(colIndex + 1)
                         .append("列时间格式不正确:[").append(scoreStr).append("]");

+ 531 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/app/ToClientServiceImpl.java

@@ -0,0 +1,531 @@
+package org.dromara.system.service.impl.app;
+
+import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.toolkit.Db;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.system.domain.*;
+import org.dromara.system.domain.bo.*;
+import org.dromara.system.domain.constant.ProjectClassification;
+import org.dromara.system.domain.vo.ScoreSheetDetailVo;
+import org.dromara.system.domain.vo.ScoreSheetItemVo;
+import org.dromara.system.domain.vo.ScoreSheetVo;
+import org.dromara.system.mapper.GameAthleteMapper;
+import org.dromara.system.mapper.GameEventMapper;
+import org.dromara.system.mapper.GameEventProjectMapper;
+import org.dromara.system.mapper.app.ToClientMapper;
+import org.dromara.system.service.IGameScoreService;
+import org.dromara.system.service.IGameRankGroupService;
+import org.dromara.system.service.app.IToClientService;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class ToClientServiceImpl implements IToClientService {
+
+    private final GameEventProjectMapper projectMapper;
+    private final GameEventMapper gameEventMapper;
+    private final IGameRankGroupService gameRankGroupService;
+    private final ToClientMapper baseMapper;
+    private final IGameScoreService gameScoreService;
+    private final GameAthleteMapper gameAthleteMapper;
+
+    /**
+     * 客户端同步赛事及项目信息 (接口4)
+     *
+     * @param bo 客户端保存对象
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void saveOrUpdateEventFromClient(ClientProjectSaveBo bo) {
+        // 1. 获取或创建赛事
+        GameEvent event = gameEventMapper.selectOne(Wrappers.lambdaQuery(GameEvent.class)
+                .eq(GameEvent::getEventCode, bo.getEventCode()));
+        if (event == null) {
+            event = new GameEvent();
+            event.setEventCode(bo.getEventCode());
+            event.setEventName(bo.getEventName());
+            event.setStatus("0");
+            gameEventMapper.insert(event);
+        } else if (!event.getEventName().equals(bo.getEventName())) {
+            throw new RuntimeException("赛事编号" + bo.getEventCode() + "已被赛事【" + event.getEventName() + "】占用");
+        }
+        Long eventId = event.getEventId();
+
+        // 2. 批量维护配置信息 (机器编号、赛事提示、上传成绩路径)
+        batchSaveConfigs(eventId, bo);
+
+        // 3. 维护组别信息 (参赛组别)
+        Long rgId = bo.getRgId();
+        String rgName = bo.getRgName();
+        if (rgId == null && StringUtils.isNotBlank(rgName)) {
+            GameRankGroup group = Db.getOne(Wrappers.lambdaQuery(GameRankGroup.class)
+                    .select(GameRankGroup::getRgId)
+                    .eq(GameRankGroup::getEventId, eventId)
+                    .eq(GameRankGroup::getRgName, rgName)
+                    .last("LIMIT 1"));
+
+            if (group == null) {
+                GameRankGroupBo addGroup = new GameRankGroupBo();
+                addGroup.setEventId(eventId);
+                addGroup.setRgName(rgName);
+                addGroup.setParentId(0L);
+                addGroup.setStatus("0");
+                gameRankGroupService.insertByBo(addGroup);
+                rgId = addGroup.getRgId();
+            } else {
+                rgId = group.getRgId();
+            }
+        }
+
+        // 4. 维护项目信息 (多填项目内容) - 使用批量操作优化性能
+        List<String> projectNames = bo.getProjectNames();
+        if (CollectionUtils.isNotEmpty(projectNames)) {
+            // 批量查询现有项目
+            List<GameEventProject> existingProjects = projectMapper
+                    .selectList(Wrappers.lambdaQuery(GameEventProject.class)
+                            .eq(GameEventProject::getEventId, eventId)
+                            .in(GameEventProject::getProjectName, projectNames)
+                            .eq(StringUtils.isNotBlank(rgName), GameEventProject::getRgName, rgName)
+                            .eq(rgId != null, GameEventProject::getRgId, rgId));
+
+            // 转换为 Map 方便查找
+            Map<String, GameEventProject> projectMap = existingProjects.stream()
+                    .collect(Collectors.toMap(GameEventProject::getProjectName, p -> p, (p1, p2) -> p1));
+
+            List<GameEventProject> projectsToSave = new ArrayList<>();
+            for (String projectName : projectNames) {
+                GameEventProject project = projectMap.get(projectName);
+                if (project == null) {
+                    project = new GameEventProject();
+                    project.setEventId(eventId);
+                    project.setProjectName(projectName);
+                }
+                project.setProjectType(bo.getProjectType());
+                project.setClassification(bo.getClassification());
+                project.setScoreRule(bo.getScoreRule());
+                project.setTimingFormat(bo.getTimingFormat());
+                project.setDistanceMode(bo.getDistanceMode());
+                project.setCountUnit(bo.getCountUnit());
+                project.setScoreCount(bo.getScoreCount());
+                project.setGender(bo.getGender());
+                project.setRgName(rgName);
+                project.setRgId(rgId);
+                project.setGameStage(bo.getGameStage());
+                project.setGameRound(bo.getGameRound());
+                project.setOrderType(bo.getOrderType());
+                project.setRoundType(bo.getRoundType());
+                projectsToSave.add(project);
+            }
+            // 批量保存或更新
+            Db.saveOrUpdateBatch(projectsToSave);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void removeEvent(Long eventId) {
+        // 先验证赛事是否存在
+        GameEvent event = gameEventMapper.selectById(eventId);
+        if (event == null) {
+            throw new RuntimeException("赛事不存在");
+        }
+        // 异步执行删除操作
+        asyncRemoveEventData(eventId);
+    }
+
+    /**
+     * 异步删除赛事关联数据
+     */
+    @Async
+    @Transactional(rollbackFor = Exception.class)
+    public void asyncRemoveEventData(Long eventId) {
+        log.info("开始异步删除赛事数据,eventId: {}", eventId);
+        long startTime = System.currentTimeMillis();
+        try {
+            // 删除关联的组别
+            Db.remove(Wrappers.lambdaQuery(GameRankGroup.class)
+                    .eq(GameRankGroup::getEventId, eventId));
+            // 删除关联的配置信息
+            Db.remove(Wrappers.lambdaQuery(GameEventConfig.class)
+                    .eq(GameEventConfig::getEventId, eventId));
+            // 删除关联的成绩信息
+            Db.remove(Wrappers.lambdaQuery(GameScore.class)
+                    .eq(GameScore::getEventId, eventId));
+            // 删除关联的项目
+            int projectCount = projectMapper.delete(Wrappers.lambdaQuery(GameEventProject.class)
+                    .eq(GameEventProject::getEventId, eventId));
+            log.info("删除项目完成,eventId: {}, 删除数量: {}", eventId, projectCount);
+            // 删除赛事
+            gameEventMapper.deleteById(eventId);
+            long endTime = System.currentTimeMillis();
+            log.info("赛事数据删除完成,eventId: {}, 耗时: {}ms", eventId, (endTime - startTime));
+        } catch (Exception e) {
+            log.error("异步删除赛事数据失败,eventId: {}", eventId, e);
+            throw new RuntimeException("删除赛事数据失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public List<GameEvent> getEventList() {
+        return gameEventMapper.selectList(Wrappers.lambdaQuery(GameEvent.class)
+                .orderByDesc(GameEvent::getCreateTime)
+                .select(GameEvent::getEventId, GameEvent::getEventCode, GameEvent::getEventName,
+                        GameEvent::getCreateTime));
+    }
+
+    @Override
+    public List<GameEventProject> getProjectList(Long eventId) {
+        return projectMapper.selectList(Wrappers.lambdaQuery(GameEventProject.class)
+                .eq(GameEventProject::getEventId, eventId)
+                .select(GameEventProject::getProjectId, GameEventProject::getProjectType,
+                        GameEventProject::getClassification, GameEventProject::getProjectName));
+    }
+
+    @Override
+    public ScoreSheetVo getScoreSheet(Long eventId, Long projectId) {
+        // 1. 查询元数据和统计信息
+        ScoreSheetVo vo = baseMapper.selectScoreSheetMetadata(eventId, projectId);
+        if (vo == null) {
+            throw new RuntimeException("该赛事下不存在此项目");
+        }
+        // 2. 查询该项目下所有参赛人员和队伍信息 (包括已录入成绩和未录入的)
+        List<ScoreSheetItemVo> items = baseMapper.selectScoreSheetItems(eventId, projectId);
+
+        // 3. 批量查询该项目下所有成绩明细 (适配个人/团体关联差异)
+        List<GameScoreDetail> allDetails = Db.list(Wrappers.lambdaQuery(GameScoreDetail.class)
+                .eq(GameScoreDetail::getProjectId, projectId)
+                .eq(GameScoreDetail::getDelFlag, "0"));
+
+        if (!allDetails.isEmpty()) {
+            Map<Long, List<ScoreSheetDetailVo>> detailMap;
+            if (ProjectClassification.TEAM.getValue().equals(vo.getClassification())) {
+                // 团体项目:按 teamId 分组
+                detailMap = allDetails.stream()
+                        .filter(d -> d.getTeamId() != null)
+                        .map(this::convertToDetailVo)
+                        .collect(Collectors.groupingBy(ScoreSheetDetailVo::getTeamId));
+
+                items.forEach(item -> item.setDetails(detailMap.get(item.getTeamId())));
+            } else {
+                // 个人项目:优先按 athleteId 分组 (如果存储时没存 athleteId 则按 scoreId)
+                detailMap = allDetails.stream()
+                        .filter(d -> d.getAthleteId() != null)
+                        .map(this::convertToDetailVo)
+                        .collect(Collectors.groupingBy(ScoreSheetDetailVo::getAthleteId));
+
+                items.forEach(item -> item.setDetails(detailMap.get(item.getAthleteId())));
+
+                // 兜底:处理部分仅关联 scoreId 的旧数据
+                items.stream().filter(i -> i.getDetails() == null && i.getScoreId() != null).forEach(item -> {
+                    List<ScoreSheetDetailVo> scoreDetails = allDetails.stream()
+                            .filter(d -> Objects.equals(d.getScoreId(), item.getScoreId()))
+                            .map(this::convertToDetailVo)
+                            .toList();
+                    item.setDetails(scoreDetails);
+                });
+            }
+        }
+
+        // 4. 处理计时格式转换
+        if (vo.getTimingFormat() != null && StringUtils.isNotBlank(vo.getTimingFormat())) {
+            items.forEach(item -> {
+                // 格式化主成绩
+                if (StringUtils.isNotBlank(item.getScore())) {
+                    item.setScore(convertDecimalToTimeScore(new BigDecimal(item.getScore()), vo.getTimingFormat()));
+                }
+                // 格式化明细成绩
+                if (item.getDetails() != null) {
+                    item.getDetails().forEach(detail -> {
+                        if (StringUtils.isNotBlank(detail.getPerformanceValue())) {
+                            detail.setPerformanceValue(convertDecimalToTimeScore(
+                                    new BigDecimal(detail.getPerformanceValue()),
+                                    vo.getTimingFormat()));
+                        }
+                    });
+                }
+            });
+        }
+
+        vo.setList(items);
+        return vo;
+    }
+
+    private ScoreSheetDetailVo convertToDetailVo(GameScoreDetail d) {
+        ScoreSheetDetailVo dvo = new ScoreSheetDetailVo();
+        dvo.setDetailId(d.getDetailId());
+        dvo.setScoreId(d.getScoreId());
+        dvo.setAttemptIndex(d.getAttemptIndex());
+        dvo.setPerformanceValue(d.getPerformanceValue() != null ? d.getPerformanceValue().toString() : null);
+        dvo.setFaultA(d.getFaultA());
+        dvo.setFaultB(d.getFaultB());
+        // 传递关联 ID 供分组使用
+        dvo.setAthleteId(d.getAthleteId());
+        dvo.setTeamId(d.getTeamId());
+        return dvo;
+    }
+
+    /**
+     * 将小数格式的成绩转换成时间格式显示
+     *
+     * @param decimalScore 以秒为单位的小数值
+     * @param format 格式 (HH:mm:ss.SSS 或 mm:ss.SSS)
+     * @return 时间格式字符串
+     */
+    private String convertDecimalToTimeScore(BigDecimal decimalScore, String format) {
+        if (decimalScore == null || decimalScore.compareTo(BigDecimal.ZERO) < 0) {
+            return "0".equals(format) ? "00:00:00.000" : "00:00.000";
+        }
+
+        try {
+            long totalMs = decimalScore.multiply(new BigDecimal(1000)).setScale(0, RoundingMode.HALF_UP).longValue();
+            long hours = totalMs / 3600000;
+            long minutes = (totalMs % 3600000) / 60000;
+            long seconds = (totalMs % 60000) / 1000;
+            long milliseconds = totalMs % 1000;
+
+            if ("1".equals(format)) {
+                // 如果是 mm:ss.SSS,把小时累加到分钟
+                long totalMinutes = hours * 60 + minutes;
+                return String.format("%02d:%02d.%03d", totalMinutes, seconds, milliseconds);
+            } else {
+                // 默认 HH:mm:ss.SSS
+                return String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds);
+            }
+        } catch (Exception e) {
+            log.warn("转换成绩格式失败: score={}, format={}", decimalScore, format, e);
+            return decimalScore.toString();
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void submitScoreSheet(ScoreSubmitBo bo) {
+        Long projectId = bo.getProjectId();
+        Long eventId = bo.getEventId();
+        GameEventProject project = projectMapper.selectOne(Wrappers.lambdaQuery(GameEventProject.class)
+                .eq(GameEventProject::getEventId, eventId)
+                .eq(GameEventProject::getProjectId, projectId));
+        if (project == null) {
+            throw new RuntimeException("该赛事下不存在此项目");
+        }
+
+        List<ScoreSubmitItemBo> items = bo.getItems();
+        if (CollectionUtils.isEmpty(items)) {
+            return;
+        }
+
+        // 1. 批量处理队伍 (缺失则新增)
+        Map<String, Long> teamIdMap = new HashMap<>();
+        Set<String> missingTeamNames = items.stream()
+                .filter(item -> item.getTeamId() == null && StringUtils.isNotBlank(item.getTeamName()))
+                .map(ScoreSubmitItemBo::getTeamName)
+                .collect(Collectors.toSet());
+
+        if (!missingTeamNames.isEmpty()) {
+            List<GameTeam> existingTeams = Db.list(Wrappers.lambdaQuery(GameTeam.class)
+                    .eq(GameTeam::getEventId, eventId)
+                    .in(GameTeam::getTeamName, missingTeamNames)
+                    .select(GameTeam::getTeamId, GameTeam::getTeamName));
+            existingTeams.forEach(t -> teamIdMap.put(t.getTeamName(), t.getTeamId()));
+
+            List<GameTeam> newTeams = missingTeamNames.stream()
+                    .filter(name -> !teamIdMap.containsKey(name))
+                    .map(name -> {
+                        GameTeam t = new GameTeam();
+                        t.setEventId(eventId);
+                        t.setTeamName(name);
+                        return t;
+                    }).toList();
+            if (!newTeams.isEmpty()) {
+                Db.saveBatch(newTeams);
+                newTeams.forEach(t -> teamIdMap.put(t.getTeamName(), t.getTeamId()));
+            }
+        }
+
+        // 2. 批量处理运动员 (缺失则新增,其余准备批量更新)
+        Map<String, Long> athleteIdMap = new HashMap<>();
+        List<ScoreSubmitItemBo> missingAthleteItems = items.stream()
+                .filter(item -> item.getAthleteId() == null && StringUtils.isNotBlank(item.getAthleteCode()))
+                .toList();
+
+        if (!missingAthleteItems.isEmpty()) {
+            // 预查是否存在,防止重复
+            Set<String> codes = missingAthleteItems.stream().map(ScoreSubmitItemBo::getAthleteCode).collect(Collectors.toSet());
+            List<GameAthlete> existAthletes = gameAthleteMapper.selectList(Wrappers.lambdaQuery(GameAthlete.class)
+                    .eq(GameAthlete::getEventId, eventId)
+                    .in(GameAthlete::getAthleteCode, codes)
+                    .select(GameAthlete::getAthleteId, GameAthlete::getAthleteCode));
+            existAthletes.forEach(a -> athleteIdMap.put(a.getAthleteCode(), a.getAthleteId()));
+
+            List<GameAthlete> newAthletes = missingAthleteItems.stream()
+                    .filter(item -> !athleteIdMap.containsKey(item.getAthleteCode()))
+                    .map(item -> {
+                        GameAthlete a = new GameAthlete();
+                        a.setEventId(eventId);
+                        a.setAthleteCode(item.getAthleteCode());
+                        a.setName(item.getName());
+                        a.setTeamId(item.getTeamId() != null ? item.getTeamId() : teamIdMap.get(item.getTeamName()));
+                        a.setProjectValue("[\"" + projectId + "\"]");
+                        a.setTrackIndex(item.getTrackIndex());
+                        return a;
+                    }).collect(Collectors.toMap(GameAthlete::getAthleteCode, a -> a, (a1, a2) -> a1))
+                    .values().stream().toList();
+
+            if (!newAthletes.isEmpty()) {
+                Db.saveBatch(newAthletes);
+                newAthletes.forEach(a -> athleteIdMap.put(a.getAthleteCode(), a.getAthleteId()));
+            }
+        }
+
+        // 3. 直接批量更新所有上传项的运动员信息 (不再预查对比,直接全量同步)
+        List<GameAthlete> updateBatch = items.stream()
+                .map(item -> {
+                    Long aid = item.getAthleteId() != null ? item.getAthleteId() : athleteIdMap.get(item.getAthleteCode());
+                    if (aid == null) return null;
+                    GameAthlete a = new GameAthlete();
+                    a.setAthleteId(aid);
+                    a.setTrackIndex(item.getTrackIndex());
+                    // 如果有姓名或其它字段变更也可以在此同步
+                    return a;
+                }).filter(Objects::nonNull).toList();
+
+        if (!updateBatch.isEmpty()) {
+            Db.updateBatchById(updateBatch);
+        }
+
+        // 4. 循环处理成绩录入 (统一使用 details 明细列表)
+        for (ScoreSubmitItemBo item : items) {
+            Long athleteId = item.getAthleteId() != null ? item.getAthleteId() : athleteIdMap.get(item.getAthleteCode());
+            if (athleteId == null) continue;
+
+            Long teamId = item.getTeamId() != null ? item.getTeamId() : teamIdMap.get(item.getTeamName());
+
+            GameScoreBo scoreBo = new GameScoreBo();
+            scoreBo.setScoreId(item.getScoreId());
+            scoreBo.setEventId(eventId);
+            scoreBo.setProjectId(projectId);
+            scoreBo.setAthleteId(athleteId);
+            scoreBo.setTeamId(teamId);
+            scoreBo.setFaultA(item.getFaultA());
+            scoreBo.setFaultB(item.getFaultB());
+
+            // 统一模式:优先处理明细列表
+            if (CollectionUtils.isNotEmpty(item.getDetails())) {
+                List<GameScoreDetailBo> details = item.getDetails().stream().map(d -> {
+                    GameScoreDetailBo dbo = new GameScoreDetailBo();
+                    dbo.setDetailId(d.getDetailId());
+                    dbo.setAttemptIndex(d.getAttemptIndex());
+                    dbo.setPerformanceValue(d.getPerformanceValue());
+                    return dbo;
+                }).toList();
+                scoreBo.setDetails(details);
+            } else if (StringUtils.isNotBlank(item.getScore())) {
+                // 如果没有明细,说明只有一个成绩,解析赋值给对应字段
+                BigDecimal performance = parsePerformanceValue(item.getScore());
+                if (ProjectClassification.TEAM.getValue().equals(project.getClassification())) {
+                    scoreBo.setTeamPerformance(performance);
+                } else {
+                    scoreBo.setIndividualPerformance(performance);
+                }
+            }
+
+            gameScoreService.updateScoreAndRecalculate(scoreBo);
+        }
+    }
+
+    /**
+     * 批量保存配置信息
+     */
+    private void batchSaveConfigs(Long eventId, ClientProjectSaveBo bo) {
+        Map<String, String> configValues = new HashMap<>();
+        if (StringUtils.isNotBlank(bo.getMachineCode()))
+            configValues.put("machine_code", bo.getMachineCode());
+        if (StringUtils.isNotBlank(bo.getEventTip()))
+            configValues.put("event_tip", bo.getEventTip());
+        if (StringUtils.isNotBlank(bo.getUploadPath()))
+            configValues.put("upload_path", bo.getUploadPath());
+
+        if (configValues.isEmpty())
+            return;
+
+        Map<String, String> configDescs = Map.of(
+                "machine_code", "机器编号",
+                "event_tip", "赛事提示",
+                "upload_path", "上传成绩路径");
+
+        // 批量查询现有配置
+        List<GameEventConfig> existingConfigs = Db.list(Wrappers.lambdaQuery(GameEventConfig.class)
+                .eq(GameEventConfig::getEventId, eventId)
+                .in(GameEventConfig::getConfigKey, configValues.keySet()));
+
+        Map<String, GameEventConfig> configMap = existingConfigs.stream()
+                .collect(Collectors.toMap(GameEventConfig::getConfigKey, c -> c));
+
+        List<GameEventConfig> toSave = new ArrayList<>();
+        for (Map.Entry<String, String> entry : configValues.entrySet()) {
+            String key = entry.getKey();
+            String value = entry.getValue();
+            GameEventConfig config = configMap.get(key);
+            if (config == null) {
+                config = new GameEventConfig();
+                config.setEventId(eventId);
+                config.setConfigType("SYSTEM"); // 系统配置
+                config.setConfigKey(key);
+                config.setConfigDesc(configDescs.get(key));
+                config.setIsEnabled("0");
+                config.setStatus("0");
+            }
+            config.setConfigValue(value);
+            toSave.add(config);
+        }
+        Db.saveOrUpdateBatch(toSave);
+    }
+
+    /**
+     * 解析成绩字符串为 BigDecimal
+     * 支持普通数字和时间格式 (HH:mm:ss.SSS 或 mm:ss.SSS)
+     */
+    private BigDecimal parsePerformanceValue(String scoreStr) {
+        if (StringUtils.isBlank(scoreStr)) return null;
+        scoreStr = scoreStr.trim();
+        // 兼容时间格式
+        if (scoreStr.contains(":")) {
+            try {
+                String[] parts = scoreStr.split(":");
+                double totalSeconds = 0;
+                if (parts.length == 3) {
+                    // HH:mm:ss.SSS
+                    totalSeconds = Double.parseDouble(parts[0]) * 3600
+                            + Double.parseDouble(parts[1]) * 60
+                            + Double.parseDouble(parts[2]);
+                } else if (parts.length == 2) {
+                    // mm:ss.SSS
+                    totalSeconds = Double.parseDouble(parts[0]) * 60
+                            + Double.parseDouble(parts[1]);
+                }
+                return BigDecimal.valueOf(totalSeconds).setScale(3, RoundingMode.HALF_UP);
+            } catch (Exception e) {
+                log.warn("成绩时间格式解析失败: {}", scoreStr);
+                return null;
+            }
+        }
+        try {
+            return new BigDecimal(scoreStr);
+        } catch (Exception e) {
+            log.warn("成绩数字解析失败: {}", scoreStr);
+            return null;
+        }
+    }
+}
+

+ 9 - 6
ruoyi-modules/ruoyi-game-event/src/main/resources/mapper/system/GameEventProjectMapper.xml

@@ -12,11 +12,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="selectPageWithStats" resultMap="GameEventProjectVoWithStatsResult">
         SELECT p.*,
-            IFNULL((SELECT COUNT(*) FROM game_athlete a WHERE a.event_id = p.event_id AND a.del_flag = '0' 
-            AND (JSON_CONTAINS(a.project_value, JSON_ARRAY(p.project_id)) 
+            IFNULL((SELECT COUNT(*) FROM game_athlete a WHERE a.event_id = p.event_id AND a.del_flag = '0'
+            AND (JSON_CONTAINS(a.project_value, JSON_ARRAY(p.project_id))
             OR JSON_CONTAINS(a.project_value, JSON_ARRAY(CAST(p.project_id AS CHAR))))), 0) as athlete_count,
-            IFNULL((SELECT COUNT(DISTINCT team_id) FROM game_athlete a WHERE a.event_id = p.event_id AND a.del_flag = '0' 
-            AND (JSON_CONTAINS(a.project_value, JSON_ARRAY(p.project_id)) 
+            IFNULL((SELECT COUNT(DISTINCT team_id) FROM game_athlete a WHERE a.event_id = p.event_id AND a.del_flag = '0'
+            AND (JSON_CONTAINS(a.project_value, JSON_ARRAY(p.project_id))
             OR JSON_CONTAINS(a.project_value, JSON_ARRAY(CAST(p.project_id AS CHAR))))), 0) as team_count,
             IFNULL((SELECT COUNT(*) FROM game_event_group g WHERE g.project_id = p.project_id AND g.del_flag = '0'), 0) as group_count
         FROM game_event_project p
@@ -34,8 +34,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="query.gender != null and query.gender != ''">
                 AND p.gender = #{query.gender}
             </if>
-            <if test="query.groups != null and query.groups != ''">
-                AND FIND_IN_SET(#{query.groups}, p.groups)
+            <if test="query.rgName != null and query.rgName != ''">
+                AND p.rg_name like #{query.rgName}
+            </if>
+            <if test="query.rgId != null and query.rgId != ''">
+                AND p.rg_id = #{query.rgId}
             </if>
             <!-- 数据权限 -->
             ${query.params.dataScope}

+ 67 - 0
ruoyi-modules/ruoyi-game-event/src/main/resources/mapper/system/app/ToClientMapper.xml

@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="org.dromara.system.mapper.app.ToClientMapper">
+    <select id="selectScoreSheetMetadata" resultType="org.dromara.system.domain.vo.ScoreSheetVo">
+        SELECT
+            e.event_name as eventName,
+            p.score_rule as scoreRule,
+            p.project_name as projectName,
+            p.project_type as projectType,
+            p.classification as classification,
+            p.gender as gender,
+            p.rg_name as rgName,
+            p.game_stage as gameStage,
+            p.game_round as gameRound,
+            DATE_FORMAT(p.start_time, '%Y-%m-%d %H:%i:%s') as startTime,
+            (SELECT GROUP_CONCAT(name SEPARATOR ',') FROM game_referee WHERE del_flag = '0'
+                    AND event_id = p.event_id
+                    AND (JSON_CONTAINS(p.referee_group, JSON_ARRAY(referee_id))
+                             OR JSON_CONTAINS(p.referee_group, JSON_ARRAY(CAST(referee_id AS CHAR))))
+            ) as refereeName,
+            p.timing_format as timingFormat,
+            (SELECT config_value FROM game_event_config WHERE event_id = p.event_id
+                                                          AND config_key = 'event_tip' AND del_flag = '0' LIMIT 1) as eventTip,
+            IFNULL((SELECT COUNT(*) FROM game_athlete a WHERE a.event_id = p.event_id AND a.del_flag = '0'
+                AND (JSON_CONTAINS(a.project_value, JSON_ARRAY(p.project_id))
+                OR JSON_CONTAINS(a.project_value, JSON_ARRAY(CAST(p.project_id AS CHAR)))))
+                , 0) as totalParticipants,
+            IFNULL((SELECT COUNT(*)
+                    FROM game_score s
+                    JOIN game_athlete a ON s.athlete_id = a.athlete_id AND a.del_flag = '0'
+                    WHERE s.project_id = p.project_id AND s.del_flag = '0'
+                    AND ((p.classification = '0' AND s.individual_performance > 0) OR (p.classification = '1' AND s.team_performance > 0))
+                    )
+            , 0) as finishedParticipants
+        FROM game_event_project p
+        JOIN game_event e ON p.event_id = e.event_id
+        WHERE p.event_id = #{eventId} AND p.project_id = #{projectId} AND p.del_flag = '0'
+    </select>
+
+    <select id="selectScoreSheetItems" resultType="org.dromara.system.domain.vo.ScoreSheetItemVo">
+        SELECT
+            a.athlete_id as athleteId,
+            a.team_id as teamId,
+            a.athlete_code as athleteCode,
+            a.name as name,
+            t.team_name as teamName,
+            a.track_index as trackIndex,
+            s.score_id as scoreId,
+            CASE
+                WHEN p.classification = '0' THEN s.individual_performance
+                ELSE s.team_performance
+            END as score,
+            s.fault_a as faultA,
+            s.fault_b as faultB
+        FROM game_athlete a
+        LEFT JOIN game_team t ON a.team_id = t.team_id AND t.del_flag = '0'
+        CROSS JOIN game_event_project p ON p.project_id = #{projectId}
+        LEFT JOIN game_score s ON a.athlete_id = s.athlete_id AND s.project_id = #{projectId} AND s.del_flag = '0'
+        WHERE a.event_id = #{eventId}
+          AND a.del_flag = '0'
+          AND (JSON_CONTAINS(a.project_value, JSON_ARRAY(#{projectId}))
+               OR JSON_CONTAINS(a.project_value, JSON_ARRAY(CAST(#{projectId} AS CHAR))))
+        ORDER BY a.track_index ASC, a.athlete_code ASC
+    </select>
+</mapper>