浏览代码

feat(game-event): 新增赛事秩序册Word文档导出功能

- 在AppEventMdMapper接口上添加@Mapper注解
- 在AppEventMdServiceImpl服务类中注入游戏事件相关依赖服务
- 实现exportEventWord方法支持导出包含日程、分组详情和号码表的完整秩序册
- 在IAppEventMdService接口中定义exportEventWord方法
- 在MarkdownController控制器中添加/exportEventWord接口端点
- 集成poi-tl文档模板引擎用于Word文档生成
- 实现表格对角线绘制等高级文档格式化功能
- 从Redis缓存获取默认赛事ID并支持中文数字编号显示
zhou 3 天之前
父节点
当前提交
88ac4bb641

+ 6 - 0
ruoyi-modules/ruoyi-game-event/pom.xml

@@ -115,6 +115,12 @@
             <artifactId>poi-ooxml</artifactId>
             <version>4.1.2</version>
         </dependency>
+        <!--  文档模版引擎  -->
+        <dependency>
+            <groupId>com.deepoove</groupId>
+            <artifactId>poi-tl</artifactId>
+            <version>1.9.0</version>
+        </dependency>
         <dependency>
             <groupId>org.dromara</groupId>
             <artifactId>ruoyi-common-sse</artifactId>

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

@@ -1,6 +1,7 @@
 package org.dromara.system.controller;
 
 import cn.dev33.satoken.annotation.SaCheckPermission;
+import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.constraints.NotNull;
 import lombok.RequiredArgsConstructor;
 import org.dromara.common.core.domain.R;
@@ -9,8 +10,10 @@ import org.dromara.common.core.validate.EditGroup;
 import org.dromara.common.idempotent.annotation.RepeatSubmit;
 import org.dromara.common.log.annotation.Log;
 import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.redis.utils.RedisUtils;
 import org.dromara.common.web.core.BaseController;
 import org.dromara.system.domain.bo.AppEventMdBo;
+import org.dromara.system.domain.constant.GameEventConstant;
 import org.dromara.system.domain.vo.AppEventMdVo;
 import org.dromara.system.service.IAppEventMdService;
 import org.springframework.validation.annotation.Validated;
@@ -80,4 +83,16 @@ public class MarkdownController extends BaseController {
                           @PathVariable Integer type) {
         return toAjax(appEventMdService.deleteByEventIdAndType(eventId, type));
     }
+
+    /**
+     * 导出秩序册(日程、分组详情、号码表) Word 文档
+     */
+    @SaCheckPermission("system:gameEvent:exportWord")
+    @Log(title = "赛事秩序册导出", businessType = BusinessType.EXPORT)
+    @PostMapping("/exportEventWord")
+    public void exportEventWord(HttpServletResponse response) {
+        Object cacheObject = RedisUtils.getCacheObject(GameEventConstant.DEFAULT_EVENT_ID);
+        Long eventId = Long.valueOf(cacheObject.toString());
+        appEventMdService.exportEventWord(response, eventId);
+    }
 }

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

@@ -1,5 +1,6 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Mapper;
 import org.dromara.system.domain.AppEventMd;
 import org.dromara.system.domain.vo.AppEventMdVo;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
@@ -10,6 +11,7 @@ import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
  * @author Lion Li
  * @date 2025-08-18
  */
+@Mapper
 public interface AppEventMdMapper extends BaseMapperPlus<AppEventMd, AppEventMdVo> {
 
 }

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

@@ -1,5 +1,6 @@
 package org.dromara.system.service;
 
+import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.constraints.NotNull;
 import org.dromara.system.domain.vo.AppEventMdVo;
 import org.dromara.system.domain.bo.AppEventMdBo;
@@ -89,4 +90,11 @@ public interface IAppEventMdService {
      * @return
      */
     int deleteByEventIdAndType(Long eventId,  Integer type);
+
+    /**
+     * 导出秩序册 Word 文档
+     * @param response 响应
+     * @param eventId 赛事ID
+     */
+    void exportEventWord(HttpServletResponse response, Long eventId);
 }

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

@@ -1,6 +1,13 @@
 package org.dromara.system.service.impl;
 
 import cn.hutool.core.util.StrUtil;
+import com.deepoove.poi.XWPFTemplate;
+import com.deepoove.poi.data.RowRenderData;
+import com.deepoove.poi.data.Rows;
+import com.deepoove.poi.data.TableRenderData;
+import com.deepoove.poi.data.Tables;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.poi.xwpf.usermodel.*;
 import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.utils.MapstructUtils;
 import org.dromara.common.core.utils.StringUtils;
@@ -11,17 +18,25 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.dromara.system.domain.bo.GameEventGroupBo;
+import org.dromara.system.domain.bo.GameEventProjectBo;
+import org.dromara.system.domain.vo.*;
+import org.dromara.system.service.IGameEventGroupService;
+import org.dromara.system.service.IGameEventProjectService;
+import org.dromara.system.service.IGameEventService;
+import org.dromara.system.service.ISysDictTypeService;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.*;
 import org.springframework.stereotype.Service;
 import org.dromara.system.domain.bo.AppEventMdBo;
-import org.dromara.system.domain.vo.AppEventMdVo;
 import org.dromara.system.domain.AppEventMd;
 import org.dromara.system.mapper.AppEventMdMapper;
 import org.dromara.system.service.IAppEventMdService;
 
-import java.util.List;
-import java.util.Map;
-import java.util.Collection;
-import java.util.Optional;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URLEncoder;
+import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * 移动端富文本Service业务层处理
@@ -35,6 +50,10 @@ import java.util.Optional;
 public class AppEventMdServiceImpl implements IAppEventMdService {
 
     private final AppEventMdMapper baseMapper;
+    private final IGameEventService gameEventService;
+    private final IGameEventProjectService gameEventProjectService;
+    private final IGameEventGroupService gameEventGroupService;
+    private final ISysDictTypeService dictTypeService;
 
     /**
      * 查询移动端富文本
@@ -196,4 +215,298 @@ public class AppEventMdServiceImpl implements IAppEventMdService {
             .eq(AppEventMd::getEventId, eventId)
             .eq(AppEventMd::getType, type));
     }
+
+    /**
+     * 导出秩序册 Word 文档
+     *
+     * @param response 响应
+     * @param eventId 赛事ID
+     */
+    @Override
+    public void exportEventWord(HttpServletResponse response, Long eventId) {
+        try {
+            // 1. 基本信息
+            org.dromara.system.domain.vo.GameEventVo eventVo = gameEventService.queryById(eventId);
+            if (eventVo == null) {
+                throw new ServiceException("赛事不存在");
+            }
+
+            Map<String, Object> data = new HashMap<>();
+            data.put("eventName", eventVo.getEventName() != null ? eventVo.getEventName() : "");
+
+            // 2. 日程预览数据
+            GameEventProjectBo projectBo = new GameEventProjectBo();
+            projectBo.setEventId(eventId);
+            List<GameEventProjectVo> allProjects = gameEventProjectService.queryList(projectBo);
+            // 过滤掉没有开始时间的项目
+            List<GameEventProjectVo> scheduleProjects = new ArrayList<>();
+            for (GameEventProjectVo p : allProjects) {
+                if (p.getStartTime() != null) {
+                    scheduleProjects.add(p);
+                }
+            }
+            // 排序
+            scheduleProjects.sort((a, b) -> {
+                if (a.getStartTime() == null) return 1;
+                if (b.getStartTime() == null) return -1;
+                return a.getStartTime().compareTo(b.getStartTime());
+            });
+            // 获取项目类型字典
+            List<SysDictDataVo> projectTypes = dictTypeService.selectDictDataByType("game_project_type");
+            Map<String, String> projectTypeMap = new HashMap<>();
+            if (projectTypes != null) {
+                for (SysDictDataVo dict : projectTypes) {
+                    projectTypeMap.put(dict.getDictValue(), dict.getDictLabel());
+                }
+            }
+
+            // 表头
+            RowRenderData scheduleHeader = Rows.of("日期", "时间", "项目名称", "项目类型", "比赛场地", "分组数", "每组人数").create();
+            TableRenderData scheduleTable = Tables.create(scheduleHeader);
+            for (GameEventProjectVo p : scheduleProjects) {
+                String date = p.getStartTime() != null ? cn.hutool.core.date.DateUtil.format(p.getStartTime(), "yyyy-MM-dd") : "";
+                String time = p.getStartTime() != null ? cn.hutool.core.date.DateUtil.format(p.getStartTime(), "HH:mm") : "";
+                if(p.getEndTime() != null) {
+                    time += "-" + cn.hutool.core.date.DateUtil.format(p.getEndTime(), "HH:mm");
+                }
+                scheduleTable.addRow(Rows.of(
+                    date,
+                    time,
+                    p.getProjectName() != null ? p.getProjectName() : "",
+                    projectTypeMap.getOrDefault(p.getProjectType(), p.getProjectType() != null ? p.getProjectType() : ""),
+                    p.getLocation() != null ? p.getLocation() : "",
+                    String.valueOf(p.getGroupNum() != null ? p.getGroupNum() : 0),
+                    String.valueOf(p.getParticipateNum() != null ? p.getParticipateNum() : 0)
+                ).create());
+            }
+            data.put("scheduleTable", scheduleTable);
+
+            // 3. 各项目分组详情
+            // 按照 projectType 分组,保持原有顺序
+            Map<String, List<GameEventProjectVo>> projectsByType = allProjects.stream()
+                .filter(p -> p.getProjectType() != null)
+                .collect(Collectors.groupingBy(GameEventProjectVo::getProjectType, LinkedHashMap::new, Collectors.toList()));
+
+            List<Map<String, Object>> projectTypeList = new ArrayList<>();
+            int typeIndex = 1;
+            String[] cnNums = {"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"};
+
+            for (Map.Entry<String, List<GameEventProjectVo>> entry : projectsByType.entrySet()) {
+                String projectType = entry.getKey();
+                String projectTypeLabel = projectTypeMap.getOrDefault(projectType, projectType);
+                Map<String, Object> typeMap = new HashMap<>();
+                typeMap.put("projectType", projectTypeLabel);
+
+                String chineseIdx = String.valueOf(typeIndex);
+                if (typeIndex <= 10) {
+                    chineseIdx = cnNums[typeIndex];
+                } else if (typeIndex < 20) {
+                    chineseIdx = "十" + cnNums[typeIndex % 10];
+                } else if (typeIndex < 100) {
+                    chineseIdx = cnNums[typeIndex / 10] + "十" + (typeIndex % 10 == 0 ? "" : cnNums[typeIndex % 10]);
+                }
+                typeMap.put("projectTypeTitle", chineseIdx + "、" + projectTypeLabel);
+
+                List<Map<String, Object>> groupList = new ArrayList<>();
+                int groupIndex = 1;
+
+                for (GameEventProjectVo p : entry.getValue()) {
+                    GameEventGroupBo groupBo = new GameEventGroupBo();
+                    groupBo.setProjectId(p.getProjectId());
+                    List<GameEventGroupVo> groups = gameEventGroupService.queryList(groupBo);
+
+                    if (groups == null || groups.isEmpty()) continue;
+
+                    for(GameEventGroupVo group : groups) {
+                        Map<String, Object> groupMap = new HashMap<>();
+
+                        String bracketIdx = String.valueOf(groupIndex);
+                        if (groupIndex <= 10) {
+                            bracketIdx = cnNums[groupIndex];
+                        } else if (groupIndex < 20) {
+                            bracketIdx = "十" + cnNums[groupIndex % 10];
+                        } else if (groupIndex < 100) {
+                            bracketIdx = cnNums[groupIndex / 10] + "十" + (groupIndex % 10 == 0 ? "" : cnNums[groupIndex % 10]);
+                        }
+
+                        Map<String, Object> dbResult = gameEventGroupService.getGroupResultFromDB(group.getGroupId());
+                        Map<String, Object> groupResultMap = dbResult != null && dbResult.get("groupResult") != null ?
+                            (Map<String, Object>) dbResult.get("groupResult") : new HashMap<>();
+                        List<Map<String, Object>> teamsList = dbResult != null && dbResult.get("teams") != null ?
+                            (List<Map<String, Object>>) dbResult.get("teams") : new ArrayList<>();
+                        int totalAthletes = dbResult != null && dbResult.get("totalAthletes") != null ?
+                            Integer.parseInt(dbResult.get("totalAthletes").toString()) : 0;
+
+                        String pName = p.getProjectName() != null ? p.getProjectName() : "";
+                        String gName = group.getGroupName() != null ? group.getGroupName() : "";
+                        String eCount = String.valueOf(totalAthletes);
+                        String iGroupNum = group.getIncludeGroupNum() != null ? String.valueOf(group.getIncludeGroupNum()) : "0";
+                        String rType = p.getRoundType() != null ? p.getRoundType() : "0";
+
+                        String groupTitle = "(" + bracketIdx + ")" + pName + " " + gName + "  " + eCount + "人  " + iGroupNum + "组  取前" + rType + "名";
+                        groupMap.put("groupTitle", groupTitle);
+                        // 预留单独的变量供模板单独调用
+                        groupMap.put("projectName", pName);
+                        groupMap.put("groupName", gName);
+                        groupMap.put("eligibleAthleteCount", eCount);
+                        groupMap.put("includeGroupNum", iGroupNum);
+                        groupMap.put("roundType", rType);
+
+                        long includeGroupNumVal = group.getIncludeGroupNum() != null ? group.getIncludeGroupNum() : 0L;
+                        long trackNum = group.getTrackNum() != null ? group.getTrackNum() : 0L;
+
+                        // 动态表头
+                        List<String> trackHeaders = new ArrayList<>();
+                        trackHeaders.add("道/组");
+                        for (int t = 1; t <= trackNum; t++) {
+                            trackHeaders.add("第" + t + "道");
+                        }
+                        RowRenderData gHeader = Rows.of(trackHeaders.toArray(new String[0])).create();
+                        TableRenderData gTable = Tables.create(gHeader);
+
+                        for (int g = 1; g <= includeGroupNumVal; g++) {
+                            List<String> rowData = new ArrayList<>();
+                            rowData.add(String.valueOf(g)); // 组别序号
+                            for (int t = 1; t <= trackNum; t++) {
+                                String key = g + "-" + t;
+                                Object athleteObj = groupResultMap.get(key);
+                                if (athleteObj instanceof Map) {
+                                    Map<String, Object> athlete = (Map<String, Object>) athleteObj;
+                                    String aCode = athlete.get("athleteCode") != null ? athlete.get("athleteCode").toString() : "";
+                                    String aName = athlete.get("name") != null ? athlete.get("name").toString() : "";
+                                    String aTeam = athlete.get("teamName") != null ? athlete.get("teamName").toString() : "";
+                                    if (aTeam.isEmpty() && athlete.get("teamId") != null) {
+                                        String tId = athlete.get("teamId").toString();
+                                        for(Map<String, Object> tm : teamsList) {
+                                            if(tId.equals(String.valueOf(tm.get("teamId")))) {
+                                                aTeam = tm.get("teamName") != null ? tm.get("teamName").toString() : "";
+                                                break;
+                                            }
+                                        }
+                                    }
+                                    rowData.add(aCode + "\n" + aName + "\n" + aTeam);
+                                } else {
+                                    rowData.add("-");
+                                }
+                            }
+                            gTable.addRow(Rows.of(rowData.toArray(new String[0])).create());
+                        }
+                        groupMap.put("groupDetailTable", gTable);
+                        groupList.add(groupMap);
+                        groupIndex++;
+                    }
+                }
+                if (!groupList.isEmpty()) {
+                    typeMap.put("groupList", groupList);
+                    projectTypeList.add(typeMap);
+                    typeIndex++;
+                }
+            }
+            data.put("projectTypeList", projectTypeList);
+
+            // 4. 号码对照表
+            List<AthleteNumberTableVO> numberTableList = gameEventService.getNumberTable(eventId);
+            List<Map<String, Object>> numberTableRenderList = new ArrayList<>();
+            if (numberTableList != null) {
+                int index = 1;
+                for(AthleteNumberTableVO nt : numberTableList) {
+                    Map<String, Object> ntMap = new HashMap<>();
+                    ntMap.put("teamName", nt.getTeamName() != null ? nt.getTeamName() : "");
+                    ntMap.put("numberRange", nt.getNumberRange() != null ? nt.getNumberRange() : "");
+
+                    String chineseIdx = String.valueOf(index);
+                    if (index <= 10) {
+                        chineseIdx = cnNums[index];
+                    } else if (index < 20) {
+                        chineseIdx = "十" + cnNums[index % 10];
+                    } else if (index < 100) {
+                        chineseIdx = cnNums[index / 10] + "十" + (index % 10 == 0 ? "" : cnNums[index % 10]);
+                    }
+                    ntMap.put("chineseIndex", chineseIdx);
+                    // 提供完整的标题参数,方便直接渲染
+                    ntMap.put("title", chineseIdx + "、" + (nt.getTeamName() != null ? nt.getTeamName() : "") + "(" + ntMap.get("numberRange") + ")");
+
+                    List<RowRenderData> rowsList = new ArrayList<>();
+                    if (nt.getAthleteCodeVos() != null && !nt.getAthleteCodeVos().isEmpty()) {
+                        List<AthleteCodeVo> athletes = nt.getAthleteCodeVos();
+                        for (int i = 0; i < athletes.size(); i += 8) {
+                            List<String> codeRow = new ArrayList<>();
+                            List<String> nameRow = new ArrayList<>();
+                            for (int j = 0; j < 8; j++) {
+                                if (i + j < athletes.size()) {
+                                    AthleteCodeVo ai = athletes.get(i + j);
+                                    codeRow.add(ai.getCode() != null ? ai.getCode() : "");
+                                    nameRow.add(ai.getName() != null ? ai.getName() : "");
+                                } else {
+                                    codeRow.add("");
+                                    nameRow.add("");
+                                }
+                            }
+                            rowsList.add(Rows.of(codeRow.toArray(new String[0])).create());
+                            rowsList.add(Rows.of(nameRow.toArray(new String[0])).create());
+                        }
+                    } else {
+                        rowsList.add(Rows.of("", "", "", "", "", "", "", "").create());
+                    }
+
+                    TableRenderData ntTable = Tables.create(rowsList.get(0));
+                    for (int i = 1; i < rowsList.size(); i++) {
+                        ntTable.addRow(rowsList.get(i));
+                    }
+                    ntMap.put("athleteTable", ntTable);
+                    numberTableRenderList.add(ntMap);
+                    index++;
+                }
+            }
+            data.put("numberTableList", numberTableRenderList);
+
+            // 5. 渲染文档
+            InputStream is = getClass().getClassLoader().getResourceAsStream("template/event_template.docx");
+            if (is == null) {
+                throw new ServiceException("Word模板文件不存在: classpath:template/event_template.docx");
+            }
+            XWPFTemplate template = XWPFTemplate.compile(is).render(data);
+
+            // 对生成的表格单元格进行处理(设置对角线)
+            XWPFDocument document = template.getXWPFDocument();
+            for (XWPFTable table : document.getTables()) {
+                if (!table.getRows().isEmpty()) {
+                    XWPFTableCell firstCell = table.getRow(0).getCell(0);
+                    if (firstCell != null && firstCell.getText() != null && firstCell.getText().replaceAll("\\s", "").contains("道/组")) {
+                        // poi-tl 原生不支持直接绘制对角线,通过 POI 底层操作实现
+                        CTTc ctTc = firstCell.getCTTc();
+                        CTTcPr tcPr = ctTc.isSetTcPr() ? ctTc.getTcPr() : ctTc.addNewTcPr();
+                        CTTcBorders borders = tcPr.isSetTcBorders() ? tcPr.getTcBorders() : tcPr.addNewTcBorders();
+                        CTBorder border = borders.isSetTl2Br() ? borders.getTl2Br() : borders.addNewTl2Br();
+                        border.setVal(STBorder.SINGLE);
+                        border.setSz(new java.math.BigInteger("4"));
+                        border.setSpace(new java.math.BigInteger("0"));
+                        border.setColor("auto");
+
+                        // 为了美观,将单元格文本重新排版以适配对角线
+                        firstCell.removeParagraph(0);
+                        XWPFParagraph p1 = firstCell.addParagraph();
+                        p1.setAlignment(ParagraphAlignment.RIGHT);
+                        p1.createRun().setText("道");
+                        XWPFParagraph p2 = firstCell.addParagraph();
+                        p2.setAlignment(ParagraphAlignment.LEFT);
+                        p2.createRun().setText("组");
+                    }
+                }
+            }
+
+            response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+            response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode(eventVo.getEventName() + "_秩序册.docx", "utf-8"));
+
+            OutputStream out = response.getOutputStream();
+            template.write(out);
+            out.flush();
+            out.close();
+            template.close();
+
+        } catch (Exception e) {
+            log.error("导出秩序册失败", e);
+            throw new ServiceException("导出秩序册失败: " + e.getMessage());
+        }
+    }
 }

二进制
ruoyi-modules/ruoyi-game-event/src/main/resources/template/event_template.docx