feat(export): 新增「导入格式」报名名单导出(汇总 / 分小项),与导入模板逐列一致、可直接重导;版本 dev_0.5.14

主人反馈:导出页「报名名单」卡片导出的表格与导入模板对不上,没法拿回去重导。

核对属实(旧导出是给人看的报表):列序与 importColumns 不同、缺「种子 / 队员名单 / 队伍」
三列、姓名列被拼成「姓名(号码布)」、队名拼在单位名后面、状态列写「已退赛」而
normalizeImportStatus 不认 —— 整体错位 + 状态列报错,重导必炸。

- 新增 GET /api/export/entries-import.xlsx?format=flat|sheets&lang=zh|en&<四个筛选>
  · flat(缺省)= 所有行汇总在一张工作表;表名不含 @(导入侧见到带 @ 的表名就整份按
    分工作表模板解析,汇总表定不出「小项@组别」会被拒)
  · sheets = 按「小项@组别」分工作表,表名走 entrySheetName(与模板、导入解析三处同源,
    31 字符截断与撞名去重都跟着一起走)
  · 权限与筛选参数与 /api/export/entries.xlsx 完全一致(访客可读);空结果回 200 + 只有表头
  · 列序不写常量:按 importColumns 逐列取值,表头走 entryTemplateHeader(lang),
    加列/改序时测试当场红(TestEntryImportRecordCoversImportColumns)
- 取值口径(重导的关键):团体行的「姓名」留空、队员写「队员名单」列(中文逗号分隔;
  以库里 token 为底,再补上 entry_athletes 里后加的队员号码布,只增不减)、
  队伍独立成列、状态写导入器认的四个中文说法(正常参赛/退赛/取消资格/未出场)、
  联队照旧写 A+B、未分批留空、证件号/性别/年龄来自运动员档案(团体行空)
- 新增 internal/export/entries_import.go(读库层,与报表导出同源 SQL、取值口径刻意不同)
  与 internal/httpapi/entries_import_export.go(工作簿组装 + handler)
- 原有报表导出(/api/export/entries.xlsx 与 internal/export/entries.go)一个字没动;
  导出页新增两个按钮与中文/英文表头选择(只影响这两个导入格式)

测试:往返测试把导出件直接喂回报名导入解析器,flat 与 sheets 各断言四条(解析零错误 /
行数与 13 列逐项一致 / 团体行没被变成个人报名 / 两种格式都跑);另做变异验证 —— 状态改回
「已退赛」、给团体行填姓名,守卫当场变红。四套自检 ×2 全绿(finalcheck_0514.log:
前端 996 例 / Console 96 例 / 服务端 16 包,EXIT=0)。

无数据库迁移;已有成绩、名次与榜单不变。
文档:使用手册 §11.1(并订正「不要拿导出件重导」的旧话)、报名名单导入规范 新增第九节、
升级说明 新增 dev_0.5.14、常见问题 新增 Q104。
This commit is contained in:
AskaEth committed 2026-09-17 04:03:52 +08:00
1 parent 19e5630b39
commit ee19b92182
19 files changed
+2134 -33

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.13
dev_0.5.14
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.13
0.5.14
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.5.13",
"version": "0.5.14",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+22 -4
View File
@@ -410,7 +410,7 @@
**显示成什么样**
- **导出件**:一个队员写 **`姓名(号码布)`**(例如 `张三(101)`),多个用 `、` 连——报名名单的「姓名」列、分项成绩表 / 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列都这样;**个人得分明细里每个队员各占一行**(双打就是两行,各一整份分);
- **导出件(指给人看的报表件)**:一个队员写 **`姓名(号码布)`**(例如 `张三(101)`),多个用 `、` 连——报名名单的「姓名」列、分项成绩表 / 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列都这样;**个人得分明细里每个队员各占一行**(双打就是两行,各一整份分)。⚠️ 例外是「导入格式」那两份(见 11.1):它们按导入口径写——**团体行的「姓名」列留空**,队员在「队员名单」列(多个用中文逗号 `,` 分隔);
- **大屏**:分项成绩 / 实时赛况的运动员列、底栏「最新完成项目」的名字格都会带上队员名单(**只写姓名**,号码布太长会把整屏字号压小);
- ⚠️ **一条报名仍然只占一行**,不会因为挂了两个队员就变成两行(队里的两个人是**一条报名**里的两个身份);
- ⚠️ **队名(4.12)与队员是两件独立的事**:单位列照旧显示「单位名 + 队名」,队员只出现在运动员列 / 底栏。
@@ -932,15 +932,17 @@
| 全部(单页) | 所有单位、所有项目、所有成绩**平铺在一张表**里(一个工作簿只有一张表),适合筛选与二次统计 | `赛事成绩总表(单页).xlsx` |
| 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` |
| 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` |
| 报名名单 | **本页下方的「报名名单」卡片**:按小项 / 组别 / 单位 / 批次筛选后导出报名数据(不填 = 全部)。13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、**号码布**、**批次**、性别、年龄、状态、备注;号码布按文本导出,前导零不会丢 | `报名名单.xlsx` |
| 报名名单 | **本页下方的「报名名单」卡片**:按小项 / 组别 / 单位 / 批次筛选后导出报名数据(不填 = 全部)。13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、**号码布**、**批次**、性别、年龄、状态、备注;号码布按文本导出,前导零不会丢。**这是给人看的报表,不能拿去重新导入** | `报名名单.xlsx` |
| 报名名单(导入格式 · 汇总) | **同一张「报名名单」卡片下方的「导出(汇总·导入格式)」按钮**:13 列与报名导入模板**逐列一致**(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍),所有行**汇总在一张工作表**里;筛选与上一行的「报名名单」完全相同,**导出的文件可以直接拿去重新导入** | `报名名单(导入格式).xlsx` |
| 报名名单(导入格式 · 分小项) | **同一张卡片下方的「导出(分小项·导入格式)」按钮**:同样这 13 列,按「小项名称@组别名称」**分成多张工作表**(重导时按**分工作表模板**那份口径解析,与导入规范第八节一致) | `报名名单(导入格式·分小项).xlsx` |
| 单位奖牌明细 | **在「积分榜 → 单位奖牌榜」页操作**:逐枚奖牌列出单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队,每个有奖牌的单位一张分表(末尾小计) | `单位奖牌明细.xlsx` |
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导);
- **队名(同一个单位派两个队时写的那个,见 4.12)写进「单位」列**:分项成绩表 / 全部 / 全部(单页)/ 报名名单 / 单位奖牌明细总表 / 单位积分总明细总表里,写的是 **`第一分公司二队`** 这种「单位名 + 队名」(队名留空时仍然只写 `第一分公司`)。⚠️ **导出件里没有单独的「队名」列**——所以**不要拿导出的报名名单直接改完再导回去**(那会撞「同一个单位同一个队」的重复报名、报错拦下);要改队名或改名单,用**导入模板**那条路,或到录入页点「单位」格改队名;
- **队名(同一个单位派两个队时写的那个,见 4.12)写进「单位」列**:分项成绩表 / 全部 / 全部(单页)/ 报名名单 / 单位奖牌明细总表 / 单位积分总明细总表里,写的是 **`第一分公司二队`** 这种「单位名 + 队名」(队名留空时仍然只写 `第一分公司`)。⚠️ **这张报表里没有单独的「队名」列**(队名拼在「单位」列里),所以**报表件不能改完再导回去**(那会撞「同一个单位同一个队」的重复报名、报错拦下);**要整份重导,用「报名名单」卡片里那两个「导入格式」按钮**(见本节末「『报名名单』卡片里的两种『导入格式』导出」:13 列与报名模板逐列一致,可以直接导回;那份里「队伍」是**独立的一列**,不是拼在单位名后面的)。只改队名可以到录入页点「单位」格改,或走**导入模板**那条路;
- **联队(一条报名挂多个单位)在这几份导出件里的写法**(见 4.11):
- **「分项成绩表」「全部」「全部(单页)」的「单位」列**:写成联队全名 `第一分公司+第二分公司联队`(成员单位名写全,与导入表的 `F1+F2` 同构);**「备注」列**写清这条分给了谁、各多少,例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`——「积分」列写的是**联队拿到的总分**(上例 10 分),每家的那一份看备注;
- **「报名名单」**:「单位编码」列回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例);
- **「报名名单」(报表件)**:「单位编码」列回写成 `F1+F2`(**这一列本身可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例)。⚠️ 但它是**报表**:**姓名列拼了队员、队名拼在单位名后面**,整个文件不能直接导回;要整份重导请用本节末「导入格式」那两个按钮——那份的「单位编码」同样写 `F1+F2`,而且姓名 / 队伍列都是能导回的写法;
- **按单位筛选导出时,成员单位也看得到这条报名**:「报名名单」按 F2 筛选,能导出「主单位是 F1、成员里含 F2」的联队报名(否则 F2 导出自己的名单会发现「没派人上场」);
- 「单位积分明细 / 单位积分总明细」「个人积分总明细」「单位奖牌明细」里联队的写法见 8.3;**没有联队的赛事,这些列与升级前逐字一致**。
- **三种「还没出结果」的行也照样占一行**(「分项成绩表」「全部」「全部(单页)」都是,逐格口径完全一致):
@@ -951,6 +953,22 @@
- **「单位奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响;
- 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。
**「报名名单」卡片里的两种「导入格式」导出**
同一个「报名名单」卡片,在原来那个「导出报名名单」(给人看的**报表**)下面还有一组**导入格式**导出:
| 按钮 | 导出来是什么 | 文件名 |
| --- | --- | --- |
| 导出(汇总·导入格式) | 所有行**汇总在一张工作表**里 | `报名名单(导入格式).xlsx` |
| 导出(分小项·导入格式) | 按「小项名称@组别名称」**分成多张工作表** | `报名名单(导入格式·分小项).xlsx` |
- 两个按钮导出的都是 **13 列、与「基础数据 → 报名导入」里下载的报名模板逐列一致**(**顺序就是这个**):项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍——**没有小项名称、单位名称与备注列**(那三列只有上面的报表件才有,重导用不着:「项目编码 + 组别」已经足够定位)。**导出的文件可以直接拿去重新导入**;
- 四个筛选(小项 / 组别 / 单位 / 批次)与报表**完全一样**,都留空 = 全导;权限也和报表相同,**访客也能导**;
- 卡片上的**表头语言**两个小按钮「**中文表头**」(默认)/「**英文表头**」**只影响下面这两个导入格式的导出**,对上面的「导出报名名单」**不生效**(界面上写明了这一点)。选英文时表头是规范列名:`event_code` / `category` / `team_code` / `athlete_name` / `id_card` / `gender` / `age` / `bib` / `seed` / `heat` / `status` / `member_names` / `squad`;
- **一条报名都没有时不是报错**:照样下载回一张**只有表头**的表(一眼看得出是「这次没有数据」);分小项那种格式里,本来就没人报名的组合不会出一张空表;
- ⚠️ 分小项导出时,只要有**小项名或组别名里带 `@`**(模板里明令禁止的字符,见《报名名单导入规范》8.1),会**直接报错并点名是哪一项**,不会静默丢掉那几十行;
- 重导回去要注意的三件事(号码布可能撞冲突面板、联队份额会变成均分、队员名单只增不减)见《报名名单导入规范》第九节「导出的名单怎么导回去」。
### 11.2 赛事项目包
服务端一次只打开一个赛事项目,页面上看到的数据全部来自「当前」的那一项,其余项目只是躺在数据目录里的数据。
+41
View File
@@ -6,6 +6,47 @@
---
## dev_0.5.14
### 一句话
**没有任何数据迁移:不改一行已有数据、不用重配、不用重新导入规则包**,已有的成绩、名次、积分、榜单与**所有导出件一个字不变**。这一版**新增两种导出**:在「导出 → 结果 → 报名名单」卡片里,多了一组**与报名导入模板逐列一致**的导出(原来的报表导出**原样保留、一个字没改**)——以后**导出的报名名单可以直接拿回去重新导入**了。
### ① 新增:「报名名单」卡片里的两种「导入格式」导出
**先说清楚为什么要有它**:原来那张「导出报名名单」是**给人看的报表**(含小项名称、单位名称与备注,姓名列拼了队员、状态写「已退赛」),**导入器不认它**——想「导出 → 改几笔 → 再导回去」这条路以前走不通。现在同一个卡片里加了两个按钮:
| 按钮 | 导出来是什么 | 文件名 |
| --- | --- | --- |
| 导出(汇总·导入格式) | 13 列,所有行**汇总在一张工作表** | `报名名单(导入格式).xlsx` |
| 导出(分小项·导入格式) | 13 列,按「小项名称@组别名称」**分成多张工作表** | `报名名单(导入格式·分小项).xlsx` |
- **13 列与报名导入模板逐列一致**(顺序也是这个):项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍;**导出的文件可以直接拿去重新导入**(往返已测,见下);
- 卡片上的四个筛选(小项 / 组别 / 单位 / 批次)与原来的报表**完全一样**,都可留空;权限也一样,**访客也能导**;
- 新加的**表头语言**(中文表头 / 英文表头)**只影响这两个导出**,对上面的报表**不生效**;
- **原来的「导出报名名单」一个字没改**,照旧是给人看的报表(含小项名称、单位名称与备注)。
**往返测过**:把这两种导出件**直接喂回导入接口**各跑一遍——**零错误、行数一致、13 列逐项一致、团体行没有被变成个人报名**(队员关联还在)。真实服务端上也手工验过一遍:导出的文件回灌 `POST /api/entries/import` 得到 `created:0, updated:2, errors:[]`。
⚠️ 但它**不是"完全无损"的往返**,有三条限制请一并交代给用的人(详见《报名名单导入规范》第九节):
1. **号码布可能撞冲突面板**:导出件写的是**报名上登记的号码布**,导入时对个人行会拿它跟**运动员档案里的号码布**比;两边都非空且不同,这一行就进「冲突」列表等人工决策(库里两者一致是常态,那时是静默幂等);
2. **联队的份额权重带不回来**:13 列里联队只能写 `A+B`,重导回去是**均分(各 50%)**;原来是 `2:1` 这种非均分的会变均分,要保权重只能在编辑界面改;
3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后**队员不会少**,但个别名单的顺序 / 写法可能与最初那张表不完全一样。
### ② 升级后 5 分钟核对
1. 到「导出 → 结果」,在**报名名单**卡片点「**导出(汇总·导入格式)**」:打开下载到的 `报名名单(导入格式).xlsx`,确认**第一行就是 13 列表头**(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍),并且**团体那一行的「姓名」列是空的**(队员在「队员名单」列);
2. 把刚导出的这个文件**直接**在同一页的「基础数据 → 报名导入」原样上传(**中间不要改任何一格**):应当是**零错误**,提示的「更新 N 条」里名单与上传前**看不出变化**;
3. 想要分小项的,回到卡片点「**导出(分小项·导入格式)**」:得到的文件里**每个「小项名称@组别名称」一张工作表**(没人报名的组合不会出空表),表名与导入模板同一套写法;
4. 其余照旧:**不需要数据迁移、不需要重新导入规则包、不需要重配任何东西**;原来的「导出报名名单」以及其它导出件**一个字不变**。
### ③ 详细说明在哪
《使用手册》§11.1 结果导出(xlsx);《报名名单导入规范》第九节「导出的名单怎么导回去」(含三条限制)。
---
## dev_0.5.13
### 一句话
+30 -1
View File
@@ -226,7 +226,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
## 导出与导入
**Q43:能导出哪些文件?**
「导出 → 结果导出」页有六类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`),以及可筛选的**报名名单**(`报名名单.xlsx`,见 Q87)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、单位奖牌榜卡片头的**导出奖牌明细**(`单位奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)、**基础数据包**(xlsx,见 Q88)与规则包(YAML)。
「导出 → 结果导出」页有六类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`),以及可筛选的**报名名单**(`报名名单.xlsx`,见 Q87;同一张卡片里另有「导入格式」的两个版本 `报名名单(导入格式).xlsx` / `报名名单(导入格式·分小项).xlsx`,见 Q104)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、单位奖牌榜卡片头的**导出奖牌明细**(`单位奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)、**基础数据包**(xlsx,见 Q88)与规则包(YAML)。
**Q44:导出文件保存在哪?**
浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。
@@ -543,6 +543,8 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
**Q87:怎么把报名名单导出来?**
到「导出 → 结果导出」页,往下找**「报名名单」**卡片:先按**小项 / 组别 / 单位 / 批次**筛选(都可以留空 = 不筛,即导全部),再点「导出报名名单」,得到 `报名名单.xlsx`。表里有 13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、号码布、批次、性别、年龄、状态(中文:正常参赛 / 已退赛 / 取消资格 / 未出场)、备注;号码布按文本导出,前导零不会丢。访客也能导出(只读)。
> 这份是**给人看的报表,不能导回去**。同一张卡片下面还有「**导出(汇总·导入格式)**」「**导出(分小项·导入格式)**」两个按钮,导出的 13 列与报名导入模板逐列一致、可以直接重新导入(见 Q104)。
**Q88:基础数据(单位 / 大项 / 小项 / 组别)能批量导入导出吗?**
能。到「导出 → 基础数据」页:
@@ -732,3 +734,30 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
- 升级前的旧版本遇到这个现象时,**挪一下盖在它上面的窗口**即可立刻恢复显示,**不需要重新发布成绩、也不需要重算**。详见《升级说明》的 `dev_0.5.13` 一节。
> 顺带分清另一件事:「**撤销发布**」**保留**成绩内容(只把分收回来,改完重新发布即可);要**连成绩内容一起清掉**,那是工具栏上另一对按钮「**清空本组 / 清空本批**」(危险操作,要勾选确认,清整组时还会一并清掉按单位录的团体名次)。两者的对照见使用手册 4.7。
**Q104:导出的报名名单为什么和导入模板不一样?能不能直接导回去?**
**先说结论:以前导出的那份不能,现在报名名单卡片里多了两个能导回去的按钮——挑对按钮就行。**
原来「导出报名名单」导出的是一张**给人看的报表**:额外有小项名称、单位名称与备注三列,**姓名列拼了队员**、状态写「已退赛」、单位名后面还拼着队名。**导入器不认这份表**(列对不上,状态那个写法也不认),拿它去「基础数据 → 报名导入」重导会被拦下——所以那份只能看、不能导回去。
现在同一个**报名名单卡片**里多了一组「**导入格式**」导出,与「基础数据 → 报名导入」里下载的模板**逐列一致**,可以直接导回去:
| 按钮 | 导出来是什么 | 文件名 |
| --- | --- | --- |
| 导出(汇总·导入格式) | 13 列汇总在**一张工作表**里 | `报名名单(导入格式).xlsx` |
| 导出(分小项·导入格式) | 13 列,按「小项名称@组别名称」**分成多张工作表** | `报名名单(导入格式·分小项).xlsx` |
- 13 列是:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍(顺序就是这个,没有小项名称 / 单位名称 / 备注那三列);
- **团体报名的「姓名」列是空的**、队员写在「队员名单」列(「姓名」列填了就会被当成个人报名);「**队伍**」是**独立的一列**(报表件里是拼在单位名后面的);「状态」写的是导入器认的四个中文说法:`正常参赛` / `退赛` / `取消资格` / `未出场`;
- 筛选与权限跟报表一样:小项 / 组别 / 单位 / 批次都可留空(留空 = 不筛),**访客也能导**;
- 上方的**表头语言**(中文表头 / 英文表头)**只管这两个导出**,改它不会动上面那份报表(界面上写明了);
- **往返已经测过**:这两种导出件原样喂回导入接口,零错误、行数一致、13 列逐项一致,**团体行不会被变成个人报名**(队员关联还在)。想自己验一遍:导出 → **一个字都不改** → 在「基础数据 → 报名导入」原样上传,结果应当是「新增 0 / 更新 N / 错误 0」。
**重导前要知道的三条限制**(它不是"完全无损"):
1. **号码布可能进冲突面板**:导出件写的是**报名上登记的号码布**,导入时对个人行会拿它跟**运动员档案里的号码布**比;两边都非空且不同,这一行就进「冲突」列表等你逐条决定(库里两者一致是常态,那时是静默幂等);
2. **联队的份额会变成均分**:13 列里联队只能写 `A+B`,重导回去是**各 50%**;当初设成 `2:1` 这种非均分的会变均分——表格本身表达不了,要保权重只能在界面上改;
3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后这支队的队员**不会少**,但个别名单的**顺序 / 写法**可能与最初上传的那张表不完全一样。
**一句话**:给人看的用「导出报名名单」;**要拿回去重导,用「导出(汇总·导入格式)」或「导出(分小项·导入格式)」**,导出的文件**不要改表头、不要插删列**。详见[使用手册 §11.1](./使用手册.md)与[报名名单导入规范 · 第九节](./报名名单导入规范.md#九导出的名单怎么导回去)。
+36 -3
View File
@@ -125,7 +125,7 @@ F1+F2
**导入之后长什么样**
- 联队仍是一条报名,但**分与奖牌都按份额记给各成员单位**:单位总分榜、单位奖牌榜上 A、B 各有一份;
- 大屏和导出件里写的是**联队的名字**:`第一分公司+第二分公司联队`,报名名单导出的「单位编码」列也回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位);
- 大屏和导出件里写的是**联队的名字**:`第一分公司+第二分公司联队`,报名名单导出的「单位编码」列也回写成 `F1+F2`(**这一列本身可以直接重导**,不用手工改回单个单位;但那两份「报名名单」导出的口径不同——**报表件整份不能导回,要重导用卡片里的两个「导入格式」按钮**,见第九节);
- 用成员单位筛「报名名单」导出时,**也能看到这条联队报名**(否则 B 导出自己的名单会以为没派人上场)。
### `athlete_name` 运动员姓名
@@ -314,7 +314,7 @@ A001,A002
- `F2` 在**加号左边**,所以 **F2 是主单位**:这一条的名次与榜单以它为主体。写成 `F5+F2` 是另一条报名(主单位变成 F5),同一组别里只写一种;
- 默认**均分**:这条成绩拿到的分,F2 与 F5 **各一半**(团体第 1 名 15 分 → 各 7.5 分);奖牌**两边各记一枚整枚**(一块金牌,F2 榜上 1 枚、F5 榜上 1 枚);
- 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛));
- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(可以直接拿去重导)。
- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(**这一列本身可以直接重导**;报表件整份不能导回,要重导用报名名单卡片里的两个「导入格式」按钮,见第九节)。
### 例 5:同一个单位派两个队(团体项目多队)
@@ -503,7 +503,40 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿
---
## 九、给填写人的最短提示(一页版)
## 九、导出的名单怎么导回去
系统里一共能导出**三份**报名名单,**只有标着「导入格式」的那两份是给导入器看的**(三份都在「导出 → 结果」页签的**报名名单卡片**里,四个筛选完全一样):
| 卡片上的按钮 | 导出来是什么 | 文件名 | 对应哪种模板 |
|---|---|---|---|
| 导出报名名单 | 给人看的**报表**:另含小项名称、单位名称与备注,姓名列拼了队员,状态写「已退赛」 | `报名名单.xlsx` | **不能导回**(导入器不认它) |
| 导出(汇总·导入格式) | 13 列,所有行**汇总在一张工作表** | `报名名单(导入格式).xlsx` | **单表模板**(第二、三节) |
| 导出(分小项·导入格式) | 13 列,按「小项名称@组别名称」**分成多张工作表** | `报名名单(导入格式·分小项).xlsx` | **分工作表模板**(第八节) |
- 两个「导入格式」导出的 **13 列与第二节的模板逐列一致,顺序也一致**:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍。**没有小项名称、单位名称与备注列**——重导用不着它们(「项目编码 + 组别」已经足够定位);
- 卡片上的**「表头语言」两个按钮(中文表头 / 英文表头)只影响这两个导出**,与第二节的两套表头同一口径(选英文走规范列名 `event_code` / `category` / `team_code` / `athlete_name` / `id_card` / `gender` / `age` / `bib` / `seed` / `heat` / `status` / `member_names` / `squad`;两条路都能导回来);
- 筛选(小项 / 组别 / 单位 / 批次)与报表**完全一样**,都可留空(留空 = 不筛这一维度);**访客也能导**;
- 导出的文件**原样传回「基础数据 → 报名导入」就能导回去**:**不要改表头、不要插列删列、不要把列名换成别的写法**;要改内容就在格子里改;
- 分小项那种格式的**工作表名仍然是「小项名称@组别名称」**,与模板一字不差(8.1、8.3 的规矩照样适用)——**不要手工改工作表名**,改了就对不上号。本来没人报名的组合不会出一张空表;
- 两种格式都把**团体报名的姓名列留空**、队员写进「队员名单」列(「姓名」列填了就是个人报名,见第三节)——这正是它与报表件最关键的区别;
- 「状态」列写的是导入器认的**四个中文说法**:`正常参赛` / `退赛` / `取消资格` / `未出场`。报表件写的是「已退赛」,**那个导入器不认**(会报「status 无法识别」),所以报表件不能重导;
- 「单位编码」列照旧:联队写 `A+B`(加号左边是主单位);按成员单位筛也能筛出这条报名。
### 9.1 往返验证过的(现场也可以自己验一遍)
把这两种导出件**原样喂回导入接口**各跑一遍,结论是:**零错误、行数一致、13 列逐项一致、团体行没有被变成个人报名**(队员与那支队的关联还在)。现场自验的办法:导出 → **一个字都不改** → 在「基础数据 → 报名导入」原样上传 → 结果应当是「新增 0 / 更新 N / 错误 0」,名单看不出变化。
> ⚠️ 但它**不是"完全无损"的往返**:重导前先看下面三条限制。
### 9.2 三条要知道的限制
1. **「号码布」列可能撞冲突面板**:导出件里写的是**报名上登记的号码布**;导入时对**个人行**会拿它跟**运动员档案里的号码布**比对,两边都非空且不一样,这一行就进「冲突」列表,等你在导入结果里逐条选「保留已有数据 / 使用新数据」(见第六、七节)。库里这两者通常是**一致**的(一致时是静默幂等,什么都不用做);不一致时重导**不是**静默幂等,会走这套既有的冲突流程;
2. **联队的份额权重不会被带回来**:13 列里联队只能写成 `A+B`,重导回去是**平均分(各 50%)**。当初设成 `2:1` 这种非均分的联队,重导之后会变成均分——**表格格式本身表达不了这件事**,要保权重只能在界面上改(到「成绩录入」点那一行的「单位」格);
3. **「队员名单」只增不减**:导出时会**以库里存的名单为底,再把界面上后来补进这支队的队员号码布补上**(否则那些人在重导后就没了)。所以重导后这支队的队员**不会少**,但个别名单的**顺序或写法**可能与最初上传的那张表不完全一样——以系统里现在的名单为准,是正常的。
---
## 十、给填写人的最短提示(一页版)
> 1. 用系统里下载的模板(**中文表头 / 英文表头都能用**,见第二节),**第一行不要动**、同一列不要写两遍;
> 2. **一个人一行**;一个人报几项就写几行;
+247
View File
@@ -0,0 +1,247 @@
package export
import (
"database/sql"
"fmt"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 本文件是「报名名单(导入格式)」的读库层:**逐列与报名导入模板一致**,导出来的表可以直接
// 拿去重新导入(往返口径由 httpapi/entries_import_export.go 组装工作簿,
// 端到端往返用例见 httpapi/entries_import_export_test.go)。
//
// 它与 entries.go 的报表导出读的是同一份数据,但**取值口径刻意不同**,两处不能互相套用:
// - 报表(LoadEntriesWorkbook)是给人看的:团体报名的姓名那一格拼上队员、状态写「已退赛」、
// 单位名列写全成员并带队名;
// - 本格式是给导入器看的:姓名那一格**留空**才算团体报名(填了姓名就会被当成个人报名)、
// 状态只能写导入器认的四个中文口径、列必须与 importColumns 逐列对得上。
//
// 两处一致的是「怎么读」:同一段 JOIN、同一套筛选、同一个 ORDER BY(未分批排最前)、
// 同样的联队回写(A+B)与队员关联。现场拿两份导出件对账时,行序与行数不会对不上。
// EntryImportRow 是「报名名单(导入格式)」的一行:13 个取值对应报名导入模板的 13 列
// (列序不在这里——由 httpapi 侧按 importColumns 的列序取值,见 entryImportRecord)。
//
// 除这 13 列外另带 (小项, 组别) 的身份字段:EventID / CategoryID 用于把行分到工作表上,
// EventName / CategoryName 用于拼「小项名称@组别名称」的工作表名(entrySheetName)。
type EntryImportRow struct {
EventID int64
EventCode string
EventName string
CategoryID int64
CategoryName string
TeamCode string
AthleteName string
IDCard string
Gender string
Age string
Bib string
Seed string
Heat string
Status string
MemberNames string
Squad string
}
// LoadEntriesImportRows 读报名名单,取值口径按「与导入模板逐列一致」来(见 EntryImportRow)。
//
// 筛选条件(EntriesFilter)与报表导出完全一样:某一项为零值即该维度不筛选;按单位筛选时
// **连同联队里的成员单位一起算**(与 LoadEntriesWorkbook 同一条:成员单位同样在这条报名上
// 拿分拿牌,按 B 导出时看不到它,B 就不知道自己派了谁上场)。
//
// opts 只为与其它 Load* 同形:本格式没有抬头(第一行就是列名),Meta 一概不用。
func LoadEntriesImportRows(db *sql.DB, filter EntriesFilter, opts Options) ([]EntryImportRow, error) {
if db == nil {
return nil, ErrNoDatabase
}
// 联队配置:单位编码回写成导入表的 A+B 写法,否则重导会把联队丢掉、只剩主单位那条份额。
joints, err := loadJointTeams(db)
if err != nil {
return nil, err
}
// 队员关联:整份名单读一次,逐行按 entry_id 取(与报表导出同一个 loader)。
members, err := loadEntryMembers(db)
if err != nil {
return nil, err
}
// 这段查询与 entries.go 的 LoadEntriesWorkbook **同源**:同样的 JOIN、同样的筛选、
// 同样的 ORDER BY。刻意没有抽成公共函数——两个导出件读的数据一样、取值口径不同,
// 共用一个函数会让人误以为取值也一样。**改这里要同步改那里**。
query := `
SELECT ev.id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
COALESCE(c.id, 0), COALESCE(c.name, ''),
COALESCE(t.code, ''), COALESCE(en.squad, ''),
COALESCE(a.name, ''), COALESCE(a.id_card, ''), COALESCE(a.gender, ''), a.age,
COALESCE(en.bib, ''), COALESCE(en.seed, ''), en.heat, en.status,
COALESCE(en.member_names, ''), en.athlete_id, en.id
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN teams t ON t.id = en.team_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN athletes a ON a.id = en.athlete_id`
var (
conds []string
args []any
)
for _, f := range []struct {
column string
value int64
}{
{"en.event_id", filter.EventID},
{"en.category_id", filter.CategoryID},
{"en.heat", filter.Heat},
} {
if f.value != 0 {
conds = append(conds, f.column+" = ?")
args = append(args, f.value)
}
}
// 单位筛选要连「联队里的成员单位」一起算:entries.team_id 只是主单位(加号左边那个),
// 但成员单位同样在这条报名上拿分拿牌——按 B 导出时看不到它,B 就不知道自己派了谁上场。
// 主单位那一半条件照旧留着:份额行缺失的报名(手工造的老数据)不能因此从筛选结果里掉出去。
// 份额表不在(库还没跑到 0024)时不加这段:那种库里根本没有联队。
if filter.TeamID != 0 {
teamCond := "en.team_id = ?"
args = append(args, filter.TeamID)
ready, err := entrySharesReady(db)
if err != nil {
return nil, err
}
if ready {
teamCond += ` OR EXISTS (SELECT 1 FROM entry_team_shares s
WHERE s.entry_id = en.id AND s.team_id = ?)`
args = append(args, filter.TeamID)
}
conds = append(conds, "("+teamCond+")")
}
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
// 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。
// 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。
query += `
ORDER BY ev.sort_order, ev.code, c.sort_order, c.id,
en.heat IS NOT NULL, en.heat, t.sort_order, t.code, en.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取报名名单(导入格式): %w", err)
}
defer func() { _ = rows.Close() }()
var out []EntryImportRow
for rows.Next() {
var (
row EntryImportRow
memberJSON string
heat sql.NullInt64
age sql.NullInt64
athleteID sql.NullInt64
athleteName string
entryID int64
)
if err := rows.Scan(&row.EventID, &row.EventCode, &row.EventName,
&row.CategoryID, &row.CategoryName,
&row.TeamCode, &row.Squad,
&athleteName, &row.IDCard, &row.Gender, &age,
&row.Bib, &row.Seed, &heat, &row.Status,
&memberJSON, &athleteID, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描报名名单(导入格式): %w", err)
}
// 联队:单位编码回写成 A+B(与报表导出同一个 joint.Code()),重导回去才认得出这条联队。
if joint, ok := joints[entryID]; ok {
row.TeamCode = joint.Code()
}
if heat.Valid {
row.Heat = strconv.FormatInt(heat.Int64, 10)
}
if athleteID.Valid {
// 个人报名:姓名 / 证件号 / 性别 / 年龄都取这位运动员档案上的值。
// 团体报名(athlete_id 为空)这几列全留空——姓名一旦填了,导入侧就当成个人报名了。
row.AthleteName = athleteName
if age.Valid {
row.Age = strconv.FormatInt(age.Int64, 10)
}
} else {
// 团体报名:队员名单才是这一行的内容(导入侧按号码布认人)。
row.MemberNames = importMemberNames(memberJSON, members[entryID])
}
// 状态写**导入器认的**中文口径(正常参赛 / 退赛 / 取消资格 / 未出场)。
// 报表导出的「已退赛」导入器不认,照抄会让整份表每一条退赛行都报「status 无法识别」。
row.Status = entryImportStatusLabel(row.Status)
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历报名名单(导入格式): %w", err)
}
return out, nil
}
// entryImportStatusLabel 把库里的 status 写成导入器认的中文口径。
//
// 取值必须是 normalizeImportStatus 认得的那几个(见 entry_status.go):正常参赛 / 退赛 /
// 取消资格 / 未出场。**不要**改成录入页徽标的「已退赛」——那是给人看的,导入器不认。
// 认不出的历史值原样写出(库里被 CHECK 约束守着,正常走不到这一支),
// 宁可让导入当场报「status 无法识别」,也不静默把一条状态改写成别的意思。
func entryImportStatusLabel(status string) string {
switch domain.EntryStatus(status) {
case domain.EntryStatusRegistered, "":
return "正常参赛"
case domain.EntryStatusScratched:
return "退赛"
case domain.EntryStatusDQ:
return "取消资格"
case domain.EntryStatusDNS:
return "未出场"
default:
return status
}
}
// importMemberNames 拼团体报名的「队员名单」一格:多个队员用中文逗号「,」分隔
// (导入侧 splitNames 认 , , / 三种分隔符,中文逗号在 Excel 里也最不容易被当成数字分隔符)。
//
// 取值以库里 member_names 存着的 token 为底,再把 entry_athletes 里已关联、但不在 token 里的
// 队员补上(有号码布写号码布,没有号码布的退回姓名)。两头都不能省:
// - 只导 token 会丢掉界面上后补的队员(他们只活在 entry_athletes 里,一个字都不在 token 中);
// - 只导号码布会把纯文字名单丢掉(人没录档案、临时借调的,token 里只有名字)。
//
// 号码布优先是因为导入侧就是按号码布认人的:写了号码布这一格重导回去仍关联得到人,
// 只写姓名则退化成文字(与导出件其它地方的 memberLabel 同一口径)。去重、顺序稳定:
// token 按原书写顺序在前,补上的队员按 entry_athletes 的顺序在后。
func importMemberNames(memberJSON string, members []entryMember) string {
tokens := memberNamesOf(memberJSON)
if tokens == nil {
// 空值、坏 JSON:整格当一段原样带过去。历史上 member_names 被手工写成过别的样子,
// 那些字在这个格式里同样不能丢。
if raw := strings.TrimSpace(memberJSON); raw != "" {
tokens = []string{raw}
}
}
out := make([]string, 0, len(tokens)+len(members))
seen := make(map[string]bool, len(tokens)+len(members))
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
return
}
seen[value] = true
out = append(out, value)
}
for _, token := range tokens {
add(token)
}
for _, member := range members {
if bib := strings.TrimSpace(member.Bib); bib != "" {
add(bib)
continue
}
add(member.Name)
}
return strings.Join(out, ",")
}
@@ -0,0 +1,247 @@
package export
import (
"database/sql"
"testing"
)
// seedEntriesImport 造一份覆盖导入格式全部取值口径的报名名单:
// 个人行(含同一人两个组别)/ 团体行(姓名必须留空)/ 联队(T1+T2)/ 多队(squad)/
// 未分批(heat 为空)/ 四种状态 / 文字名单与队员关联并存。
//
// 与 seedEntries(报表导出的造数)刻意分开:那份的数据是按「给人看的报表」挑的
// (团体行的姓名要回落成员名单、状态要写「已退赛」),两边的期望值不一样。
func seedEntriesImport(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一分公司', '一公司', 1, 1)`,
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T2', '第二分公司', '二公司', 2, 1)`,
`INSERT INTO athletes(name, gender, team_id, id_card, bib, age) VALUES ('张伟', '男', 1, '110101199001011234', 'A001', 35)`,
`INSERT INTO athletes(name, gender, team_id, id_card, bib, age) VALUES ('李强', '男', 2, '110101199202022345', 'B001', 33)`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A002')`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵雷', '男', 1, 'A003')`,
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`,
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E1', '100米跑', 'ongoing', 1)`,
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E2', '4×100米接力', 'ongoing', 2)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
// 1 个人行:号码布带前导零、有种子、已分批、正常参赛。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, seed, heat, status)
VALUES (1, 1, 1, 1, '007', '3', 2, 'registered')`,
// 2 个人行:未分批(heat 为 NULL)、退赛。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (1, 1, 2, 2, 'B001', 'scratched')`,
// 3 团体行 + 一队:文字名单里既有一个已关联的号码布,也有一段认不出人的文字。
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (1, 1, 1, '["A002","预备队员"]', '一队', 'dq')`,
// 4 团体行 + 二队:文字名单里只有文字,队员是界面上后补的(只活在 entry_athletes 里)。
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (1, 1, 1, '["临时队员"]', '二队', 'dns')`,
// 5 个人行:同一个人报第二个组别(号码布重复出现是合法的)。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (1, 2, 1, 1, '007', 'registered')`,
// 6 联队团体行:T1 + T2 各占一半,队员名单里写的是姓名(要补上号码布才认得出人)。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (2, 3, 1, '["王磊","赵雷"]', 'registered')`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (6, 1, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (6, 2, 1, 1, 1)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (4, 4, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (6, 3, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (6, 4, 1)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// importKey 是测试里给每行起的键:小项编码 + 组别 + 队伍 + 号码布(四者合起来唯一)。
func importKey(r EntryImportRow) string {
return r.EventCode + "\x00" + r.CategoryName + "\x00" + r.Squad + "\x00" + r.Bib
}
func loadImportRowsByKey(t *testing.T, db *sql.DB, filter EntriesFilter) map[string]EntryImportRow {
t.Helper()
rows, err := LoadEntriesImportRows(db, filter, Options{})
if err != nil {
t.Fatalf("读取报名名单(导入格式): %v", err)
}
out := make(map[string]EntryImportRow, len(rows))
for _, r := range rows {
key := importKey(r)
if _, dup := out[key]; dup {
t.Fatalf("同一份名单里出现两行同样的键 %q:测试数据的键不够唯一", key)
}
out[key] = r
}
return out
}
func TestLoadEntriesImportRows(t *testing.T) {
db := openEventDB(t)
seedEntriesImport(t, db)
rows, err := LoadEntriesImportRows(db, EntriesFilter{}, Options{})
if err != nil {
t.Fatalf("读取报名名单(导入格式): %v", err)
}
if len(rows) != 6 {
t.Fatalf("行数 = %d,期望 6", len(rows))
}
got := loadImportRowsByKey(t, db, EntriesFilter{})
// 个人行:姓名 / 证件号 / 性别 / 年龄取运动员档案,号码布 / 种子 / 批次 / 状态取报名上的值。
personal := got["E1\x00男子\x00\x00007"]
if personal.AthleteName != "张伟" || personal.IDCard != "110101199001011234" ||
personal.Gender != "男" || personal.Age != "35" {
t.Errorf("个人行的人员字段 = %+v,期望张伟 / 110101199001011234 / 男 / 35", personal)
}
if personal.TeamCode != "T1" || personal.Seed != "3" || personal.Heat != "2" || personal.Status != "正常参赛" {
t.Errorf("个人行的报名字段 = %+v,期望 T1 / 种子 3 / 批次 2 / 正常参赛", personal)
}
if personal.MemberNames != "" {
t.Errorf("个人行的队员名单 = %q,期望留空(队员只对团体报名生效)", personal.MemberNames)
}
if personal.EventID != 1 || personal.CategoryID != 1 || personal.EventName != "100米跑" || personal.CategoryName != "男子" {
t.Errorf("个人行的 (小项, 组别) 身份字段 = %+v,组装工作表名要用它", personal)
}
// 未分批:heat 留空;四种状态的中文口径逐个钉住。
unbatched := got["E1\x00男子\x00\x00B001"]
if unbatched.Heat != "" {
t.Errorf("未分批行的批次 = %q,期望留空", unbatched.Heat)
}
if unbatched.Status != "退赛" {
t.Errorf("scratched 的状态 = %q,期望 退赛(导入器认的中文口径)", unbatched.Status)
}
if s := got["E1\x00男子\x00一队\x00"].Status; s != "取消资格" {
t.Errorf("dq 的状态 = %q,期望 取消资格", s)
}
if s := got["E1\x00男子\x00二队\x00"].Status; s != "未出场" {
t.Errorf("dns 的状态 = %q,期望 未出场", s)
}
for _, key := range []string{"E1\x00男子\x00\x00007", "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"} {
if s := got[key].Status; s != "正常参赛" {
t.Errorf("%s 的状态 = %q,期望 正常参赛", key, s)
}
}
// 号码布带前导零:它是字符串,导出件里不能变成 7。
if got["E1\x00男子\x00\x00007"].Bib != "007" {
t.Errorf("号码布 = %q,期望 007", got["E1\x00男子\x00\x00007"].Bib)
}
// 团体行:姓名必须留空(填了姓名导入侧就当成个人报名了)。
teamOne := got["E1\x00男子\x00一队\x00"]
if teamOne.AthleteName != "" || teamOne.IDCard != "" || teamOne.Gender != "" || teamOne.Age != "" {
t.Errorf("团体行的人员字段 = %+v,期望全部留空(姓名一填就变成个人报名)", teamOne)
}
// 队员名单:文字 token 原样保留,已关联的队员不重复写(A002 与王磊是同一个人)。
if teamOne.MemberNames != "A002,预备队员" {
t.Errorf("团体行的队员名单 = %q,期望 A002,预备队员(token 去重在前)", teamOne.MemberNames)
}
// 界面上后补的队员(只活在 entry_athletes 里)必须按号码布补进队员名单,否则重导就丢了这个队员。
teamTwo := got["E1\x00男子\x00二队\x00"]
if teamTwo.MemberNames != "临时队员,A003" {
t.Errorf("团体行的队员名单 = %q,期望 临时队员,A003(后补的队员按号码布补上)", teamTwo.MemberNames)
}
// 联队:单位编码回写成 A+B(与报表导出同一个 joint.Code()),重导才认得回这条联队。
joins := got["E2\x00男子\x00\x00"]
if joins.TeamCode != "T1+T2" {
t.Errorf("联队的单位编码 = %q,期望 T1+T2", joins.TeamCode)
}
if joins.MemberNames != "王磊,赵雷,A002,A003" {
t.Errorf("联队的队员名单 = %q,期望 王磊,赵雷,A002,A003(文字名单 + 补上的号码布)", joins.MemberNames)
}
}
// 筛选条件与报表导出同一套:按单位筛选要连联队里的成员单位一起算,
// 否则 B 按自己导出时看不到「A+B 那条联队里有我派的人」。
func TestLoadEntriesImportRowsFilters(t *testing.T) {
db := openEventDB(t)
seedEntriesImport(t, db)
cases := []struct {
name string
filter EntriesFilter
want []string
}{
{"全部", EntriesFilter{}, []string{
"E1\x00男子\x00\x00B001", "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00",
"E1\x00男子\x00二队\x00", "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"}},
{"按小项", EntriesFilter{EventID: 1}, []string{
"E1\x00男子\x00\x00B001", "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00",
"E1\x00男子\x00二队\x00", "E1\x00女子\x00\x00007"}},
{"按组别", EntriesFilter{CategoryID: 3}, []string{"E2\x00男子\x00\x00"}},
{"按批次", EntriesFilter{Heat: 2}, []string{"E1\x00男子\x00\x00007"}},
// T2 是联队成员:那条联队报名必须出现(主单位是 T1)。
{"按单位(含联队成员单位)", EntriesFilter{TeamID: 2}, []string{"E1\x00男子\x00\x00B001", "E2\x00男子\x00\x00"}},
{"按单位(联队主单位)", EntriesFilter{TeamID: 1}, []string{
"E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00", "E1\x00男子\x00二队\x00",
"E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := loadImportRowsByKey(t, db, tc.filter)
if len(got) != len(tc.want) {
t.Fatalf("筛选后行数 = %d,期望 %d(%v)", len(got), len(tc.want), keysOf(got))
}
for _, key := range tc.want {
if _, ok := got[key]; !ok {
t.Errorf("筛选结果里缺少 %q(实际 %v)", key, keysOf(got))
}
}
})
}
// 没有数据不是错误:批次 99 没人报名,返回空表(导出件仍是一张带表头的表)。
rows, err := LoadEntriesImportRows(db, EntriesFilter{Heat: 99}, Options{})
if err != nil {
t.Fatalf("读取空结果: %v", err)
}
if len(rows) != 0 {
t.Errorf("批次 99 应为空,得到 %d 行", len(rows))
}
// 没有库时必须报错,不能返回一份空名单(那会被当成「这次没人报名」)。
if _, err := LoadEntriesImportRows(nil, EntriesFilter{}, Options{}); err != ErrNoDatabase {
t.Errorf("空库连接的错误 = %v,期望 ErrNoDatabase", err)
}
}
func keysOf(rows map[string]EntryImportRow) []string {
out := make([]string, 0, len(rows))
for key := range rows {
out = append(out, key)
}
return out
}
// 状态列必须写**导入器认的**中文口径:报表那份写的「已退赛」导入器不认,
// 照抄过去会让整份表每一条退赛行都报「status 无法识别」。
func TestEntryImportStatusLabel(t *testing.T) {
cases := map[string]string{
"": "正常参赛",
"registered": "正常参赛",
"scratched": "退赛",
"dq": "取消资格",
"dns": "未出场",
}
for status, want := range cases {
if got := entryImportStatusLabel(status); got != want {
t.Errorf("状态 %q 的标签 = %q,期望 %q", status, got, want)
}
}
for _, label := range cases {
if label == "已退赛" {
t.Errorf("「已退赛」是报表口径,导入器认的是「退赛」")
}
}
// 认不出的历史值原样写出:宁可让导入当场报「status 无法识别」,也不静默改成别的意思。
if got := entryImportStatusLabel("unknown"); got != "unknown" {
t.Errorf("认不出的状态 = %q,期望原样写出", got)
}
}
@@ -0,0 +1,315 @@
package httpapi
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// 报名名单(导入格式)导出:**逐列与报名导入模板一致**,导出来的文件可以直接拿去重新导入。
//
// 与报表导出(GET /api/export/entries.xlsx,internal/export/entries.go)是两份东西,
// 各自的口径不能互相套用:
// - 报表是给人看的:姓名列拼上队员、状态写「已退赛」、表头是中文名,导入器不认它;
// - 这份按 importColumns 逐列写(表头就是导入模板的列名),团体报名的姓名列留空、
// 状态写导入器认的中文口径、联队写 A+B —— 重导回去一行都不会错位。
//
// 两种布局:
// - flat:所有行汇总在一张工作表里,**表名不带 @**(导入侧只要见到一张表名带 @ 就整份
// 按分工作表模板解析,这张汇总表定不出 (小项, 组别) 就会被拒绝);
// - sheets:按「小项@组别」分工作表,表名由 entrySheetName 算,与导入模板、导入解析三处同源。
// **不能多出任何一张工作表**(一张说明表就会让整份文件按分工作表口径解析对不上号)。
// 两种布局的 format 取值。
const (
entryImportFormatFlat = "flat"
entryImportFormatSheets = "sheets"
)
// entryImportFlatSheet 是 flat 布局唯一那张工作表的名字(与报表导出的表名一致)。
// 它是**普通表名**:绝不能让 entrySheetName 的结果在这里出现,否则这份文件会被当成
// 分工作表模板解析,而它定不出小项与组别 → 整份被拒绝。
const entryImportFlatSheet = "报名名单"
// exportEntriesImportXLSX 导出「与报名导入模板逐列一致」的报名名单(xlsx)。
//
// 筛选参数(eventId / categoryId / teamId / heat)与报表导出完全一样,都可省;
// format 缺省 flat;lang 只有 "en" 走英文规范列名,其余一律中文(与模板下载的两个按钮同一口径)。
func (s *server) exportEntriesImportXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format")))
if format == "" {
format = entryImportFormatFlat
}
if format != entryImportFormatFlat && format != entryImportFormatSheets {
return nil, badRequest("format 只能是 flat(一张表汇总)或 sheets(按小项@组别分工作表)", format)
}
language := "zh"
if strings.ToLower(strings.TrimSpace(r.URL.Query().Get("lang"))) == "en" {
language = "en"
}
// 与报表导出(exportEntriesXLSX)同一套筛选参数:留空 = 不筛,填了必须是正整数。
filter := export.EntriesFilter{}
for _, f := range []struct {
key string
dst *int64
what string
}{
{"eventId", &filter.EventID, "小项"},
{"categoryId", &filter.CategoryID, "组别"},
{"teamId", &filter.TeamID, "单位"},
{"heat", &filter.Heat, "批次"},
} {
id, err := filterIDQuery(r, f.key, f.what)
if err != nil {
return nil, err
}
*f.dst = id
}
rows, err := export.LoadEntriesImportRows(s.ev, filter, export.Options{})
if err != nil {
return nil, internal(err)
}
sheets, err := s.entryImportSheets(r.Context(), format, rows)
if err != nil {
return nil, err
}
filename, ascii := entryImportFilename(format)
return s.streamDownload(w, ascii, filename, xlsxMIME, func(out io.Writer) error {
return writeEntriesImportXLSX(out, language, sheets)
})
}
// entryImportSheets 把导出的行分到工作表上。
//
// flat(或一条报名都没有)永远只有一张「报名名单」表:空名单也要给一张带表头的表,
// 拿到手的人一眼看得出是「这次没有数据」,而不是遇到一个莫名其妙的错误。
func (s *server) entryImportSheets(ctx context.Context, format string, rows []export.EntryImportRow) ([]entryImportSheet, error) {
if format != entryImportFormatSheets || len(rows) == 0 {
return []entryImportSheet{{Name: entryImportFlatSheet, Rows: rows}}, nil
}
// 工作表名必须与**导入侧**算出来的完全一致,所以这里重算同一份计划(同一个
// loadEntrySheetScopes + newEntrySheetPlan,下载模板、导入解析、本导出三处同源)。
// 自己拼「小项@组别」在撞名去重与 31 字符截断上会与导入侧分叉,分叉的后果是整份表导不回去。
scopes, err := s.loadEntrySheetScopes(ctx)
if err != nil {
return nil, err
}
plan := newEntrySheetPlan(scopes)
nameOf := make(map[[2]int64]string, len(plan.sheets))
for _, planned := range plan.sheets {
nameOf[[2]int64{planned.Scope.EventID, planned.Scope.CategoryID}] = planned.Name
}
grouped := make(map[[2]int64][]export.EntryImportRow, len(plan.sheets))
unnameable := make([]string, 0, 2)
seenUnnameable := make(map[string]bool, 2)
for _, row := range rows {
key := [2]int64{row.EventID, row.CategoryID}
if _, ok := nameOf[key]; !ok {
// 只有名字里带 @ 的小项 / 组别做不出工作表名(entrySheetName 那里就被排除了)。
// 静默丢掉这些行等于现场少了几十条报名而毫不知情,所以整次拒绝并点名。
label := row.EventName + entrySheetSeparator + row.CategoryName
if !seenUnnameable[label] {
seenUnnameable[label] = true
unnameable = append(unnameable, label)
}
continue
}
grouped[key] = append(grouped[key], row)
}
if len(unnameable) > 0 {
if len(plan.excluded) > 0 {
return nil, conflict(entrySheetExcludedMessage(plan.excluded))
}
return nil, conflict("这几个小项 / 组别做不出「小项名称" + entrySheetSeparator + "组别名称」的工作表名:" +
strings.Join(unnameable, "、") + "。请到「基础数据 → 大项 / 小项」「组别」检查它们的名字," +
"或改用 format=flat(一张表汇总)导出")
}
// 工作表顺序照计划来(与模板的先后一致),表内行序照导出的 ORDER BY:
// 同一份数据每次导出的样子都一样,现场两张表对着看时不用重新找行。
out := make([]entryImportSheet, 0, len(plan.sheets))
for _, planned := range plan.sheets {
key := [2]int64{planned.Scope.EventID, planned.Scope.CategoryID}
if len(grouped[key]) == 0 {
// 这个小项组别这次没有人报名:不出空表。空表是模板用来教人填的,数据导出件里
// 几十张空表只会让人以为名单丢了。
continue
}
out = append(out, entryImportSheet{Name: planned.Name, Rows: grouped[key]})
}
return out, nil
}
// entryImportFilename 是两种布局的文件名:中文名给浏览器,ASCII 名给不认 filename* 的老客户端。
func entryImportFilename(format string) (filename, ascii string) {
if format == entryImportFormatSheets {
return "报名名单(导入格式·分小项).xlsx", "entries-import-sheets.xlsx"
}
return "报名名单(导入格式).xlsx", "entries-import.xlsx"
}
// entryImportSheet 是一张待写的工作表:名字 + 该表的数据行(行序即写入顺序)。
type entryImportSheet struct {
Name string
Rows []export.EntryImportRow
}
// writeEntriesImportXLSX 写整份「导入格式」工作簿。
//
// 版式与报名模板刻意一致:**表头就在第 1 行**,没有标题行 / 副标题行——导入器认第 1 行为表头,
// 在它上面加一行标题,用户把这份表填回来时会被判成「报名表缺少列」。
// 列宽照模板给(entryTemplateWidths),但**不挂批注与输入提示**:那是教人填空白模板的东西,
// 数据导出件里挂上去只会挡住看数据的人。
func writeEntriesImportXLSX(out io.Writer, language string, sheets []entryImportSheet) error {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
// 文档属性照导出件那一套(internal/export 的 setDocProps):Excel「文件 → 信息」里
// 不该出现第三方库的作者名(xuri)与一个假日期——这份表是要发给现场、发出去对账的。
now := time.Now().Format(time.RFC3339)
if err := f.SetDocProps(&excelize.DocProperties{
Creator: entryTemplateAuthor, LastModifiedBy: entryTemplateAuthor, Created: now, Modified: now,
}); err != nil {
return fmt.Errorf("httpapi: 设置报名名单文档属性: %w", err)
}
if err := f.SetAppProps(&excelize.AppProperties{Application: entryTemplateAuthor}); err != nil {
return fmt.Errorf("httpapi: 设置报名名单应用属性: %w", err)
}
headerStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11, Family: "微软雅黑", Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"3482FF"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
})
if err != nil {
return fmt.Errorf("httpapi: 创建报名名单表头样式: %w", err)
}
// 表头与列宽都按 importColumns 的列序来,与模板共用同一份实现(改 importColumns 三处一起变)。
headers := entryTemplateHeader(language)
for i, sheet := range sheets {
if i == 0 {
if err := f.SetSheetName("Sheet1", sheet.Name); err != nil {
return fmt.Errorf("httpapi: 重命名报名名单工作表 %s: %w", sheet.Name, err)
}
} else if _, err := f.NewSheet(sheet.Name); err != nil {
return fmt.Errorf("httpapi: 新建报名名单工作表 %s: %w", sheet.Name, err)
}
if err := writeEntryImportHeader(f, sheet.Name, headers, headerStyle); err != nil {
return err
}
for n, row := range sheet.Rows {
for column, value := range entryImportRecord(row) {
cell, err := excelize.CoordinatesToCellName(column+1, n+2)
if err != nil {
return err
}
if err := f.SetCellValue(sheet.Name, cell, value); err != nil {
return err
}
}
}
// 冻结表头:一份名单几十行,往下翻的时候列名得一直看得见(与模板同一版式)。
if err := f.SetPanes(sheet.Name, &excelize.Panes{
Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft",
}); err != nil {
return err
}
}
if err := f.Write(out); err != nil {
return fmt.Errorf("httpapi: 写出报名名单(导入格式): %w", err)
}
return nil
}
// writeEntryImportHeader 写第 1 行表头并设各列列宽。
// 列宽取自模板同一张表(entryTemplateWidths),没登记的列用 defaultEntryTemplateWidth。
func writeEntryImportHeader(f *excelize.File, sheet string, headers []string, style int) error {
for column, title := range headers {
cell, err := excelize.CoordinatesToCellName(column+1, 1)
if err != nil {
return err
}
if err := f.SetCellValue(sheet, cell, title); err != nil {
return err
}
name, err := excelize.ColumnNumberToName(column + 1)
if err != nil {
return err
}
width := float64(defaultEntryTemplateWidth)
if column < len(importColumns) {
if custom, ok := entryTemplateWidths[importColumns[column].name]; ok {
width = custom
}
}
if err := f.SetColWidth(sheet, name, name, width); err != nil {
return err
}
}
if len(headers) == 0 {
return nil
}
last, err := excelize.ColumnNumberToName(len(headers))
if err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A1", last+"1", style); err != nil {
return err
}
return f.SetRowHeight(sheet, 1, 24)
}
// entryImportRecord 按 importColumns 的列序把一行导成格子值。
//
// **列序只有一个来源**:这里绝不另写一份列序常量,而是逐列按 importColumns 里的列名取值——
// importColumns 增删列或调顺序,这份导出件跟着变,两边的列序不可能分叉。
// importColumns 将来加了新列而这里没接上,会写出一列空格子;
// entries_import_export_internal_test.go 的 TestEntryImportRecordCoversImportColumns 会在那时当场红。
func entryImportRecord(row export.EntryImportRow) []any {
cells := make([]any, 0, len(importColumns))
for _, column := range importColumns {
switch column.name {
case "event_code":
cells = append(cells, row.EventCode)
case "category":
cells = append(cells, row.CategoryName)
case "team_code":
cells = append(cells, row.TeamCode)
case "athlete_name":
cells = append(cells, row.AthleteName)
case "id_card":
cells = append(cells, row.IDCard)
case "gender":
cells = append(cells, row.Gender)
case "age":
cells = append(cells, row.Age)
case "bib":
cells = append(cells, row.Bib)
case "seed":
cells = append(cells, row.Seed)
case "heat":
cells = append(cells, row.Heat)
case "status":
cells = append(cells, row.Status)
case "member_names":
cells = append(cells, row.MemberNames)
case "squad":
cells = append(cells, row.Squad)
default:
cells = append(cells, "")
}
}
return cells
}
@@ -0,0 +1,106 @@
package httpapi
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// 导出件的列序与列名只有一个来源:importColumns。
//
// 这条用例把 entryImportRecord 的每一列都钉住:importColumns 新增一列而这里没接上,
// 那一列会写出一整列空格子——现场拿到的是「报名表缺少列」或悄悄丢数据;
// 顺序被调换则会让姓名写进证件号那一格。两种都必须在这里当场红。
func TestEntryImportRecordCoversImportColumns(t *testing.T) {
row := export.EntryImportRow{
EventID: 1,
EventCode: "E1",
EventName: "100米跑",
CategoryID: 1,
CategoryName: "男子",
TeamCode: "T1",
AthleteName: "张伟",
IDCard: "110101199001011234",
Gender: "男",
Age: "35",
Bib: "007",
Seed: "3",
Heat: "2",
Status: "正常参赛",
MemberNames: "A002",
Squad: "一队",
}
// 每个字段都给一个互不相同的值:列序错了(取了别的字段)也看得出来。
want := map[string]any{
"event_code": "E1",
"category": "男子",
"team_code": "T1",
"athlete_name": "张伟",
"id_card": "110101199001011234",
"gender": "男",
"age": "35",
"bib": "007",
"seed": "3",
"heat": "2",
"status": "正常参赛",
"member_names": "A002",
"squad": "一队",
}
if len(want) != len(importColumns) {
t.Fatalf("importColumns 有 %d 列,本用例的期望表有 %d 条:加了列就要在这里补一条期望值",
len(importColumns), len(want))
}
cells := entryImportRecord(row)
if len(cells) != len(importColumns) {
t.Fatalf("导出的格子数 = %d,期望与 importColumns 一致的 %d 列", len(cells), len(importColumns))
}
for i, column := range importColumns {
expected, ok := want[column.name]
if !ok {
t.Errorf("importColumns 的 %s 列在 entryImportRecord 里没有接上取值(会写出一列空格子)", column.name)
continue
}
if cells[i] != expected {
t.Errorf("第 %d 列(%s)= %v,期望 %v", i+1, column.name, cells[i], expected)
}
}
}
// 表头必须是 entryTemplateHeader 给的那一份(中英各一份,列序与 importColumns 一致)。
// 这里只确认「导出用的就是模板那一份」,不是另写一遍列名——列名再写一遍就会分叉。
func TestEntryImportHeaderComesFromTemplate(t *testing.T) {
for _, language := range []string{"zh", "en"} {
want := entryTemplateHeader(language)
if len(want) != len(importColumns) {
t.Fatalf("%s 表头有 %d 列,importColumns 有 %d 列", language, len(want), len(importColumns))
}
record := entryImportRecord(export.EntryImportRow{})
if len(record) != len(want) {
t.Fatalf("%s 表头与导出格子的列数不一致:%d vs %d", language, len(want), len(record))
}
}
if entryTemplateHeader("zh")[0] != importColumns[0].aliases[0] {
t.Errorf("中文表头第 1 列 = %q,期望 %q", entryTemplateHeader("zh")[0], importColumns[0].aliases[0])
}
if entryTemplateHeader("en")[0] != importColumns[0].name {
t.Errorf("英文表头第 1 列 = %q,期望 %q", entryTemplateHeader("en")[0], importColumns[0].name)
}
}
// flat 布局那张汇总表的表名里**不能有 @**:导入侧只要见到一张表名带 @ 就整份按分工作表口径解析,
// 而汇总表定不出 (小项, 组别),整份会被拒绝。
func TestEntryImportFlatSheetNameHasNoSeparator(t *testing.T) {
if strings.Contains(entryImportFlatSheet, entrySheetSeparator) {
t.Fatalf("flat 布局的表名 %q 里不能有 %q", entryImportFlatSheet, entrySheetSeparator)
}
name, ascii := entryImportFilename(entryImportFormatFlat)
if name != "报名名单(导入格式).xlsx" || ascii != "entries-import.xlsx" {
t.Errorf("flat 的文件名 = %q / %q", name, ascii)
}
name, ascii = entryImportFilename(entryImportFormatSheets)
if name != "报名名单(导入格式·分小项).xlsx" || ascii != "entries-import-sheets.xlsx" {
t.Errorf("sheets 的文件名 = %q / %q", name, ascii)
}
}
@@ -0,0 +1,534 @@
package httpapi_test
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
// 报名名单(导入格式)导出:列名与列序与报名导入模板**逐列一致**,导出来的文件可以直接重导。
//
// 本文件的核心是往返(round-trip):把导出的字节**原样喂给现成的报名导入解析器**
// (POST /api/entries/import,与现场上传的是同一条链),再查库。
// 这是「格式对得上」唯一算数的证明——列序错位、团体行姓名被填上、状态写「已退赛」,
// 任何一处都会在这里当场红,而不是等现场导入时才发现。
// importExportHeader 是导出件的表头(中文口径,缺省语言)。
//
// 刻意在这里**逐字写死**:验的就是「列序与 importColumns 逐列一致」这个对外契约,
// 写成 entryTemplateHeader(language) 的断言等于什么都没验(两边一起改会一起错)。
var importExportHeader = []string{
"项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄",
"号码布", "种子", "批次", "状态", "队员名单", "队伍",
}
// importExportEnglishHeader 是 lang=en 的英文规范列名。
var importExportEnglishHeader = []string{
"event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age",
"bib", "seed", "heat", "status", "member_names", "squad",
}
// importRoundTripResult 是导入响应的读数(只取本用例要断言的字段)。
type importRoundTripResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
Notices []string `json:"notices"`
Conflicts []json.RawMessage `json:"conflicts"`
}
// seedImportRoundTrip 造一份覆盖两种布局与全部取值口径的报名名单:
// 个人行(含同一人两个组别)/ 团体行(姓名留空)/ 联队(T1+T2)/ 多队(squad)/
// 未分批(heat 为空)/ 四种状态 / 文字名单与队员关联并存。
func seedImportRoundTrip(t *testing.T, env *testEnv) {
t.Helper()
t1 := env.insert(t, `INSERT INTO teams(code, name, short_name, sort_order, active)
VALUES ('T1', '第一分公司', '一公司', 1, 1)`)
t2 := env.insert(t, `INSERT INTO teams(code, name, short_name, sort_order, active)
VALUES ('T2', '第二分公司', '二公司', 2, 1)`)
athZhang := env.insert(t, `INSERT INTO athletes(name, gender, team_id, id_card, bib, age)
VALUES ('张伟', '男', ?, '110101199001011234', '007', 35)`, t1)
athLi := env.insert(t, `INSERT INTO athletes(name, gender, team_id, id_card, bib, age)
VALUES ('李强', '男', ?, '110101199202022345', 'B001', 33)`, t2)
athWang := env.insert(t, `INSERT INTO athletes(name, gender, team_id, bib)
VALUES ('王磊', '男', ?, 'A002')`, t1)
athZhao := env.insert(t, `INSERT INTO athletes(name, gender, team_id, bib)
VALUES ('赵雷', '男', ?, 'A003')`, t1)
sport := env.insert(t, `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`)
e1 := env.insert(t, `INSERT INTO events(sport_id, code, name, status, sort_order)
VALUES (?, 'E1', '100米跑', 'ongoing', 1)`, sport)
e2 := env.insert(t, `INSERT INTO events(sport_id, code, name, status, sort_order)
VALUES (?, 'E2', '4×100米接力', 'ongoing', 2)`, sport)
c1 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '男子', 1)`, e1)
c2 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '女子', 2)`, e1)
c3 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '男子', 1)`, e2)
// 个人行:号码布带前导零、有种子、已分批、正常参赛。
// 号码布刻意取运动员档案上那个(导入侧的号码布列既写报名也认人,两边不同会被当成冲突)。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, seed, heat, status)
VALUES (?, ?, ?, ?, '007', '3', 2, 'registered')`, e1, c1, t1, athZhang)
// 个人行:未分批(heat 为 NULL)、退赛。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (?, ?, ?, ?, 'B001', 'scratched')`, e1, c1, t2, athLi)
// 团体行 + 一队:文字名单里既有一个已关联的号码布,也有一段认不出人的文字。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (?, ?, ?, '["A002","预备队员"]', '一队', 'dq')`, e1, c1, t1)
// 团体行 + 二队:文字名单里只有文字,队员是界面上后补的(只活在 entry_athletes 里)。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (?, ?, ?, '["临时队员"]', '二队', 'dns')`, e1, c1, t1)
// 个人行:同一个人报第二个组别(号码布重复出现是合法的)。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (?, ?, ?, ?, '007', 'registered')`, e1, c2, t1, athZhang)
// 联队团体行:T1 + T2 各占一半,队员名单里写的是姓名(要补上号码布才认得出人)。
joint := env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (?, ?, ?, '["王磊","赵雷"]', 'registered')`, e2, c3, t1)
env.exec(t, `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 0)`, joint, t1)
env.exec(t, `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 1)`, joint, t2)
// 团体行的队员关联:一条与文字名单里的号码布重复(去重),一条只存在于关联里(必须补进名单)。
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
SELECT id, ?, 0 FROM entries WHERE event_id = ? AND squad = '一队'`, athWang, e1)
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
SELECT id, ?, 0 FROM entries WHERE event_id = ? AND squad = '二队'`, athZhao, e1)
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (?, ?, 0)`, joint, athWang)
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (?, ?, 1)`, joint, athZhao)
}
// importExportRow 是导出件里的一行(按表头列序读)。
type importExportRow struct {
eventCode, category, teamCode, athleteName, idCard, gender, age string
bib, seed, heat, status, memberNames, squad string
}
// label 是测试里的行标识:小项 / 组别 / 队伍 / 号码布(四者合起来唯一)。
func (r importExportRow) label() string {
return r.eventCode + "/" + r.category + "/" + r.squad + "/" + r.bib
}
// readImportExportRows 读出一份导出件里的全部数据行,并逐张表核对表头与列宽。
func readImportExportRows(t *testing.T, f *excelize.File) []importExportRow {
t.Helper()
var out []importExportRow
for _, sheet := range f.GetSheetList() {
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
if len(rows) == 0 {
t.Fatalf("工作表 %s 是空的:第 1 行必须是表头", sheet)
}
for i, want := range importExportHeader {
if got := cellAt(rows, 0, i); got != want {
t.Errorf("工作表 %s 表头第 %d 列 = %q,期望 %q", sheet, i+1, got, want)
}
}
for r := 1; r < len(rows); r++ {
out = append(out, importExportRow{
eventCode: cellAt(rows, r, 0), category: cellAt(rows, r, 1),
teamCode: cellAt(rows, r, 2), athleteName: cellAt(rows, r, 3),
idCard: cellAt(rows, r, 4), gender: cellAt(rows, r, 5), age: cellAt(rows, r, 6),
bib: cellAt(rows, r, 7), seed: cellAt(rows, r, 8), heat: cellAt(rows, r, 9),
status: cellAt(rows, r, 10), memberNames: cellAt(rows, r, 11), squad: cellAt(rows, r, 12),
})
}
// 列宽照模板给:号码布那列窄、证件号那列宽,导出即可打印(与下载的模板同一观感)。
if width, err := f.GetColWidth(sheet, "A"); err == nil && width != 14 {
t.Errorf("工作表 %s 的 A 列宽 = %v,期望 14(与模板一致)", sheet, width)
}
if width, err := f.GetColWidth(sheet, "F"); err == nil && width != 8 {
t.Errorf("工作表 %s 的 F 列宽 = %v,期望 8(与模板一致)", sheet, width)
}
// 数据导出件不挂批注与输入提示:那是空模板教填表人的东西。
if comments, err := f.GetComments(sheet); err == nil && len(comments) != 0 {
t.Errorf("工作表 %s 挂了 %d 条批注,数据导出件不该有", sheet, len(comments))
}
}
return out
}
// wantImportExportRows 是这份导出件应当逐字写出来的内容(列序、取值口径全钉在这里)。
func wantImportExportRows(t *testing.T) map[string]importExportRow {
t.Helper()
rows := []importExportRow{
// 个人行:人员字段取运动员档案。
{eventCode: "E1", category: "男子", teamCode: "T1", athleteName: "张伟",
idCard: "110101199001011234", gender: "男", age: "35",
bib: "007", seed: "3", heat: "2", status: "正常参赛"},
// 未分批:批次留空;退赛写导入器认的中文口径。
{eventCode: "E1", category: "男子", teamCode: "T2", athleteName: "李强",
idCard: "110101199202022345", gender: "男", age: "33",
bib: "B001", status: "退赛"},
// 团体行:姓名必须留空(填了就会被当成个人报名),队员名单是这一行的内容。
{eventCode: "E1", category: "男子", teamCode: "T1", bib: "", squad: "一队",
status: "取消资格", memberNames: "A002,预备队员"},
{eventCode: "E1", category: "男子", teamCode: "T1", squad: "二队",
status: "未出场", memberNames: "临时队员,A003"},
// 同一个人报第二个组别:号码布照样写出来(导入侧按证件号认得出是一个人)。
{eventCode: "E1", category: "女子", teamCode: "T1", athleteName: "张伟",
idCard: "110101199001011234", gender: "男", age: "35", bib: "007", status: "正常参赛"},
// 联队:单位编码写 A+B(与导入表的写法逐字一致),文字名单后面补上队员的号码布。
{eventCode: "E2", category: "男子", teamCode: "T1+T2", status: "正常参赛",
memberNames: "王磊,赵雷,A002,A003"},
}
out := make(map[string]importExportRow, len(rows))
for _, r := range rows {
out[r.label()] = r
}
return out
}
func TestEntriesImportExportRoundTrip(t *testing.T) {
for _, format := range []string{"flat", "sheets"} {
t.Run(format, func(t *testing.T) {
env := newTestEnv(t)
seedImportRoundTrip(t, env)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
before := snapshotEntries(t, env)
status, respHeader, body := env.getRaw(t, client, "/api/export/entries-import.xlsx?format="+format)
if status != http.StatusOK {
t.Fatalf("导出报名名单(导入格式)应 200,得到 %d %s", status, body)
}
if format == "sheets" {
assertContentDisposition(t, respHeader.Get("Content-Disposition"),
"entries-import-sheets.xlsx", "报名名单(导入格式·分小项).xlsx")
} else {
assertContentDisposition(t, respHeader.Get("Content-Disposition"),
"entries-import.xlsx", "报名名单(导入格式).xlsx")
}
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
assertImportExportSheets(t, f, format)
exported := readImportExportRows(t, f)
want := wantImportExportRows(t)
if len(exported) != len(want) {
t.Fatalf("导出行数 = %d,期望 %d(%+v)", len(exported), len(want), exported)
}
for _, row := range exported {
expected, ok := want[row.label()]
if !ok {
t.Errorf("导出件里多出一行 %s:%+v", row.label(), row)
continue
}
if row != expected {
t.Errorf("导出件里 %s = %+v,期望 %+v", row.label(), row, expected)
}
}
// 把导出的字节原样喂给报名导入解析器(与现场上传的是同一条链)。
status, resp := env.uploadEntries(t, client, "entries.xlsx", body)
if status != http.StatusOK || !resp.OK {
t.Fatalf("重新导入导出件应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[importRoundTripResult](t, resp)
// 断言一:解析零错误(状态列写「已退赛」这类对不上的口径会在这里现形)。
if len(result.Errors) != 0 {
t.Fatalf("重新导入必须零错误,得到 %v", result.Errors)
}
if len(result.Conflicts) != 0 {
t.Fatalf("重新导入不该产生冲突(逐行都该命中库里那条报名),得到 %+v", result.Conflicts)
}
if result.Skipped != 0 || result.Created != 0 {
t.Fatalf("重新导入应逐行更新已有报名:created=%d skipped=%d(不能新建、不能跳过)",
result.Created, result.Skipped)
}
// 断言二:导入出来的行数与导出行数一致。
if result.Updated != len(exported) {
t.Fatalf("更新行数 = %d,期望 %d(与导出行数一致)", result.Updated, len(exported))
}
// 断言二(续):逐行核对库里的报名与原库逐项一致,条数一条不差。
assertEntriesUnchanged(t, before, snapshotEntries(t, env))
// 断言三:团体行没有被变成个人报名。
assertTeamRowsStayTeam(t, env)
})
}
}
// assertImportExportSheets 核对两种布局的工作表结构与文档属性。
func assertImportExportSheets(t *testing.T, f *excelize.File, format string) {
t.Helper()
// 文档属性不能留第三方库的作者名(excelize 缺省写的是 xuri)与一个假日期。
props, err := f.GetDocProps()
if err == nil {
if props.Creator != "赛事积分榜系统" {
t.Errorf("文档属性里的作者 = %q,期望 赛事积分榜系统", props.Creator)
}
} else {
t.Errorf("读文档属性: %v", err)
}
sheets := f.GetSheetList()
if format == "flat" {
// 汇总表的表名里**不能有 @**:导入侧只要见到一张表名带 @ 就整份按分工作表口径解析。
if len(sheets) != 1 || sheets[0] != "报名名单" {
t.Fatalf("flat 布局的工作表 = %v,期望只有一张「报名名单」", sheets)
}
return
}
want := []string{"100米跑@男子", "100米跑@女子", "4×100米接力@男子"}
if len(sheets) != len(want) {
t.Fatalf("sheets 布局的工作表 = %v,期望 %v(不能多出说明表)", sheets, want)
}
for i, name := range want {
if sheets[i] != name {
t.Errorf("第 %d 张工作表 = %q,期望 %q(表名由 entrySheetName 算,与导入侧同源)", i+1, sheets[i], name)
}
if !strings.Contains(sheets[i], "@") {
t.Errorf("分小项布局的表名必须带 @:%q", sheets[i])
}
}
}
// assertTeamRowsStayTeam 断言团体行重导后仍是团体报名:athlete_id 为空、队员关联一个不少。
// 这是本格式最容易踩坏的一处——姓名那一格一旦被填上,导入侧就会把它当个人报名。
func assertTeamRowsStayTeam(t *testing.T, env *testEnv) {
t.Helper()
cases := []struct {
eventCode, category, squad string
members int
}{
{"E1", "男子", "一队", 1},
{"E1", "男子", "二队", 1},
{"E2", "男子", "", 2},
}
for _, tc := range cases {
var (
entryID int64
athleteID sql.NullInt64
)
if err := env.ev.QueryRow(`
SELECT en.id, en.athlete_id
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN event_categories c ON c.id = en.category_id
WHERE ev.code = ? AND c.name = ? AND en.squad = ?`,
tc.eventCode, tc.category, tc.squad).Scan(&entryID, &athleteID); err != nil {
t.Fatalf("查 %s %s %q 的报名: %v", tc.eventCode, tc.category, tc.squad, err)
}
if athleteID.Valid {
t.Errorf("%s %s %q 被当成了个人报名:athlete_id = %d(团体行的姓名列必须是空的)",
tc.eventCode, tc.category, tc.squad, athleteID.Int64)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes WHERE entry_id = ?`, entryID); n != tc.members {
t.Errorf("%s %s %q 的队员关联 = %d 名,期望 %d 名(队员不能因为重导而掉队)",
tc.eventCode, tc.category, tc.squad, n, tc.members)
}
}
}
// entryState 是一条报名在库里的全部可比字段(往返前后逐项比对)。
//
// 覆盖的正是本格式承载的东西:小项 / 组别 / 单位编码 / 人员 / 性别 / 年龄 / 号码布 / 种子 /
// 批次 / 状态 / 队伍,外加是否个人报名、联队份额与队员关联。
type entryState struct {
eventCode, category, teamCode string
athleteName, gender, age string
bib, seed, heat, status, squad string
memberNames string
personal bool
shares, members string
}
// snapshotEntries 读全库的报名状态,按报名 id 归组(新建出重复报名会让条数变多)。
//
// 份额那一列有意把「没有份额行」折算成主单位独占:库里的约定是两者等价
// (迁移 0024:没有行 = 主单位 100%),而导入侧整组重写份额时会把这一条补上。
// 不折算的话,一次语义上什么都没改的重导会看起来像改过库。
func snapshotEntries(t *testing.T, env *testEnv) map[int64]entryState {
t.Helper()
rows, err := env.ev.Query(`
SELECT en.id, COALESCE(ev.code, ''), COALESCE(c.name, ''), COALESCE(t.code, ''),
COALESCE(a.name, ''), COALESCE(a.gender, ''), COALESCE(CAST(a.age AS TEXT), ''),
COALESCE(en.bib, ''), COALESCE(en.seed, ''), COALESCE(CAST(en.heat AS TEXT), ''),
en.status, COALESCE(en.member_names, ''), COALESCE(en.squad, ''),
en.athlete_id IS NOT NULL,
COALESCE(NULLIF((SELECT group_concat(team_id) FROM (SELECT s.team_id FROM entry_team_shares s
WHERE s.entry_id = en.id ORDER BY s.sort_order, s.team_id)), ''), CAST(en.team_id AS TEXT)),
COALESCE((SELECT group_concat(athlete_id) FROM (SELECT ea.athlete_id FROM entry_athletes ea
WHERE ea.entry_id = en.id ORDER BY ea.athlete_id)), '')
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN teams t ON t.id = en.team_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN athletes a ON a.id = en.athlete_id
ORDER BY en.id`)
if err != nil {
t.Fatalf("读报名快照: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]entryState)
for rows.Next() {
var (
id int64
state entryState
)
if err := rows.Scan(&id, &state.eventCode, &state.category, &state.teamCode,
&state.athleteName, &state.gender, &state.age,
&state.bib, &state.seed, &state.heat, &state.status,
&state.memberNames, &state.squad, &state.personal,
&state.shares, &state.members); err != nil {
t.Fatalf("扫描报名快照: %v", err)
}
out[id] = state
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历报名快照: %v", err)
}
return out
}
// assertEntriesUnchanged 断言重导后库里的报名与原库逐项一致(条数一条不差)。
//
// 唯一允许的两处差异,都是这个格式**有意**带来的、不丢信息的差异:
// - 队员名单只增不减:导出时把 entry_athletes 里已关联的队员补进了那一格
// (否则界面后补的队员在重导后就没了),导入后会多出这些号码布;
// - 份额行会被补成「主单位独占 100%」:库里没有份额行本来就等价于它。
func assertEntriesUnchanged(t *testing.T, before, after map[int64]entryState) {
t.Helper()
if len(after) != len(before) {
t.Fatalf("重新导入后报名条数 = %d,期望 %d(不能新建、不能删除)", len(after), len(before))
}
for id, want := range before {
got, ok := after[id]
if !ok {
t.Errorf("报名 %d 在重新导入后不见了(往返必须逐行命中已有报名)", id)
continue
}
for _, diff := range []string{
diffText("小项", want.eventCode, got.eventCode),
diffText("组别", want.category, got.category),
diffText("单位编码", want.teamCode, got.teamCode),
diffText("姓名", want.athleteName, got.athleteName),
diffText("性别", want.gender, got.gender),
diffText("年龄", want.age, got.age),
diffText("号码布", want.bib, got.bib),
diffText("种子", want.seed, got.seed),
diffText("批次", want.heat, got.heat),
diffText("状态", want.status, got.status),
diffText("队伍", want.squad, got.squad),
diffText("份额", want.shares, got.shares),
diffText("队员关联", want.members, got.members),
} {
if diff != "" {
t.Errorf("报名 %d:%s", id, diff)
}
}
if want.personal != got.personal {
t.Errorf("报名 %d 的报名类型变了:个人 = %v → %v(团体行被当成个人报名 / 反过来都不行)",
id, want.personal, got.personal)
}
// 队员名单只增不减:原来的每一段(文字或号码布)都必须还在。
for _, token := range memberTokensOf(want.memberNames) {
if !containsToken(memberTokensOf(got.memberNames), token) {
t.Errorf("报名 %d 的队员名单丢了 %q:%q → %q", id, token, want.memberNames, got.memberNames)
}
}
}
}
// diffText 拼一条字段差异;两边一样时返回空串。
func diffText(field, before, after string) string {
if before == after {
return ""
}
return fmt.Sprintf("%s 变了:%q → %q", field, before, after)
}
// memberTokensOf 把库里的队员名单拆成一段段:正常是 JSON 数组文本,
// 历史上被手工写成别的样子时整格当一段(与导出侧的读法同口径)。
func memberTokensOf(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var tokens []string
if err := json.Unmarshal([]byte(raw), &tokens); err != nil {
return []string{raw}
}
return tokens
}
func containsToken(tokens []string, want string) bool {
for _, token := range tokens {
if strings.TrimSpace(token) == want {
return true
}
}
return false
}
// format / lang 的口径,以及空名单时仍然给一张带表头的表。
func TestEntriesImportExportFormatAndLanguage(t *testing.T) {
env := newTestEnv(t)
seedImportRoundTrip(t, env)
// guest 即可下载:权限与报表导出(entries.xlsx)同档。
client, _ := env.client()
status, _, body := env.getRaw(t, client, "/api/export/entries-import.xlsx?format=csv")
if status != http.StatusBadRequest {
t.Fatalf("非法 format 应 400,得到 %d %s", status, body)
}
if msg := string(body); !strings.Contains(msg, "flat") || !strings.Contains(msg, "sheets") {
t.Errorf("非法 format 的提示要写清只认 flat / sheets,得到 %s", msg)
}
// lang 只有 en 走英文表头,其余一律中文(与模板下载的两个按钮同一口径)。
status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?lang=en")
if status != http.StatusOK {
t.Fatalf("lang=en 应 200,得到 %d", status)
}
assertHeaderRow(t, openWorkbook(t, body), "报名名单", importExportEnglishHeader)
status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?lang=fr")
if status != http.StatusOK {
t.Fatalf("lang=fr 应 200(其余一律中文),得到 %d", status)
}
assertHeaderRow(t, openWorkbook(t, body), "报名名单", importExportHeader)
// 筛出来是空的也要给一张带表头的表:拿到手的人一眼看得出是「这次没有数据」。
for _, format := range []string{"flat", "sheets"} {
status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?format="+format+"&heat=99")
if status != http.StatusOK {
t.Fatalf("%s 空名单应 200,得到 %d %s", format, status, body)
}
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
assertHeaderRow(t, f, "报名名单", importExportHeader)
if sheets := f.GetSheetList(); len(sheets) != 1 || sheets[0] != "报名名单" {
t.Errorf("%s 空名单的工作表 = %v,期望只有一张「报名名单」", format, sheets)
}
}
}
// assertHeaderRow 读一张表的第 1 行并逐列核对(同时确认没有标题行占位)。
func assertHeaderRow(t *testing.T, f *excelize.File, sheet string, want []string) {
t.Helper()
defer func() { _ = f.Close() }()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
if len(rows) == 0 {
t.Fatalf("工作表 %s 没有表头行", sheet)
}
for i, header := range want {
if got := cellAt(rows, 0, i); got != header {
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header)
}
}
}
@@ -62,6 +62,9 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
// 报名名单:按小项 / 组别 / 单位 / 批次筛选,四者可单传也可全不传(= 全部)。
mux.HandleFunc("GET /api/export/entries.xlsx", s.read(auth.RoleGuest, s.exportEntriesXLSX))
// 报名名单(导入格式):列序与列名与报名导入模板逐列一致,导出的文件可以直接重新导入。
// 筛选参数与上面那份报表完全一样;format=flat(一张表汇总,缺省)| sheets(按小项@组别分表)。
mux.HandleFunc("GET /api/export/entries-import.xlsx", s.read(auth.RoleGuest, s.exportEntriesImportXLSX))
// 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 sheet 工作簿,导入按编码 upsert。
// 导出与其它导出同档(基础数据本身在 /api/teams、/api/sports 等接口就是公开可读的);
// 导入是批量写、影响面比录入大,权限收到 admin。
+104 -16
View File
@@ -23,10 +23,22 @@ import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
import {
downloadBaseDataXLSX,
downloadEntriesImportXLSX,
downloadEntriesXLSX,
importBaseDataXLSX,
type BaseDataImportResult,
type EntryExportFilter,
} from '@/shared/basedata'
import {
ENTRIES_IMPORT_ACTIONS,
ENTRIES_IMPORT_LANG_NOTE,
ENTRIES_IMPORT_LANG_OPTIONS,
ENTRIES_IMPORT_NOTE,
entriesImportRequest,
parseEntryFilter,
type EntriesImportFormat,
type EntriesImportLanguage,
} from './exportEntriesImport'
import type { EventCategory } from '@/shared'
/**
@@ -135,6 +147,10 @@ const entryTeamId = ref<number | null>(null)
const entryHeat = ref('')
const entryCategories = ref<EventCategory[]>([])
const entryExporting = ref(false)
/** 正在导出的那张导入格式表;null = 两个导入格式按钮都没在跑。 */
const entryImportExporting = ref<EntriesImportFormat | null>(null)
/** 两个导入格式导出的表头语言,默认中文;报表导出没有这个参数。 */
const entryImportLang = ref<EntriesImportLanguage>('zh')
/**
* 换小项就把组别重置并重新拉取:组别 id 只在小项内有意义,
@@ -151,25 +167,27 @@ watch(entryEventId, async (id) => {
}
})
/** 三个导出按钮共用同一套筛选与批次校验;批次写错时提示后返回 null(这条不发出去)。 */
function entryFilterOrWarn(): EntryExportFilter | null {
const parsed = parseEntryFilter({
eventId: entryEventId.value,
categoryId: entryCategoryId.value,
teamId: entryTeamId.value,
heat: entryHeat.value,
})
if (!parsed.ok) {
toast.warning(parsed.message)
return null
}
return parsed.filter
}
async function exportEntries(): Promise<void> {
let heat: number | undefined
const rawHeat = entryHeat.value.trim()
if (rawHeat !== '') {
const parsed = Number(rawHeat)
if (!Number.isInteger(parsed) || parsed <= 0) {
toast.warning('批次需要是正整数(留空表示不筛)')
return
}
heat = parsed
}
const filter = entryFilterOrWarn()
if (filter === null) return
entryExporting.value = true
try {
const file = await downloadEntriesXLSX({
eventId: entryEventId.value ?? undefined,
categoryId: entryCategoryId.value ?? undefined,
teamId: entryTeamId.value ?? undefined,
heat,
})
const file = await downloadEntriesXLSX(filter)
adminApi.client.exports.save(file.blob, file.filename)
toast.success(`已导出 ${file.filename}`)
} catch (caught) {
@@ -179,6 +197,30 @@ async function exportEntries(): Promise<void> {
}
}
/**
* 导出「导入格式」的报名名单:与报表同一个筛选,只多带 format / lang。
*
* 口径与报表导出一样是只读下载(访客也能用),成功 / 失败的提示也同一套;
* 只是产出的列与导入模板逐列一致,能直接拿去重新导入。
*/
async function exportEntriesImport(format: EntriesImportFormat): Promise<void> {
if (entryImportExporting.value !== null) return
const filter = entryFilterOrWarn()
if (filter === null) return
entryImportExporting.value = format
try {
const file = await downloadEntriesImportXLSX(
entriesImportRequest(filter, { format, lang: entryImportLang.value }),
)
adminApi.client.exports.save(file.blob, file.filename)
toast.success(`已导出 ${file.filename}`, '列与报名导入模板逐列一致,可直接拿去重新导入')
} catch (caught) {
toast.error('导出失败', describeError(caught))
} finally {
entryImportExporting.value = null
}
}
// ── 基础数据包(单位 / 大项 / 小项 / 组别) ──────────────────────────────────
const baseDataFile = ref<File | null>(null)
const baseDataInput = ref<HTMLInputElement | null>(null)
@@ -699,6 +741,38 @@ async function applyRules(): Promise<void> {
<MxIcon name="export" :size="15" />
{{ entryExporting ? '导出中…' : '导出报名名单' }}
</button>
<!-- 导入格式:与上面的报表是两种口径,用一条分隔线隔开,语言选择只作用于这一块 -->
<div class="entries-import-block">
<p class="hint">{{ ENTRIES_IMPORT_NOTE }}</p>
<div class="lang-row">
<span class="mx-label">表头语言</span>
<button
v-for="option in ENTRIES_IMPORT_LANG_OPTIONS"
:key="option.lang"
class="mx-btn mx-btn-sm"
:class="entryImportLang === option.lang ? 'mx-btn-primary' : 'mx-btn-secondary'"
type="button"
:title="option.title"
@click="entryImportLang = option.lang"
>
{{ option.label }}
</button>
</div>
<p class="hint">{{ ENTRIES_IMPORT_LANG_NOTE }}</p>
<button
v-for="action in ENTRIES_IMPORT_ACTIONS"
:key="action.format"
class="mx-btn mx-btn-secondary full"
type="button"
:title="action.title"
:disabled="entryImportExporting !== null"
@click="exportEntriesImport(action.format)"
>
<MxIcon name="export" :size="15" />
{{ entryImportExporting === action.format ? '导出中…' : action.label }}
</button>
</div>
</div>
</section>
@@ -1245,6 +1319,20 @@ async function applyRules(): Promise<void> {
.entries-card {
margin-top: 16px;
}
/* 导入格式与上面的报表是两种口径:用一条分隔线 + 一段说明把两处按钮分开。 */
.entries-import-block {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--mx-border);
}
.lang-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */
.basedata-grid {
display: grid;
+132
View File
@@ -0,0 +1,132 @@
/**
* 导出页「报名名单」卡片上三个导出按钮共用的纯逻辑与文案。
*
* 抽出来的原因:`.vue` 跑不进 `node --test`,而这张卡片最容易出错的两处恰恰是纯逻辑——
* 「四个筛选只在该维度选了 / 填了才拼进 query」(批次 0 / 空串 / 没填都不算筛)
* 与「表头语言只作用于两个导入格式的导出,报表那条调用不许带 lang」。
*
* 三种导出同一套筛选、同一句批次提示,只是口径不同:
* - 报表(GET /export/entries.xlsx):给人看,含小项名称、单位名称、备注;
* - 导入格式(GET /export/entries-import.xlsx):13 列与报名导入模板逐列一致,可重新导入,
* 分 flat(所有行汇总一张表)与 sheets(按「小项名称@组别名称」分工作表)两种。
*/
import type {
EntriesImportFormat,
EntriesImportLanguage,
EntriesImportRequest,
EntryExportFilter,
} from '@/shared/basedata'
export type { EntriesImportFormat, EntriesImportLanguage, EntriesImportRequest, EntryExportFilter }
/** 卡片上的筛选状态:下拉未选是 null,批次是输入框原文(空串 = 不筛)。 */
export interface EntryFilterState {
eventId: number | null
categoryId: number | null
teamId: number | null
heat: string
}
/** 批次写错时的提示(三个按钮共用;现有报表导出用的是同一句话)。 */
export const ENTRY_HEAT_MESSAGE = '批次需要是正整数(留空表示不筛)'
/** 解析结果:要么给出可用的筛选条件,要么给出要给用户看的那句话。 */
export type EntryFilterParse =
| { ok: true; filter: EntryExportFilter }
| { ok: false; message: string }
/**
* 页面筛选 → 请求参数。
*
* 只有真的选了 / 填了才带这个维度:下拉没选(null)与批次留空(空串)都让这个键整个不出现
* (= 不筛);批次必须是非零正整数,写错就整条不发(由调用方提示),免得把「批次」筛成一张空表。
*/
export function parseEntryFilter(state: EntryFilterState): EntryFilterParse {
const rawHeat = state.heat.trim()
let heat: number | undefined
if (rawHeat !== '') {
const parsed = Number(rawHeat)
if (!Number.isInteger(parsed) || parsed <= 0) return { ok: false, message: ENTRY_HEAT_MESSAGE }
heat = parsed
}
// 没值的维度整个键都不出现(而不是留一个 undefined):拼 query 时不必再判断一次。
const filter: EntryExportFilter = {}
if (state.eventId !== null) filter.eventId = state.eventId
if (state.categoryId !== null) filter.categoryId = state.categoryId
if (state.teamId !== null) filter.teamId = state.teamId
if (heat !== undefined) filter.heat = heat
return { ok: true, filter }
}
/**
* 在报表的筛选条件上补 format / lang,得到导入格式导出的请求参数。
*
* 两个导出走的是同一份 filter(同一个筛选、同一个批次口径),报表那条永远不带 lang:
* 表头语言是导入格式专有的参数,服务端的 entries.xlsx 根本不认它。
*/
export function entriesImportRequest(
filter: EntryExportFilter,
options: { format?: EntriesImportFormat; lang?: EntriesImportLanguage } = {},
): EntriesImportRequest {
// 原样带上 filter 里出现过的维度,再补 format / lang —— 两个导出共用同一份筛选。
return {
...filter,
format: options.format ?? 'flat',
lang: options.lang ?? 'zh',
}
}
export interface EntriesImportAction {
format: EntriesImportFormat
/** 按钮上的字。 */
label: string
/** 悬停说明:既要说清「能拿去重新导入」,也要和上面的报表按钮分得开。 */
title: string
}
/** 两个导入格式导出的按钮。措辞与基础数据页的模板下载(中文表头 / 英文表头)同一套口径。 */
export const ENTRIES_IMPORT_ACTIONS: readonly EntriesImportAction[] = [
{
format: 'flat',
label: '导出(汇总·导入格式)',
title:
'汇总·导入格式:所有行放在一张工作表里,列与报名导入模板逐列一致(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍 共 13 列),文件名「报名名单(导入格式).xlsx」,可以直接拿去重新导入。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。',
},
{
format: 'sheets',
label: '导出(分小项·导入格式)',
title:
'分小项·导入格式:按「小项名称@组别名称」分成多张工作表,每张表的列与报名导入模板逐列一致(共 13 列),文件名「报名名单(导入格式·分小项).xlsx」,整份可以直接拿去重新导入;小项与组别多的时候,一个小项组别一张表核对起来不容易串行。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。',
},
]
export interface EntriesImportLangOption {
lang: EntriesImportLanguage
label: string
title: string
}
/** 表头语言选项,默认中文;与模板下载的两个按钮同一套措辞。 */
export const ENTRIES_IMPORT_LANG_OPTIONS: readonly EntriesImportLangOption[] = [
{
lang: 'zh',
label: '中文表头',
title:
'两个导入格式导出的表头用中文列名(项目编码 / 组别 / 单位编码…);只影响下面两个导入格式的导出,上面的「导出报名名单」是报表,没有这个参数。',
},
{
lang: 'en',
label: '英文表头',
title:
'两个导入格式导出的表头用英文规范列名(event_code / category / team_code…);只影响下面两个导入格式的导出,上面的「导出报名名单」是报表,没有这个参数。',
},
]
/** 卡片正文里对「报表 / 导入格式」两种口径的说明。 */
export const ENTRIES_IMPORT_NOTE =
'下面两个按钮导出的是「导入格式」:13 列与报名导入模板逐列一致(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍),导出的文件可以直接拿去重新导入。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。'
/** 「表头语言」那一行的说明:说清它管谁、不管谁。 */
export const ENTRIES_IMPORT_LANG_NOTE =
'表头语言只影响下面两个「导入格式」的导出,默认中文;上面的「导出报名名单」是给人看的报表,没有这个参数,改这里不会动它。'
@@ -0,0 +1,243 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import {
downloadEntriesImportXLSX,
entriesExportPath,
entriesImportPath,
entriesImportXlsxFilename,
ENTRIES_IMPORT_SHEETS_XLSX_FILENAME,
ENTRIES_IMPORT_XLSX_FILENAME,
type DownloadFile,
} from '@/shared/basedata'
import {
ENTRIES_IMPORT_ACTIONS,
ENTRIES_IMPORT_LANG_NOTE,
ENTRIES_IMPORT_LANG_OPTIONS,
ENTRIES_IMPORT_NOTE,
ENTRY_HEAT_MESSAGE,
entriesImportRequest,
parseEntryFilter,
type EntryFilterState,
} from '../pages/exportEntriesImport'
/**
* 「导入格式」的报名名单导出(GET /api/export/entries-import.xlsx)在前端这一侧的四件事:
* 请求路径与筛选参数、两种格式各自的文件名、按钮 / 说明文案、以及「表头语言只作用于
* 两个导入格式、报表那条调用不带 lang」这条口径。
*
* 现场会踩的坑:筛了却导出全量(参数没拼上)、把 0 / 空串当批次发出去(筛成一张空表)、
* 点错按钮拿到一份不能导回的报表(文件名与文案分不清)。
* 服务端那一半(工作簿怎么生成)由另一个代理实现,这里只钉前端发出去的请求。
*/
interface Captured {
url?: string
method?: string
}
function xlsxResponse(): Response {
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
status: 200,
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
})
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => xlsxResponse()
let lastRequest: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET' }
return reply()
}) as typeof fetch
function respondWith(next: () => Response): void {
lastRequest = {}
reply = next
}
/** 页面上的筛选状态:什么都没选 / 没填。 */
function emptyState(): EntryFilterState {
return { eventId: null, categoryId: null, teamId: null, heat: '' }
}
// ── 参数拼装:缺省值、筛选、批次 ─────────────────────────────────────────────
test('导入格式缺省 flat + zh:两个参数总会出现在 query 里', () => {
assert.equal(entriesImportPath(), '/export/entries-import.xlsx?format=flat&lang=zh')
assert.equal(
entriesImportPath({ format: 'sheets', lang: 'en' }),
'/export/entries-import.xlsx?format=sheets&lang=en',
)
const request = entriesImportRequest({}, {})
assert.equal(request.format, 'flat')
assert.equal(request.lang, 'zh')
})
test('四个筛选只在有值时才拼进 query,顺序与报表导出一致', () => {
const parsed = parseEntryFilter({ eventId: 3, categoryId: 9, teamId: 1, heat: '2' })
assert.equal(parsed.ok, true)
assert.ok(parsed.ok)
assert.deepEqual(parsed.filter, { eventId: 3, categoryId: 9, teamId: 1, heat: 2 })
assert.equal(
entriesImportPath(entriesImportRequest(parsed.filter, { format: 'sheets', lang: 'zh' })),
'/export/entries-import.xlsx?format=sheets&lang=zh&eventId=3&categoryId=9&teamId=1&heat=2',
)
// 一个维度都没选:query 里只剩 format / lang,不能出现 eventId= 这类空参数。
const empty = parseEntryFilter(emptyState())
assert.ok(empty.ok)
assert.deepEqual(Object.keys(empty.filter), [])
assert.equal(
entriesImportPath(entriesImportRequest(empty.filter)),
'/export/entries-import.xlsx?format=flat&lang=zh',
)
})
test('批次留空 / 只有空格 = 不筛,不进 query', () => {
for (const raw of ['', ' ']) {
const parsed = parseEntryFilter({ ...emptyState(), eventId: 5, heat: raw })
assert.ok(parsed.ok)
assert.deepEqual(parsed.filter, { eventId: 5 })
}
})
test('批次 0 / 负数 / 小数 / 非数字一律拦下并给同一句提示,不发请求', () => {
for (const raw of ['0', '-1', '1.5', 'abc', '2 批次']) {
const parsed = parseEntryFilter({ ...emptyState(), heat: raw })
assert.equal(parsed.ok, false, `批次「${raw}」应当被拦下`)
assert.ok(!parsed.ok)
assert.equal(parsed.message, ENTRY_HEAT_MESSAGE)
assert.match(parsed.message, /正整数/)
}
// 正整数照旧放行,并且去掉两侧空格。
const parsed = parseEntryFilter({ ...emptyState(), heat: ' 12 ' })
assert.ok(parsed.ok)
assert.deepEqual(parsed.filter, { heat: 12 })
})
test('报表导出那条调用不带 lang:筛选条件里根本没有这两个键', () => {
const parsed = parseEntryFilter({ eventId: 3, categoryId: null, teamId: null, heat: '2' })
assert.ok(parsed.ok)
const reportPath = entriesExportPath(parsed.filter)
assert.equal(reportPath, '/export/entries.xlsx?eventId=3&heat=2')
assert.doesNotMatch(reportPath, /lang|format/)
// 同一份筛选交给导入格式导出,才补上 format / lang。
const importRequest = entriesImportRequest(parsed.filter, { format: 'flat', lang: 'en' })
assert.deepEqual(importRequest, { eventId: 3, heat: 2, format: 'flat', lang: 'en' })
assert.equal(
entriesImportPath(importRequest),
'/export/entries-import.xlsx?format=flat&lang=en&eventId=3&heat=2',
)
})
// ── 文件名与真正发出去的请求 ─────────────────────────────────────────────────
test('两种格式的文件名分得清,且与按钮文案一一对应', () => {
assert.equal(entriesImportXlsxFilename('flat'), '报名名单(导入格式).xlsx')
assert.equal(entriesImportXlsxFilename('sheets'), '报名名单(导入格式·分小项).xlsx')
assert.equal(entriesImportXlsxFilename(), '报名名单(导入格式).xlsx')
assert.equal(ENTRIES_IMPORT_XLSX_FILENAME, '报名名单(导入格式).xlsx')
assert.equal(ENTRIES_IMPORT_SHEETS_XLSX_FILENAME, '报名名单(导入格式·分小项).xlsx')
// 与报表的文件名不能撞在一起,否则现场下载完分不清哪份能导回。
assert.notEqual(ENTRIES_IMPORT_XLSX_FILENAME, ENTRIES_IMPORT_SHEETS_XLSX_FILENAME)
})
test('汇总格式:GET /api/export/entries-import.xlsx?format=flat&lang=zh,文件名带「导入格式」', async () => {
respondWith(() => xlsxResponse())
const filter = parseEntryFilter({ ...emptyState(), eventId: 3 })
assert.ok(filter.ok)
const file: DownloadFile = await downloadEntriesImportXLSX(entriesImportRequest(filter.filter))
assert.equal(lastRequest.url, '/api/export/entries-import.xlsx?format=flat&lang=zh&eventId=3')
assert.equal(lastRequest.method, 'GET')
assert.equal(file.filename, '报名名单(导入格式).xlsx')
assert.equal(file.blob.size, 4)
})
test('分小项格式带 format=sheets,文件名里写清「分小项」', async () => {
respondWith(() => xlsxResponse())
const file = await downloadEntriesImportXLSX({ format: 'sheets', lang: 'en', heat: 2 })
assert.equal(lastRequest.url, '/api/export/entries-import.xlsx?format=sheets&lang=en&heat=2')
assert.equal(file.filename, '报名名单(导入格式·分小项).xlsx')
})
test('服务端 400(参数不对/格式不合法)原样抛出,不产出空文件', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: 'format 只能是 flat 或 sheets' } }, 400),
)
await assert.rejects(
() => downloadEntriesImportXLSX({ format: 'flat' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 400)
assert.equal(error.message, 'format 只能是 flat 或 sheets')
return true
},
)
})
// ── 界面文案:能不能一眼看出「这两个是能导回去的」 ───────────────────────────
test('两个导入格式按钮的文案点明「导入格式 / 可重新导入」,并说清与报表的区别', () => {
assert.deepEqual(
ENTRIES_IMPORT_ACTIONS.map((action) => action.format),
['flat', 'sheets'],
)
const [flat, sheets] = ENTRIES_IMPORT_ACTIONS
assert.equal(flat.label, '导出(汇总·导入格式)')
assert.equal(sheets.label, '导出(分小项·导入格式)')
for (const action of ENTRIES_IMPORT_ACTIONS) {
assert.match(action.title, /导入模板/)
assert.match(action.title, /逐列一致/)
assert.match(action.title, /重新导入/)
assert.match(action.title, /13 列/)
// 报表按钮在旁边:区别必须写出来(报表含小项名称 / 单位名称 / 备注,不能直接导回)。
assert.match(action.title, /报表/)
assert.match(action.title, /备注/)
}
assert.match(flat.title, /一张工作表/)
assert.match(sheets.title, /多张工作表/)
assert.match(sheets.title, /小项名称@组别名称/)
assert.match(ENTRIES_IMPORT_NOTE, /报表/)
assert.match(ENTRIES_IMPORT_NOTE, /不能直接导回/)
assert.match(ENTRIES_IMPORT_NOTE, /13 列/)
})
test('表头语言两个选项默认中文,说明里写清只管两个导入格式、不管报表', () => {
assert.deepEqual(
ENTRIES_IMPORT_LANG_OPTIONS.map((option) => option.lang),
['zh', 'en'],
)
assert.equal(ENTRIES_IMPORT_LANG_OPTIONS[0].label, '中文表头')
assert.equal(ENTRIES_IMPORT_LANG_OPTIONS[1].label, '英文表头')
for (const option of ENTRIES_IMPORT_LANG_OPTIONS) {
assert.match(option.title, /只影响/)
assert.match(option.title, /导出报名名单/)
}
assert.match(ENTRIES_IMPORT_LANG_OPTIONS[1].title, /event_code/)
assert.match(ENTRIES_IMPORT_LANG_NOTE, /只影响/)
assert.match(ENTRIES_IMPORT_LANG_NOTE, /默认中文/)
assert.match(ENTRIES_IMPORT_LANG_NOTE, /没有这个参数/)
})
+71 -6
View File
@@ -34,6 +34,10 @@ const API_ERROR_CODES: readonly string[] = [
export const ENTRIES_XLSX_FILENAME = '报名名单.xlsx'
export const BASEDATA_XLSX_FILENAME = '基础数据包.xlsx'
/** 导入格式(汇总)导出:所有行在一张工作表里。 */
export const ENTRIES_IMPORT_XLSX_FILENAME = '报名名单(导入格式).xlsx'
/** 导入格式(分小项)导出:按「小项名称@组别名称」分成多张工作表。 */
export const ENTRIES_IMPORT_SHEETS_XLSX_FILENAME = '报名名单(导入格式·分小项).xlsx'
export interface DownloadFile {
blob: Blob
@@ -48,6 +52,22 @@ export interface EntryExportFilter {
heat?: number
}
/** 导出组织方式:flat = 所有行汇总在一张工作表;sheets = 按「小项名称@组别名称」分工作表。 */
export type EntriesImportFormat = 'flat' | 'sheets'
/** 表头语言:只有导入格式导出认它(报表导出没有这个参数)。 */
export type EntriesImportLanguage = 'zh' | 'en'
/**
* 导入格式导出的请求参数:四个筛选维度与报表导出完全相同,另加 format / lang。
*
* 缺省 format=flat、lang=zh(与服务端同一口径):调用方拿不准时不必显式写全。
*/
export interface EntriesImportRequest extends EntryExportFilter {
format?: EntriesImportFormat
lang?: EntriesImportLanguage
}
export interface BaseDataFieldDiff {
field: string
from: string
@@ -79,13 +99,33 @@ export interface BaseDataImportResult {
/** 报名名单导出的请求路径(筛选参数为空就不带 query)。 */
export function entriesExportPath(filter: EntryExportFilter = {}): string {
const params = new URLSearchParams()
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
const value = filter[key]
if (value !== undefined) params.append(key, String(value))
const search = appendEntryFilter(new URLSearchParams(), filter).toString()
return `/export/entries.xlsx${search === '' ? '' : `?${search}`}`
}
const query = params.toString()
return `/export/entries.xlsx${query === '' ? '' : `?${query}`}`
/**
* 导入格式导出的请求路径:`GET /api/export/entries-import.xlsx?format=…&lang=…&<四个筛选>`。
*
* format / lang 从缺省值补齐后再拼,所以这两个参数总会出现在 query 里;
* 四个筛选维度与报表导出共用 appendEntryFilter,两条路径的筛选口径不会走偏。
* 前端不产生非法 format(界面上只有两个按钮),服务端收到别的值会回 400。
*/
export function entriesImportPath(request: EntriesImportRequest = {}): string {
const params = appendEntryFilter(
new URLSearchParams({ format: request.format ?? 'flat', lang: request.lang ?? 'zh' }),
request,
)
return `/export/entries-import.xlsx?${params.toString()}`
}
/**
* 导入格式导出的文件名。
*
* 分小项那一种在线名里就写清「分小项」:文件名是现场分清「汇总表 / 分小项表」、
* 以及「导入格式 / 给人看的报表」的第一眼线索。
*/
export function entriesImportXlsxFilename(format: EntriesImportFormat = 'flat'): string {
return format === 'sheets' ? ENTRIES_IMPORT_SHEETS_XLSX_FILENAME : ENTRIES_IMPORT_XLSX_FILENAME
}
/** 下载报名名单(xlsx)。 */
@@ -93,6 +133,16 @@ export function downloadEntriesXLSX(filter: EntryExportFilter = {}): Promise<Dow
return download(entriesExportPath(filter), ENTRIES_XLSX_FILENAME)
}
/**
* 下载「导入格式」的报名名单(xlsx):列与报名导入模板逐列一致,可以直接重新导入。
*
* 与上面的 downloadEntriesXLSX 是两种口径:那个是给人看的报表(含小项名称、单位名称、备注),
* 这个只带导入模板认的那 13 列。两个接口在服务端各算各的,前端只是换个地址。
*/
export function downloadEntriesImportXLSX(request: EntriesImportRequest = {}): Promise<DownloadFile> {
return download(entriesImportPath(request), entriesImportXlsxFilename(request.format))
}
/** 下载基础数据包(多 sheet 工作簿)。 */
export function downloadBaseDataXLSX(): Promise<DownloadFile> {
return download('/export/basedata.xlsx', BASEDATA_XLSX_FILENAME)
@@ -142,6 +192,21 @@ export async function importBaseDataXLSX(
// 底层
// ---------------------------------------------------------------------------
/**
* 把四个筛选维度里「有值」的那些拼进 query:未选(undefined)不拼。
*
* 两条导出路径共用这一份,避免出现「报表按 A 口径拼、导入格式按 B 口径拼」这种
* 只在某个维度上悄悄不同的偏差。heat = 0 照旧拼进去(口径是「不传 = 不筛」,
* 页面在拼参数前就把空 / 0 / 非法批次换成 undefined,见 pages/exportEntriesImport.ts)。
*/
function appendEntryFilter(params: URLSearchParams, filter: EntryExportFilter): URLSearchParams {
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
const value = filter[key]
if (value !== undefined) params.append(key, String(value))
}
return params
}
async function download(path: string, filename: string): Promise<DownloadFile> {
const response = await request(path, { method: 'GET' })
return { blob: await response.blob(), filename }