feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5

## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
This commit is contained in:
AskaEth committed 2026-09-14 11:45:32 +08:00
1 parent 9663d5bfb0
commit 7a3e4ad365
67 files changed
+4658 -228

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.4 dev_0.5.5
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.4 0.5.5
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scoreboard-console", "name": "scoreboard-console",
"productName": "Scoreboard Console", "productName": "Scoreboard Console",
"version": "0.5.4", "version": "0.5.5",
"private": true, "private": true,
"author": "AskaEth", "author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+41 -7
View File
@@ -102,7 +102,7 @@
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。 表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。 **「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。
| 列 | 操作 | | 列 | 操作 |
| --- | --- | | --- | --- |
@@ -300,7 +300,8 @@
3. 底部「**添加成员单位…**」下拉里选一个单位再点「添加」——下拉里列的是还没在成员里的单位(**已停用**的单位也可以加,会标注「已停用」;被「彻底删除」的单位不出现); 3. 底部「**添加成员单位…**」下拉里选一个单位再点「添加」——下拉里列的是还没在成员里的单位(**已停用**的单位也可以加,会标注「已停用」;被「彻底删除」的单位不出现);
4. 弹窗下方实时显示 `当前比例:第一分公司 50% · 第二分公司 50%`,两个以上成员时旁边还会给出联队名预览(`第一分公司+第二分公司联队`); 4. 弹窗下方实时显示 `当前比例:第一分公司 50% · 第二分公司 50%`,两个以上成员时旁边还会给出联队名预览(`第一分公司+第二分公司联队`);
5. **主单位不能去掉**(它的「移除」是灰的):报名挂在它名下,要换主单位只能重新建报名; 5. **主单位不能去掉**(它的「移除」是灰的):报名挂在它名下,要换主单位只能重新建报名;
6. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。 6. **纯团体报名还会多一个「队伍」输入框**(同一个单位派两个队时写队名,见 4.12):**只有纯团体报名显示这个框,个人报名连框都不出现**;留空 = 不区分队伍。它与份额是**两个接口**,改队名**不会重算榜单**(队名只是标签);
7. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。按钮上的字会按你改了什么变:只改队名时是 **「保存队伍」**、只改份额时是 **「保存成员单位」**、两样都改了是 **「保存成员单位与队伍」**。
**要注意的** **要注意的**
@@ -313,6 +314,34 @@
- ⚠️ **「按队内平均」这类聚合通道的项目,联队不拆份额**:这类通道的名次不对应任何一条报名,分仍记在主单位头上(本轮如此)。要保证联队一起算,请用不聚合的口径(或按 4.9 在主单位上录名次)。 - ⚠️ **「按队内平均」这类聚合通道的项目,联队不拆份额**:这类通道的名次不对应任何一条报名,分仍记在主单位头上(本轮如此)。要保证联队一起算,请用不聚合的口径(或按 4.9 在主单位上录名次)。
- 一件事只做一次:**同一个小项同一个组别里,两个单位联合参赛就写一条 `A+B` 的报名**。`A+B` 与 `B+A` 是**两条不同的报名**(主单位不同),不要两种写法都填。 - 一件事只做一次:**同一个小项同一个组别里,两个单位联合参赛就写一条 `A+B` 的报名**。`A+B` 与 `B+A` 是**两条不同的报名**(主单位不同),不要两种写法都填。
### 4.12 团体项目多队(同一个单位派两个队)
**羽毛球双打那种:一个单位在同一个组别报了两支队**(例如一队、二队),两队可能各占一个名次。系统支持这么报——**不用新建单位、也不用重复报两次**,两队各算各的成绩、名次与积分,最后都汇到同一个单位的总分上(一个单位两个队都进前三时,这个单位拿 **2 枚牌**)。
**只对团体项目**:个人项目(按人报名的那些)不受影响,也没有「队」这回事。
**怎么录(三条路,任选)**
| 方式 | 怎么做 |
| --- | --- |
| **导入报名表** | 用**最后一列「队伍」**写队名:一队那行写 `一队`、二队那行写 `二队`(同一个单位、同一个组别写两行)。**只有一支队的单位这一列留空**;不填这一列、表头里没有它,都合法。列名与报错见[报名名单导入规范 · 第三节的「队伍」](./报名名单导入规范.md)——⚠️ **导入表由 12 列变成 13 列,但老 12 列表照常导入**(新列加在最后,一列都不会错位) |
| **补报名(勾选)** | 在「成绩录入」页工具栏点 **「补报名」**(或「基础数据 → 报名导入」的**团体报名**页签):勾上某个单位后,**那一行右侧会出现「队伍(留空=不区分)」输入框**,填队名即可;**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)` |
| **改队名** | 队名填错了**不用删掉重建**:到「成绩录入」页**点那一行的「单位」格**,打开「成员单位与份额」弹窗,里面有 **「队伍」输入框**(**只有纯团体报名才显示**,个人报名连框都不出现)。改完点保存——按钮会按你改了什么都写明(`保存队伍` / `保存成员单位` / `保存成员单位与队伍`),**没改动时按钮是灰的** |
**显示成什么样**
- **`单位名 + 队名`,不加括号、不加分隔符**:`第一分公司一队`、`第一分公司二队`;**队名留空时仍然只写 `第一分公司`**(与以前逐字一样);
- 出现的地方:**成绩录入页 / 成绩审核页的「单位」列、批量操作的确认框、报错里的行名、大屏的分项成绩 / 实时赛况 / 底栏「最新完成项目」,以及导出件的「单位」列**(分项成绩表、全部成绩单页、报名名单、单位奖牌明细总表、单位积分总明细总表);
- **单位总分榜、单位奖牌榜、个人积分榜上不写队名**(它们的单位名来自单位汇总,分与奖牌本来就归到单位)。
**要注意的**
- **同一个单位、同一个组别、同一个队名只能有一条**:要派两支队就得写**两个不同的队名**。两个队名写一样(或都留空)时,第二行会被**逐行拦下**并说清是哪个单位、哪个队(见导入规范的第六节);
- **队伍只对团体行生效**:个人行(有姓名的那几行)**填了队名也不会写进库里**,导入结果里会给一条提示说明这一格被忽略了;
- ⚠️ **改完队名之后,记得把报名表里的队名也改掉**:导入是按「小项 + 组别 + 单位 + 队名」认人的,以后再导一份**还写着旧队名**的表,会**新增出旧队名那一条报名**(不会静默合并);
- **改队名不影响成绩与榜单**:队名只是给现场看的一个标签,不参与名次、积分、奖牌的计算,所以改它**不会重算榜单**,也**不会给别处的大屏 / 录入页推刷新**——别的窗口要等下一次刷新才看到新队名(你自己那一次保存的页面上会立刻变);
- 队伍**不参与「团体名次」按单位录入**的那一类项目(篮球那种每个单位只有一支队):名次仍然是按单位录的,与队名无关。
--- ---
## 5. 成绩审核 ## 5. 成绩审核
@@ -723,12 +752,13 @@
1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项; 1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项;
2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号与号码布。 2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号与号码布。
**团体报名**(一个单位一条报名) **团体报名**(默认一个单位一条报名;同一个单位要派两支队时见 4.12)
1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员; 1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员;
2. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补; 2. **同一个单位要派两支队时**(羽毛球双打那种):勾上这个单位后,那一行右侧会出现 **「队伍(留空=不区分)」** 输入框,填上队名(例如 `一队`);**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)`——**要再报第二支队,就再填一个新的队名提交一次**(同一个队名重复提交只是更新队员名单,不会新增)。详见 4.12;
3. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单; 3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补;
4. 点「加入报名」,一次为所有勾选的单位各建一条报名。 4. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单;
5. 点「加入报名」,一次为所有勾选的单位各建一条报名(**同一个单位 + 同一个队名**只留一条)。
- 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。 - 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。
**表格导入** **表格导入**
@@ -740,8 +770,9 @@
- 「**分工作表 · 中文表头 / 分工作表 · 英文表头**」=**分工作表模板**(`.xlsx`):按当前赛事已配好的**小项 × 组别各建一张工作表**,表名是「**小项名称@组别名称**」(例如 `100 米跑@男子组 30岁以下`),范围由表名承担,数据区只填人和单位。小项组别多时用它更不容易抄串; - 「**分工作表 · 中文表头 / 分工作表 · 英文表头**」=**分工作表模板**(`.xlsx`):按当前赛事已配好的**小项 × 组别各建一张工作表**,表名是「**小项名称@组别名称**」(例如 `100 米跑@男子组 30岁以下`),范围由表名承担,数据区只填人和单位。小项组别多时用它更不容易抄串;
- 两种模板的表头与列顺序完全一样,只有「范围从哪来」不同(详细规范见 [报名名单导入规范 · 第八节](./报名名单导入规范.md)); - 两种模板的表头与列顺序完全一样,只有「范围从哪来」不同(详细规范见 [报名名单导入规范 · 第八节](./报名名单导入规范.md));
- **两个单位联合参赛(联队)**:把「单位编码」那一格写成 `F1+F2`(加号左边是主单位,默认均分,见 4.11)。**分工作表模板**里,「单位编码」表头那一格挂了一条**单元格批注**(鼠标停上去就弹出来)、这一列还有**输入提示**(点中该列的格子就弹出来),中英各一份,写的正是这套写法——表头文字、列顺序与工作表名一个字都没动,**老表格照常导入**; - **两个单位联合参赛(联队)**:把「单位编码」那一格写成 `F1+F2`(加号左边是主单位,默认均分,见 4.11)。**分工作表模板**里,「单位编码」表头那一格挂了一条**单元格批注**(鼠标停上去就弹出来)、这一列还有**输入提示**(点中该列的格子就弹出来),中英各一份,写的正是这套写法——表头文字、列顺序与工作表名一个字都没动,**老表格照常导入**;
2. 表头共 12 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)为可选列,缺失不影响导入,其余列必须包含; 2. 表头共 13 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`、`squad`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)、`squad`(队伍)为可选列,缺失不影响导入,其余列必须包含;
- `team_code`(单位编码)有两种填法:**一个单位**填它的编码(`F1`);**两个单位联合参赛**填 `F1+F2`(加号左边是主单位,默认均分;非均分要到录入页改,见 4.11); - `team_code`(单位编码)有两种填法:**一个单位**填它的编码(`F1`);**两个单位联合参赛**填 `F1+F2`(加号左边是主单位,默认均分;非均分要到录入页改,见 4.11);
- `squad`(队伍)是**最后一列**、**新加的(2026 年 9 月)**:同一个单位在同一个组别派两支队时(羽毛球双打的一队 / 二队)用它区分,写队名即可(中文表头里叫「队伍」,也认「队名」「队伍名」);**只有一支队的单位整列留空**、整列不写、表头里没有它,三种都合法。**老 12 列表照常导入**(新列加在最后,一列都不会错位),详见 4.12 与[报名名单导入规范 · 第三节「队伍」](./报名名单导入规范.md);
- 用**分工作表模板**时,`event_code`、`category` 两列也可以整列不要或整列留空(范围由工作表名决定);一旦填了就必须与工作表名一致,不一致的那一行会报错并跳过; - 用**分工作表模板**时,`event_code`、`category` 两列也可以整列不要或整列留空(范围由工作表名决定);一旦填了就必须与工作表名一致,不一致的那一行会报错并跳过;
- 工作表名必须是「小项名称@组别名称」;工作簿里只要有**一张**表名带 `@`,整份文件就按分工作表模板处理,别的表名不合规会**整份拒绝并点名是哪张表**(不让数据无声消失); - 工作表名必须是「小项名称@组别名称」;工作簿里只要有**一张**表名带 `@`,整份文件就按分工作表模板处理,别的表名不合规会**整份拒绝并点名是哪张表**(不让数据无声消失);
- 小项名与组别名里**不允许出现 `@`**(它是表名里的分隔符):新建 / 编辑小项与组别时界面会当场拦下并说明原因;**已经带 `@` 的历史名字不影响报名与成绩**,但下载分工作表模板时会被点名要求先改名; - 小项名与组别名里**不允许出现 `@`**(它是表名里的分隔符):新建 / 编辑小项与组别时界面会当场拦下并说明原因;**已经带 `@` 的历史名字不影响报名与成绩**,但下载分工作表模板时会被点名要求先改名;
@@ -811,6 +842,7 @@
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` | | 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导); - 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导);
- **队名(同一个单位派两个队时写的那个,见 4.12)写进「单位」列**:分项成绩表 / 全部 / 全部(单页)/ 报名名单 / 单位奖牌明细总表 / 单位积分总明细总表里,写的是 **`第一分公司二队`** 这种「单位名 + 队名」(队名留空时仍然只写 `第一分公司`)。⚠️ **导出件里没有单独的「队名」列**——所以**不要拿导出的报名名单直接改完再导回去**(那会撞「同一个单位同一个队」的重复报名、报错拦下);要改队名或改名单,用**导入模板**那条路,或到录入页点「单位」格改队名;
- **联队(一条报名挂多个单位)在这几份导出件里的写法**(见 4.11): - **联队(一条报名挂多个单位)在这几份导出件里的写法**(见 4.11):
- **「分项成绩表」「全部」「全部(单页)」的「单位」列**:写成联队全名 `第一分公司+第二分公司联队`(成员单位名写全,与导入表的 `F1+F2` 同构);**「备注」列**写清这条分给了谁、各多少,例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`——「积分」列写的是**联队拿到的总分**(上例 10 分),每家的那一份看备注; - **「分项成绩表」「全部」「全部(单页)」的「单位」列**:写成联队全名 `第一分公司+第二分公司联队`(成员单位名写全,与导入表的 `F1+F2` 同构);**「备注」列**写清这条分给了谁、各多少,例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`——「积分」列写的是**联队拿到的总分**(上例 10 分),每家的那一份看备注;
- **「报名名单」**:「单位编码」列回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例); - **「报名名单」**:「单位编码」列回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例);
@@ -1053,6 +1085,8 @@
> **联队(两个单位合并参赛)在大屏上怎么写**(见 4.11):「分项成绩」与「实时赛况」的**单位列写联队全名**——`第一分公司+第二分公司联队`,并且**把两个单位的 logo 都放上**(单位列宽会为该行实际要画的 logo 数留位置,第二个 logo 不会把名字挤成省略号);底栏「最新完成项目」那一行同样写联队全名。**单位奖牌榜、总积分榜本来就是逐单位一行,服务端已按份额拆好**(例:A、B 各 1 枚金、各 7.5 分),屏上不需要另做处理。**没有联队的赛事,大屏画面与升级前逐像素一致**。 > **联队(两个单位合并参赛)在大屏上怎么写**(见 4.11):「分项成绩」与「实时赛况」的**单位列写联队全名**——`第一分公司+第二分公司联队`,并且**把两个单位的 logo 都放上**(单位列宽会为该行实际要画的 logo 数留位置,第二个 logo 不会把名字挤成省略号);底栏「最新完成项目」那一行同样写联队全名。**单位奖牌榜、总积分榜本来就是逐单位一行,服务端已按份额拆好**(例:A、B 各 1 枚金、各 7.5 分),屏上不需要另做处理。**没有联队的赛事,大屏画面与升级前逐像素一致**。
> **队名(同一个单位派两个队时写的那个,见 4.12)同样写在大屏的单位名后面**:`第一分公司二队`——分项成绩、实时赛况、底栏「最新完成项目」三处都一样(同一个单位的两个队都进了名次时,两行只差队名,不写就分不出哪一行是谁);**单位奖牌榜、总积分榜上不写队名**。队名留空时画面与以前逐像素一致。
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 > 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。
+69 -2
View File
@@ -6,7 +6,74 @@
--- ---
## dev_0.5.4(当前版本) ## dev_0.5.5(当前版本)
### 一句话
**有一条数据迁移(`0025`),老数据一个字节都不变**:团体项目现在支持**一个单位在一个组别派多支队**(羽毛球双打那种「一队 / 二队」)——以前数据库的唯一索引只允许一个单位一条团体报名,第二支队录不进去。升级后:**现有的报名、成绩、名次、积分、奖牌、导出件与升级前完全一样**(老数据的队名都是空的,在新索引下仍然「一个单位一条」),**多队能力只有在真的给某个单位报了第二支队之后才会体现**;**报名表由 12 列变成 13 列**(最后一列新增「队伍」),**老 12 列表照常导入**。
### ① 数据迁移:一条(`0025`)
- **它做什么**:给报名表加一列 `squad`(队伍,自由文本,**默认空串**),并把「同一个单位在同一个组别只能有一条团体报名」的那个唯一索引换成**带上队伍的版本**(`组别 + 单位 + 队伍`)。**带的运动员的报名不受影响**:它们仍然按「组别 + 单位 + 运动员」唯一,同一个运动员不会因为这次改动而在同一组别报两次。
- **老数据零变化**:升级后所有旧报名的队伍都是**空串**,在新索引下等价于「一个单位一条」——**原来能跑的名单、成绩、榜单、导出件一个字节都不变**(对拍证据见下)。
- **怎么看它跑过**:启动一次 Console,在运行日志或 `logs\operations\` 当天文件里能看到 `已应用迁移 … name=0025_entry_squad.sql`。
- **对拍证据**:接口层把老数据 `GET /entries` 的 JSON 里新字段剥掉后**逐字节相同**;导出层把 9 份导出件的**全部单元格(共 750 行)dump 出来比对**,**md5 相同**(另带敏感度自检,确认这份对拍真的能发现差异)。
### ② 新能力:团体项目多队(只给团体项目)
**怎么做**:一个单位在同一个组别报两支队时——两队**各写一行**,靠队伍列 / 队名区分:
| 场景 | 怎么做 |
| --- | --- |
| **导入报名表** | 用**最后一列「队伍」**写队名(`一队` / `二队` / `青年队`,随便写);**只有一支队的单位这一列留空**。中文表头叫「队伍」,也认「队名」「队伍名」 |
| **补报名(勾选)** | 「成绩录入」页的「补报名」或「基础数据 → 报名导入」的**团体报名**页签:勾上单位后**那一行右侧出现「队伍(留空=不区分)」输入框**,填队名提交即可;**没勾的单位不显示这个框**;已报过的单位徽标写成 `已报名(一队、二队)` |
| **改队名** | 填错不用删掉重建:「成绩录入」页**点那一行的「单位」格** → 弹窗里的 **「队伍」输入框**(**只有纯团体报名才显示**)→ 保存(按钮会按改了啥写成 `保存队伍` / `保存成员单位` / `保存成员单位与队伍`,**没改动时是灰的**) |
**口径**:
- **队伍只对团体项目**:个人项目(按人报名的那些)**不受影响**,也没有「队」这回事;个人行即使填了队名也**不会写进库里**,导入结果里会给一条提示;
- **留空 = 不区分队伍**:绝大多数单位只有一支队,**这一列留空 / 整列不写 / 表头里没有它**都是一样的(= 升级前的行为);
- **同一个单位 + 同一个组别 + 同一个队名只能有一条**:要派两支队就写**两个不同的队名**;写重了会被**逐行拦下**并说清是哪个单位、哪个队,**其余行照常导入**;
- **一个单位两个队都进前三 → 这个单位拿 2 枚牌**(两支队各算各的名次,分与牌都记到同一个单位头上);
- **队伍不影响算分**:两队的成绩、名次、积分各算各的,最后都汇到同一个单位的总分里;
- **不涉及「按单位手工录名次」的项目**(篮球那种每个单位只有一支队):名次仍然按单位录,与队名无关。
### ③ 显示口径:`单位名 + 队名`
- 写出来**不加括号、不加分隔符**:`第一分公司二队`;**队名留空时逐字保持原样**(`第一分公司`);
- **写队名的地方**:成绩录入页 / 成绩审核页的「单位」列、批量操作的确认框、报错里的行名、大屏的分项成绩 / 实时赛况 / 底栏「最新完成项目」,以及**导出件的「单位」列**(分项成绩表、全部成绩单页、报名名单、单位奖牌明细总表、单位积分总明细总表);
- **不写队名的地方**:**单位总分榜、单位奖牌榜、个人积分榜**——这三处是单位级汇总,分与奖牌本来就归到单位。
### ④ 报名表 12 列 → 13 列(老表照常导入)
- 新增的是**最后一列「队伍」**(英文规范名 `squad`,中文别名「队伍」/「队名」/「队伍名」),**可选**:整列不写、格子留空、表头里没有它,三种都合法;
- 新列加在**表尾**,所以**手里原来的 12 列表一列都不会错位、照常导入**;下载的新模板是 13 列(分工作表模板也一样);
- 填法、报错文案与例子见《报名名单导入规范》第二节 / 第三节「队伍」/ 第五节的例 5。
### ⑤ 改完队名,记得顺手改报名表
⚠️ 导入是**按「小项 + 组别 + 单位 + 队名」认人**的:在界面上把「二队」改成「青年队」之后,如果以后再导一份**还写着「二队」**的报名表,系统会**新增出「二队」那一条报名**(不会静默合并,也不会报错)。所以**改队名时把报名表里的队名也改成一样**。
### ⑥ 升级后 5 分钟核对
1. 启动一次 Console,看运行日志 / `logs\operations\` 当天文件:应能看到 `0025` 这条迁移记录,没有别的报错;
2. 刷新管理端「积分榜」与大屏:**单位总分榜、单位奖牌榜、个人积分榜的每一个数字与升级前一致**(本版不改任何算分口径);
3. 打开「成绩录入」页:**「单位」那一列与升级前长得一样**(老报名的队名是空的,不会多出任何字);点某一行的「单位」格,**纯团体报名**的弹窗里应多出一个「队伍」输入框(**个人报名看不到这个框**,这是对的);
4. 打开「基础数据 → 报名导入」,下载一份模板:**表头最后一列应是「队伍」**(共 13 列);拿一份升级前的老 12 列表导一次,应与升级前一样正常导入;
5. 拿一个真要多队的项目试一次:同一个单位、同一个组别报两行、队伍列分别写 `一队` / `二队`,导入后到录入页确认**两支队各占一行、单位列写成 `第一分公司一队` / `第一分公司二队`**,录成绩发布后**两队各拿各的名次、单位的分数是两队之和**;
6. 用《上线检查清单》过一遍。
### ⑦ 详细说明在哪
| 想了解 | 看 |
| --- | --- |
| 多队怎么录、显示成什么样、改队名在哪 | 《使用手册》4.12(以及 4.2、4.11、10.7、11.1、17.1 各处的衔接说明) |
| 导入表「队伍」列怎么写、报什么错 | 《报名名单导入规范》第二节、第三节「队伍」、第五节的例 5、第六节 |
| 迁移与唯一索引 | `server/migrations/event/0025_entry_squad.sql` |
---
## dev_0.5.4
### 一句话 ### 一句话
@@ -67,7 +134,7 @@
怎么用: 怎么用:
- **配联队的两个入口**: - **配联队的两个入口**:
1. **导入报名表**时把单位编码写成 `A+B`(默认均分;最多 5 个单位;`A+`、`+B`、`A++B`、`A+A` 这些写法会被**逐行拦下**并说清是第几行、错在哪,**其余行照常导入**);报名模板(分工作表 xlsx)的「单位编码」表头挂了一条**单元格批注**、该列还有**输入提示**,中英各一份,内容就是这套写法——**表头文字、列序、12 列、工作表命名一个字都没动**,老表格照常导入; 1. **导入报名表**时把单位编码写成 `A+B`(默认均分;最多 5 个单位;`A+`、`+B`、`A++B`、`A+A` 这些写法会被**逐行拦下**并说清是第几行、错在哪,**其余行照常导入**);报名模板(分工作表 xlsx)的「单位编码」表头挂了一条**单元格批注**、该列还有**输入提示**,中英各一份,内容就是这套写法——**表头文字、列序、12 列、工作表命名一个字都没动**,老表格照常导入(**这一版是 12 列;`dev_0.5.5` 起表尾新增了第 13 列「队伍」,见那一节**);
2. 导入后到**「成绩录入」页点那一行的「单位」格**,打开「**成员单位与份额**」弹窗改(加成员单位、填权重、去掉成员)。⚠️ **非均分(例如 2:1)不能在导入表里写**,只能在弹窗里改。 2. 导入后到**「成绩录入」页点那一行的「单位」格**,打开「**成员单位与份额**」弹窗改(加成员单位、填权重、去掉成员)。⚠️ **非均分(例如 2:1)不能在导入表里写**,只能在弹窗里改。
- **谁能改**:份额编辑与录成绩同档,**录入员及以上**;访客只读时输入框置灰并写明原因。 - **谁能改**:份额编辑与录成绩同档,**录入员及以上**;访客只读时输入框置灰并写明原因。
- **改完当场生效**:保存立即重算该小项该组别并推送大屏。 - **改完当场生效**:保存立即重算该小项该组别并推送大屏。
+54 -6
View File
@@ -11,14 +11,17 @@
## 一、先看懂三件事 ## 一、先看懂三件事
1. **用什么软件做表**:Excel 或 WPS 都行,做完存成 `.xlsx` 或 `.csv` 都可以(推荐 `.csv`,系统兼容性最好,不用管编码)。 1. **用什么软件做表**:Excel 或 WPS 都行,做完存成 `.xlsx` 或 `.csv` 都可以(推荐 `.csv`,系统兼容性最好,不用管编码)。
2. **表格长什么样**:**第一行是表头**(固定的 12 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。 2. **表格长什么样**:**第一行是表头**(固定的 13 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。
3. **编码不能自己编**:表里的「小项编码」「单位编码」「组别名称」必须是系统里已经配好的,**照抄系统里的写法**。系统里没有的编码,这一行会被跳过并报错。 3. **编码不能自己编**:表里的「小项编码」「单位编码」「组别名称」必须是系统里已经配好的,**照抄系统里的写法**。系统里没有的编码,这一行会被跳过并报错。
--- ---
## 二、表头(第一行的列名) ## 二、表头(第一行的列名)
一共 12 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。 一共 13 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。
> **第 13 列「队伍」是新加的(2026 年 9 月)**:**同一个单位在同一个组别派两支队**(羽毛球双打的一队 / 二队那种)时才需要填它;**绝大多数表格整列留空就行**,填法见第三节的「队伍」。
> **老表格不用改**:表头里没有这一列、整列不写、格子留空,三种情况都合法,系统按「不区分队伍」处理——**原来的 12 列一列都不会错位**(新的一列加在**最后**)。
下载模板时按手上表格的样子选: 下载模板时按手上表格的样子选:
@@ -43,6 +46,9 @@
| `heat` | 批次(也认「批次号」) | 批次,可缺省 | | `heat` | 批次(也认「批次号」) | 批次,可缺省 |
| `status` | 状态(也认「报名状态」) | 报名状态 | | `status` | 状态(也认「报名状态」) | 报名状态 |
| `member_names` | 队员名单 | 团体项目的队员名单 | | `member_names` | 队员名单 | 团体项目的队员名单 |
| `squad` | 队伍(也认「队名」「队伍名」) | **队名,可缺省**;同一个单位在同一组别派两支队时用它区分(留空 = 不区分队伍) |
> **第 13 列 `squad`(队伍)永远排在最后**:它是 2026 年 9 月新增的,加在表尾是为了让**手里那些原来 12 列的老表格一列都不会错位**(表格少的正是最后这一列)。整列不写、整列留空、表头没有它,三种情况都合法。
> **同一列只能写一次**:一份表里同时出现 `event_code` 和「项目编码」(或者干脆把同一列写了两遍),会被整份拦下并提示「报名表表头重复」,删掉多余的那一列再导入即可——系统不会替你猜该信哪一列,免得静默丢掉另一列的数据。 > **同一列只能写一次**:一份表里同时出现 `event_code` 和「项目编码」(或者干脆把同一列写了两遍),会被整份拦下并提示「报名表表头重复」,删掉多余的那一列再导入即可——系统不会替你猜该信哪一列,免得静默丢掉另一列的数据。
> >
@@ -64,6 +70,7 @@
| `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` | | `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
| `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` | | `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
| `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` | | `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` |
| `squad`(队伍) | 可选 | **队名**(自由写,例如 `一队`);**留空 = 不区分队伍**。**同一个单位在同一个组别派两支队就靠它区分**,见第三节 | `一队` |
> 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。 > 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。
@@ -183,6 +190,23 @@ F1+F2
- 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。 - 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。
- 系统界面里会把这条名单显示成「张三、李四、王五」。 - 系统界面里会把这条名单显示成「张三、李四、王五」。
### `squad` 队伍 —— 可选(表的最后一列)
**这一列只在「同一个单位在同一个组别派了不止一支队」时才需要填**,比如羽毛球双打:一个单位报了一队、二队,两队可能各占一个名次。
```
一队
二队
青年队
```
- **队名随便写**(`一队` / `二队` / `青年队` / `A 组` 都行),就是给现场看的一个标签;
- **留空 = 不区分队伍**:一个单位在这个组别只有一支队(绝大多数情况)时,**整列留空、整列不写、表头里没有这一列**,三种都合法,与升级前的行为完全一样;
- **只对团体项目有效**:这一列只认「姓名那一格留空」的团体行。**个人行(填了姓名的行)如果也填了队名,这一格不会写进库里**,导入结果里会给你一条提示:`第 N 行填了队伍「一队」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里`;
- **同一个单位、同一个组别、同一个队名只能有一条**(要派两支队就写两个不同的队名)。撞上了会逐行拦下并说清是哪个单位、哪个队(见第六节);
- **队名只影响显示与查重,不影响成绩与算分**:两个队的成绩、名次、积分各算各的,最后都汇总到同一个单位的总分上;显示上写成「单位名 + 队名」,例如 `第一分公司二队`(队名留空时仍然只写 `第一分公司`)。**单位总分榜 / 单位奖牌榜 / 个人积分榜上不写队名**(分与牌本来就归到单位);
- 队名**改起来不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ 改完记得**把报名表里的队名也改成一样**——以后再导一份还写着旧队名的表,会因为认不出来而**新增出旧队名那一条**。
### 系统怎么认人(证件号 → 号码布 → 姓名 + 单位) ### 系统怎么认人(证件号 → 号码布 → 姓名 + 单位)
有姓名的行,系统按下面的顺序认人。**证件号是唯一标识,号码布是它的替补,最后才靠「姓名 + 单位」兜底回填**: 有姓名的行,系统按下面的顺序认人。**证件号是唯一标识,号码布是它的替补,最后才靠「姓名 + 单位」兜底回填**:
@@ -196,7 +220,7 @@ F1+F2
--- ---
## 四、四个完整例子 ## 四、五个完整例子
### 例 1:个人项目(七单位 × 若干人) ### 例 1:个人项目(七单位 × 若干人)
@@ -246,11 +270,30 @@ F1+F2
- 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛)); - 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛));
- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(可以直接拿去重导)。 - 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(可以直接拿去重导)。
### 例 5:同一个单位派两个队(团体项目多队)
羽毛球双打,**F1 派出「一队」和「二队」两支队伍**,两队都可能拿名次。`BD` 是羽毛球双打、`男子组`:
| event_code | category | team_code | athlete_name | id_card | gender | age | bib | seed | heat | status | member_names | squad |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| BD | 男子组 | F1 | | | | | | | | registered | 张三/李四 | 一队 |
| BD | 男子组 | F1 | | | | | | | | registered | 王五/赵六 | 二队 |
| BD | 男子组 | F2 | | | | | | | | registered | 孙八/周九 | |
| BD | 男子组 | F3 | | | | | | | | registered | 吴十/郑十一 | |
要点:
- **同一个单位、同一个组别写两行**,靠**最后一列「队伍」**区分开:`一队`、`二队`。**一个单位两个队都进前三时,这个单位会拿 2 枚牌**(两支队各算各的名次);
- F2、F3 只有一支队,队伍列**留空**即可(也可以整列不填、或表头里干脆没有这一列);
- **列都写对了才有多队的效果**:两行的 `event_code` / `category` / `team_code` / `squad` 必须**都不一样的那一格正好是 squad**——如果两行队名也写成一样的(都写 `一队`),第二行会被拦下(「同一个单位同一个组别的同一个队只能报一条」);如果两行队名都留空,第二行也会被拦下(「一个单位在一个组别只能有一条不分队的报名」);
- 显示上写成 **`第一分公司一队` / `第一分公司二队`**(单位名后面直接跟队名,不加括号不加分隔符);**单位总分榜、单位奖牌榜、个人积分榜上只写 `第一分公司`**(两队的分数都已经算进这个单位的总分);
- 报了之后想改队名**不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ **改完记得把这张表里的队名也改成一样**——以后再导一份还写着旧队名的表,会**新增出旧队名那一条报名**(导入是按「小项 + 组别 + 单位 + 队名」认人的)。
--- ---
## 五、导入前请先检查(自检清单) ## 五、导入前请先检查(自检清单)
- [ ] 第一行是那 12 个列名(**英文一套、中文一套都行**,也可以中英混着写,见第二节),没有被合并单元格、**同一列没有写两遍** - [ ] 第一行是那 13 个列名(**英文一套、中文一套都行**,也可以中英混着写,见第二节),没有被合并单元格、**同一列没有写两遍**
- [ ] 表头上方没有多余的标题行或空行 - [ ] 表头上方没有多余的标题行或空行
- [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格 - [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格
- [ ] `category` 与系统里的组别名一字不差 - [ ] `category` 与系统里的组别名一字不差
@@ -262,6 +305,7 @@ F1+F2
- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空 - [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组) - [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
- [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节) - [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节)
- [ ] **「队伍」列**(最后一列)只在**同一个单位派了不止一支队**时才填:两队写了**两个不一样的队名**(都写一样或都留空会被拦下);只有一支队的单位留空即可;**个人行(有姓名的行)没有填队名**(填了也不会写进库里,只给一条提示)
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的) - [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
- [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节) - [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节)
@@ -284,6 +328,9 @@ F1+F2
| 第 N 行:单位编码 F1+ 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B) | **加号有一边是空的**(`F1+`、`+F2`、`F1++F2` 都是这一条)。多半是填一半或多了个加号 | 补上缺的那个编码,或删掉多余的加号;联队至少两个单位 | | 第 N 行:单位编码 F1+ 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B) | **加号有一边是空的**(`F1+`、`+F2`、`F1++F2` 都是这一条)。多半是填一半或多了个加号 | 补上缺的那个编码,或删掉多余的加号;联队至少两个单位 |
| 第 N 行:单位编码 F1+F1 里同一个单位出现了两次:联队的每个单位只能写一次 | 同一个单位在联队里写了两遍(`F1+F1`) | 一个单位只写一次;两个单位联合参赛就是 `F1+F2` | | 第 N 行:单位编码 F1+F1 里同一个单位出现了两次:联队的每个单位只能写一次 | 同一个单位在联队里写了两遍(`F1+F1`) | 一个单位只写一次;两个单位联合参赛就是 `F1+F2` |
| 第 N 行:单位编码 … 里写了 6 个单位:一个联队最多 5 个单位 | 加号太多、成员超过 5 个 | **一个联队最多 5 个单位**;现场最多见过三个单位合并,超过 5 个多半是这一格填错了东西(例如整格都是加号) | | 第 N 行:单位编码 … 里写了 6 个单位:一个联队最多 5 个单位 | 加号太多、成员超过 5 个 | **一个联队最多 5 个单位**;现场最多见过三个单位合并,超过 5 个多半是这一格填错了东西(例如整格都是加号) |
| 第 N 行:单位 A 在本组别已经有一条队伍「一队」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队) | 同一个单位、同一个组别、**同一个队名**写了两遍(两支队要写两个不同的队名) | 把其中一行的「队伍」列改成另一个队名(`一队` / `二队`);如果本来就是同一支队、只是想把队员名单补全,改完重导**不会新增**(同一单位同一队名是更新) |
| 第 N 行:单位 A 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请在队伍列分别写清(例如 一队 / 二队) | **两行的队伍列都留空**(等于都说「这个单位在这个组别只有一支队」),或者库里以前报过一条不分队的、这行又留空 | 要派两支队就在两行的「队伍」列分别写 `一队` / `二队`;确实只有一支队,就把多余的那一行删掉 |
| 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 |
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 | | 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 | | 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
@@ -344,7 +391,7 @@ F1+F2
### 8.2 数据怎么填 ### 8.2 数据怎么填
- 每张工作表**第 1 行就是表头**(与单表模板同样的 12 列、中英两套都认),**从第 2 行开始填人**; - 每张工作表**第 1 行就是表头**(与单表模板同样的 13 列、中英两套都认),**从第 2 行开始填人**;
- **「项目编码」「组别」两列可以整列留空**——表名已经说明了是哪个小项、哪个组别; - **「项目编码」「组别」两列可以整列留空**——表名已经说明了是哪个小项、哪个组别;
一旦填了,就**必须与表名一致**,不一致的那一行会报错并跳过(系统不猜、也不会挑一个用); 一旦填了,就**必须与表名一致**,不一致的那一行会报错并跳过(系统不猜、也不会挑一个用);
- 其余列与单表模板完全一样(第三节逐列说明照样适用); - 其余列与单表模板完全一样(第三节逐列说明照样适用);
@@ -395,6 +442,7 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿
> 8. 填完先对着第五节的清单自查一遍,再发给收表人; > 8. 填完先对着第五节的清单自查一遍,再发给收表人;
> 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**; > 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**;
> 10. **两个单位联合参赛(联队)**:`team_code` 那一格写 `F1+F2`(加号左边是主单位,默认均分,最多 5 个单位),**不要**新建一个「联队单位」、也不要两个单位各写一行;非均分(例如 2:1)要在导入后到「成绩录入」页点那一行的「单位」格改; > 10. **两个单位联合参赛(联队)**:`team_code` 那一格写 `F1+F2`(加号左边是主单位,默认均分,最多 5 个单位),**不要**新建一个「联队单位」、也不要两个单位各写一行;非均分(例如 2:1)要在导入后到「成绩录入」页点那一行的「单位」格改;
> 11. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。 > 11. **同一个单位派两支队**(羽毛球双打的一队 / 二队那种):在**最后一列「队伍」**里分别写队名(`一队` / `二队`);**只有一支队的单位这一列留空**,整列不写也行。**个人项目不用管这一列**;
> 12. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。
> >
> 一句话:**下载模板 → 只往下面粘数据 → 另存为 CSV UTF-8 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。 > 一句话:**下载模板 → 只往下面粘数据 → 另存为 CSV UTF-8 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。
+7 -2
View File
@@ -131,7 +131,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
query := ` query := `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''), SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''),
COALESCE(a.name, ''), COALESCE(en.member_names, ''), COALESCE(a.name, ''), COALESCE(en.member_names, ''),
COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat, COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat,
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, ''), en.id COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, ''), en.id
@@ -198,10 +198,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
heat sql.NullInt64 heat sql.NullInt64
age sql.NullInt64 age sql.NullInt64
athleteName string athleteName string
squad string
entryID int64 entryID int64
) )
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName, if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat, &squad, &athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil { &row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err) return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
} }
@@ -222,6 +223,10 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
row.TeamName = joint.Name() row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareRatioNote()) row.Remark = appendNote(row.Remark, joint.shareRatioNote())
} }
// 队名接在单位名后面(口径:单位名+队名,不加括号),空队名时一个字都不加:
// 老数据与单队单位的这份名单必须与升级前逐行一致。单位编码不动 —— 队名不是编码的一部分,
// 编码列照旧是可以直接拿去重导的写法。
row.TeamName = withSquad(row.TeamName, squad)
out = append(out, row) out = append(out, row)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
+10 -4
View File
@@ -542,7 +542,8 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
// 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。 // 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。
query := ` query := `
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''), COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''),
COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''), COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = en.id AND l.voided = 0), 0), WHERE l.entry_id = en.id AND l.voided = 0), 0),
@@ -594,6 +595,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
row EventResultRow row EventResultRow
rank, isTie int rank, isTie int
memberJSON, athleteName string memberJSON, athleteName string
squad string
resultStat, resultState sql.NullString resultStat, resultState sql.NullString
entryStat, entryRemark string entryStat, entryRemark string
resultNote string resultNote string
@@ -601,7 +603,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
required, published int required, published int
entryID int64 entryID int64
) )
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName, if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &squad, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat, &row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil { &entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err) return nil, fmt.Errorf("export: 扫描成绩: %w", err)
@@ -616,6 +618,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
row.TeamName = joint.Name() row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareNote()) row.Remark = appendNote(row.Remark, joint.shareNote())
} }
// 队名接在单位名后面(口径:单位名+队名,不加括号)。放在联队之后:联队名本身就是单位名,
// 「A+B联队二队」读起来才是「这个联队的第二支队」。空队名时这一行不动,导出件与升级前逐字一致。
row.TeamName = withSquad(row.TeamName, squad)
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。 // results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。 // 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
row.NoResult = !resultState.Valid row.NoResult = !resultState.Valid
@@ -724,7 +729,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''), COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
COALESCE(r.unit, ''), COALESCE(r.unit, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0), l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0),
COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0) COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0), COALESCE(en.squad, '')
FROM score_ledger l FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id LEFT JOIN sports s ON s.id = e.sport_id
@@ -759,7 +764,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([
var athleteID, eventID, entryID int64 var athleteID, eventID, entryID int64
var resultText, unit string var resultText, unit string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID); err != nil { &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID,
&r.squad); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err) return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
} }
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩 // 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
+18 -3
View File
@@ -36,6 +36,11 @@ type MedalDetailRow struct {
// 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。 // 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。
jointNote string jointNote string
// squad 是这块奖牌对应报名项的队名(entries.squad),只有总表的「单位」列用它:
// 一个单位两个队各拿一块牌时,总表上要认得出来哪块是哪个队的。分表没有「单位」列,
// 表名本身就是单位(它是一整份单位级清单,不按队拆)。
squad string
// 排序键,只在本包内用,不导出。 // 排序键,只在本包内用,不导出。
sportSort int sportSort int
eventSort int eventSort int
@@ -129,7 +134,9 @@ func medalMasterRows(sheets []MedalDetailSheet) [][]any {
for _, s := range sheets { for _, s := range sheets {
unit := displayName(s.Team.Name, s.Team.ShortName) unit := displayName(s.Team.Name, s.Team.ShortName)
for _, r := range s.Rows { for _, r := range s.Rows {
out = append(out, append([]any{unit}, medalRow(r)...)) // 队名接在单位名后面(口径:单位名+队名):一个单位两个队各拿一块牌时,
// 总表上两块牌的单位列不是同一行字,读者才知道哪块是哪个队拿的。空队名时一个字都不加。
out = append(out, append([]any{withSquad(unit, r.squad)}, medalRow(r)...))
} }
} }
return out return out
@@ -213,9 +220,15 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
} }
orderTeamsByStandings(teams, order) orderTeamsByStandings(teams, order)
// 队名按报名项读一次给全部奖牌行复用(逐行去查库是白跑)。
squads, err := loadEntrySquads(db)
if err != nil {
return MedalDetailWorkbook{}, err
}
sheets := make([]MedalDetailSheet, 0, len(teams)) sheets := make([]MedalDetailSheet, 0, len(teams))
for _, t := range teams { for _, t := range teams {
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints) rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints, squads)
if len(rows) == 0 { if len(rows) == 0 {
continue continue
} }
@@ -409,7 +422,8 @@ func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。 // medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。 // 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam) []MedalDetailRow { // squads 是「报名 id → 队名」,用来给总表的单位列补上这块奖牌是哪个队拿的。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam, squads map[int64]string) []MedalDetailRow {
list := hits[teamID] list := hits[teamID]
rows := make([]MedalDetailRow, 0, len(list)) rows := make([]MedalDetailRow, 0, len(list))
for _, h := range list { for _, h := range list {
@@ -430,6 +444,7 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
eventID: c.eventID, eventID: c.eventID,
categoryID: c.categoryID, categoryID: c.categoryID,
resultID: h.resultID, resultID: h.resultID,
squad: squads[h.entryID],
} }
// 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。 // 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。
// 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。 // 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。
+54
View File
@@ -0,0 +1,54 @@
package export
import (
"database/sql"
"fmt"
"strings"
)
// 本文件是「队名」(entries.squad,迁移 0025)在导出侧的读法。
//
// 同一个单位在同一组别可以派多支队参赛(例如羽毛球双打,一个单位两个队各占一个名次),
// 报名上多出一列自由文本队名,空串 = 不区分队伍 —— 那是老数据与单队单位的默认值。
//
// 显示口径(与大屏、管理端同一份实现):**单位名 + 队名**,不加括号也不加分隔符,例如「第一分公司二队」。
// 现场就是把它当一个单位名念的,括号与空格只会白占列宽,还会让同一支队在两份材料里写法不一样。
// withSquad 把队名接在单位名后面;队名为空(或只有空白)时**一个字都不加**。
//
// 空队名什么都不加是硬门槛:升级后导出的老赛事必须与升级前逐行一致。
// 顺带去掉队名首尾空白:它由现场自由填写,带着空格会把「第一分公司 二队」这种读法印进导出件。
func withSquad(name, squad string) string {
squad = strings.TrimSpace(squad)
if squad == "" {
return name
}
return name + squad
}
// loadEntrySquads 读全部带队名的报名,按报名 id 归组。
//
// 只取非空的行:绝大多数报名的 squad 都是空串,整表扫回来再逐行判空是白花内存。
func loadEntrySquads(db *sql.DB) (map[int64]string, error) {
rows, err := db.Query(`SELECT id, squad FROM entries WHERE TRIM(COALESCE(squad, '')) <> ''`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名队名: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]string)
for rows.Next() {
var (
entryID int64
squad string
)
if err := rows.Scan(&entryID, &squad); err != nil {
return nil, fmt.Errorf("export: 扫描报名队名: %w", err)
}
out[entryID] = squad
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历报名队名: %w", err)
}
return out, nil
}
+183
View File
@@ -0,0 +1,183 @@
package export
import (
"bytes"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
)
// 这份用例是「squad 为空时导出件逐行一致」这条硬门槛的对拍工具。
//
// 做法:造一份**全部报名都没有队名**的赛事库(队名列留空 = 老数据与单队单位的原样),
// 把每一份导出件的每一张工作表、每一个单元格 dump 成文本;同一个用例文件放到改动前的代码上
// 再跑一次(`git archive` 取一份 HEAD 的树,只补上这次迁移与这个用例文件),两份 dump
// `diff` 必须为空。
//
// 落地路径由 EXPORT_SQUAD_DUMP 指定,不设时写进 t.TempDir() —— 也就是说平时它只当一次
// 「全部导出件都能加载」的冒烟,不往仓库里落文件。
func TestSquadEmptyExportDumpIsUnchanged(t *testing.T) {
db := openEventDB(t)
seedSquadDumpEvent(t, db)
opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}}
empty := dumpAllWorkbooks(t, db, opts)
// 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。
path := os.Getenv("EXPORT_SQUAD_DUMP")
if path == "" {
path = filepath.Join(t.TempDir(), "squad-empty-dump.txt")
}
if err := os.WriteFile(path, []byte(empty), 0o644); err != nil {
t.Fatalf("写出 dump: %v", err)
}
t.Logf("空队名 dump:%s(%d 个单元格)", path, strings.Count(empty, "\n"))
// 敏感度自检:给那条团体报名写上队名后重新 dump,两次必须不同。
// 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到单位列 —— 那就白对拍了。
if _, err := db.Exec(`UPDATE entries SET squad = '二队' WHERE event_id = 2`); err != nil {
t.Fatalf("写入队名: %v", err)
}
withSquadDump := dumpAllWorkbooks(t, db, opts)
if withSquadDump == empty {
t.Fatal("给报名写上队名后 dump 没变:这份对拍看不见单位列,对拍结论无效" +
"(在对拍用的旧代码上这条自检本来就会红——旧代码根本不看 squad,正说明两份 dump 的差异只可能来自这次改动)")
}
if !strings.Contains(withSquadDump, "第一分公司二队") {
t.Fatal("带队名的 dump 里找不到「第一分公司二队」:队名没有写进导出件")
}
}
// seedSquadDumpEvent 造一份覆盖面尽量宽的赛事库,且**所有报名的队名都留空**:
// 个人小项(含空批次、团体行回落成员名单)+ 一条纯团体报名 + 联队份额。
// 个人路径与团体路径都要有,否则这次改动覆盖的两处读取点里会漏掉一处。
func seedSquadDumpEvent(t *testing.T, db *sql.DB) {
t.Helper()
seedEvent(t, db) // T1/T2、100 米跑、两条已发布成绩与流水
addBronzeEntry(t, db) // 第 3 名(一个单位两条报名)
stmts := []string{
`INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'team', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, bib, heat)
VALUES (2, 1, 1, '["王磊","赵雷"]', '201', 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
SELECT 2, id, 1, '2:0', 2, 'pts', 'published' FROM entries WHERE event_id = 2`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
SELECT 1, 2, e.id, r.id, 'result', r.id, 10
FROM entries e JOIN results r ON r.entry_id = e.id WHERE e.event_id = 2`,
// 联队:这条团体报名由两个单位合报(奖牌各记一枚),队名列仍然是空的。
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
SELECT e.id, t.id, 1, 1, t.id - 1 FROM entries e CROSS JOIN teams t WHERE e.event_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
}
// dumpAllWorkbooks 把全部导出件 dump 成一份文本:先写每个工作簿的 sheet 列表(表名里也有单位名),
// 再逐单元格写 `工作簿|工作表|行|列|值`。用 Write*(生产走的是同一条流)而不是落盘再读,
// 保证 dump 的就是响应体里那份文件。
func dumpAllWorkbooks(t *testing.T, db *sql.DB, opts Options) string {
t.Helper()
var sb strings.Builder
dump := func(name string, write func(io.Writer) error) {
t.Helper()
var buf bytes.Buffer
if err := write(&buf); err != nil {
t.Fatalf("导出 %s: %v", name, err)
}
f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("打开 %s: %v", name, err)
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
sort.Strings(sheets)
fmt.Fprintf(&sb, "== %s sheets=%s\n", name, strings.Join(sheets, ","))
for _, sheet := range sheets {
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s!%s: %v", name, sheet, err)
}
for r, row := range rows {
for c, v := range row {
fmt.Fprintf(&sb, "%s|%s|%d|%d|%q\n", name, sheet, r+1, c+1, v)
}
}
}
}
standings, err := LoadStandingsWorkbook(db, opts)
if err != nil {
t.Fatalf("读总表: %v", err)
}
dump("总表", func(w io.Writer) error { return WriteStandingsXLSX(w, standings) })
events, err := LoadEventWorkbook(db, 0, opts)
if err != nil {
t.Fatalf("读分项成绩表: %v", err)
}
dump("分项成绩表", func(w io.Writer) error { return WriteEventXLSX(w, events) })
flat, err := LoadFlatWorkbook(db, opts)
if err != nil {
t.Fatalf("读全部成绩单页: %v", err)
}
dump("全部成绩单页", func(w io.Writer) error { return WriteFlatXLSX(w, flat) })
all, err := LoadAllWorkbook(db, opts)
if err != nil {
t.Fatalf("读全部: %v", err)
}
dump("全部", func(w io.Writer) error { return WriteAllXLSX(w, all) })
entries, err := LoadEntriesWorkbook(db, EntriesFilter{}, opts)
if err != nil {
t.Fatalf("读报名名单: %v", err)
}
dump("报名名单", func(w io.Writer) error { return WriteEntriesXLSX(w, entries) })
medals, err := LoadMedalDetailWorkbook(db, opts)
if err != nil {
t.Fatalf("读单位奖牌明细: %v", err)
}
dump("单位奖牌明细", func(w io.Writer) error { return WriteMedalDetailXLSX(w, medals) })
athletes, err := LoadAthleteDetailWorkbook(db, opts)
if err != nil {
t.Fatalf("读个人得分明细: %v", err)
}
dump("个人得分明细", func(w io.Writer) error { return WriteAthleteDetailXLSX(w, athletes) })
details, err := LoadAllTeamDetailsWorkbook(db, opts)
if err != nil {
t.Fatalf("读总明细: %v", err)
}
dump("总明细", func(w io.Writer) error { return WriteAllTeamDetailsXLSX(w, details) })
for _, teamID := range []int64{1, 2} {
wb, err := LoadTeamDetailWorkbook(db, teamID, opts)
if err != nil {
t.Fatalf("读单位 %d 积分明细: %v", teamID, err)
}
name := fmt.Sprintf("单位%d积分明细", teamID)
dump(name, func(w io.Writer) error { return WriteTeamDetailXLSX(w, wb) })
}
return sb.String()
}
+210
View File
@@ -0,0 +1,210 @@
package export
import (
"database/sql"
"path/filepath"
"testing"
)
// seedSquads 造一场「一个单位派两支队」的团体赛:羽毛球男子双打,第一分公司派一队 / 二队,
// 两队分别拿第 1、第 2 名(口径 3:一个单位两个队各占一个名次、各拿一块牌,不去重不合并),
// 第二分公司一支不区分队伍的队(squad 留空 = 老写法)拿第 3 名。
func seedSquads(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T2', '第二分公司', '二公司', 2)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '羽毛球')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
// 同一个单位在同一组别的两条团体报名:靠 squad 区分,只有带上 squad 的唯一索引才放得下。
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, bib)
VALUES (1, 1, 1, '["王磊","赵雷"]', '一队', '101')`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, bib)
VALUES (1, 1, 1, '["陈刚","郑凯"]', '二队', '102')`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, bib)
VALUES (1, 1, 2, '["孙七","周八"]', '103')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 1, 1, '2:0', 2, 'pts', 'published')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 2, 2, '2:1', 2, 'pts', 'published')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 3, 3, '2:1', 2, 'pts', 'published')`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 2, 2, 'result', 2, 8)`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (2, 1, 3, 3, 'result', 3, 6)`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
`UPDATE settings SET value = '测试主办方' WHERE key = 'event.organizer'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// 分项成绩表:单位列在队名非空时写「单位名+队名」,一个单位的两个队各占一行。
func TestEventSheetShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "成绩表.xlsx")
if err := ExportEventXLSX(db, 0, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
rows := readSheet(t, dst, "羽毛球·男子双打·男子")
// 4 行版式 + 3 条报名,按名次排:一队、二队、第二分公司。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 2); got != w {
t.Errorf("第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
}
// 全部成绩(单页):与分项成绩表同源,单位列同样带上队名。
func TestFlatSheetShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "单页.xlsx")
if err := ExportFlatXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出全部成绩(单页): %v", err)
}
rows := readSheet(t, dst, "全部成绩")
// 单页表在「名次」前多了 大项/小项/组别 三列,单位列是第 6 列。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 5); got != w {
t.Errorf("第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
}
// 报名名单:单位名列在队名非空时写「单位名+队名」,不区分队伍的报名照旧只写单位名。
func TestEntriesSheetShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
// 列序:小项编码/小项名/组别/单位编码/单位名/…,单位名是第 5 列。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 4); got != w {
t.Errorf("第 %d 行单位名 = %q,期望 %q", i+1, got, w)
}
}
}
// 单位奖牌明细:总表的单位列按该奖牌对应报名项的队名写全;一个单位两个队各拿一块牌,
// 就是两块牌(口径 3),不合并、不去重。
func TestMedalDetailShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
wantSheets := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalStrings(got, wantSheets) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheets)
}
master, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(master, 4+i, 0); got != w {
t.Errorf("总表第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
// 第一分公司是一枚金牌加一枚银牌(两个队各一块),分表的小计照实写两块。
unit, err := f.GetRows("第一分公司奖牌明细")
if err != nil {
t.Fatalf("读取分表: %v", err)
}
if got := cell(unit, len(unit)-1, 4); got != "金 1 / 银 1 / 铜 0" {
t.Errorf("第一分公司小计 = %q,期望 金 1 / 银 1 / 铜 0", got)
}
}
// 单位积分总明细:总表的单位列按该条流水对应报名项的队名写全(分表没有单位列,表名就是单位)。
func TestAllTeamDetailsShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "总明细.xlsx")
if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出总明细: %v", err)
}
rows := readSheet(t, dst, "积分总明细")
// 列序:单位/序号/大项/小项/组别/运动员/名次/成绩/来源/积分/备注;
// 行序按单位总分榜(第一分公司在前),单位内按名次:一队 10 分、二队 8 分,第二分公司 6 分。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 0); got != w {
t.Errorf("总明细第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
if got := cell(rows, 7, 0); got != "合计" {
t.Errorf("末行首列 = %q,期望 合计", got)
}
}
// 队名为空(不区分队伍)时,五份导出件的单位名列一个字都不多:这是老数据与单队单位的默认,
// 导出件必须与升级前逐行一致。
func TestEmptySquadKeepsPlainUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dir := t.TempDir()
entriesDst := filepath.Join(dir, "报名名单.xlsx")
if err := ExportEntriesXLSX(db, entriesDst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
// 第三条报名(第二分公司)没有队名。
if got := cell(readSheet(t, entriesDst, "报名名单"), 6, 4); got != "第二分公司" {
t.Errorf("空队名的单位名 = %q,期望 第二分公司", got)
}
medalDst := filepath.Join(dir, "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, medalDst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
if got := cell(readSheet(t, medalDst, "单位奖牌明细"), 6, 0); got != "第二分公司" {
t.Errorf("空队名的奖牌总表单位 = %q,期望 第二分公司", got)
}
detailDst := filepath.Join(dir, "总明细.xlsx")
if err := ExportAllTeamDetailsXLSX(db, detailDst, Options{}); err != nil {
t.Fatalf("导出总明细: %v", err)
}
if got := cell(readSheet(t, detailDst, "积分总明细"), 6, 0); got != "第二分公司" {
t.Errorf("空队名的总明细单位 = %q,期望 第二分公司", got)
}
}
+4
View File
@@ -23,6 +23,10 @@ type TeamDetailRow struct {
Remark string Remark string
// individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。 // individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。
individual bool individual bool
// squad 是这条流水对应报名项的队名(entries.squad),只有总明细的「单位」列用它:
// 单位分表的表名就是单位(一份单位级清单),没有单位列可写,所以那里不展示队名。
squad string
} }
// TeamDetailWorkbook 是「单位积分明细」的内容。 // TeamDetailWorkbook 是「单位积分明细」的内容。
+9 -2
View File
@@ -94,8 +94,15 @@ func allTeamDetailRows(sheets []TeamDetailSheet, weight float64) [][]any {
unit := displayName(s.Team.Name, s.Team.ShortName) unit := displayName(s.Team.Name, s.Team.ShortName)
rows, sub := teamDetailDataRows(s.Rows, weight) rows, sub := teamDetailDataRows(s.Rows, weight)
total += sub total += sub
for _, row := range rows { for i, row := range rows {
out = append(out, append([]any{unit}, row...)) // 队名接在单位名后面(口径:单位名+队名):一行分是哪个队拿的,总表上要认得出来。
// 空队名时一个字都不加(老数据与单队单位的导出件与升级前逐行一致)。
// dataRows 逐行按原序产出,索引与 s.Rows 一一对应。
label := unit
if i < len(s.Rows) {
label = withSquad(unit, s.Rows[i].squad)
}
out = append(out, append([]any{label}, row...))
} }
} }
// 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。 // 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。
@@ -0,0 +1,90 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// seedCompareNoneEventNoCategory 造一个 compare=none 的个人小项,**不带任何组别**。
//
// 这样的现场以前能把 basis=result 的通道存进去:recomputeLedgerTx 是逐组别算的
// (scoring.go 里 `for _, cat := range cats`),没有组别就一轮都不跑,domain.ScoreEvent 的
// ErrCompareRequired 根本到不了,通道配置就这么落库了。带组别的小项则会在这里撞 409,但那是
// 原始领域错误(「domain: basis=result 的通道要求小项配置 compare=asc/desc」),操作员看不出该改什么。
func seedCompareNoneEventNoCategory(t *testing.T, env *testEnv, ids seedIDs, code, name string) int64 {
t.Helper()
return env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, ?, ?, 'individual', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport, code, name)
}
// compare=none 的小项不得配 basis=result / basis=aggregate 的通道——这条守卫以前只在规则包导入里
// (rules/diff.go checkCompareCompatible),管理端手工配的这条路没人拦:现场形态就是一个台球小项
// (compare=none,名次靠人工录入)被配上 basis=result 的通道,一直配到发布才 409。
func TestReplaceChannelsRejectsResultBasisOnCompareNoneEvent(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
eventID := seedCompareNoneEventNoCategory(t, env, ids, "Z_CN", "台球")
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
channels := fmt.Sprintf("/api/events/%d/channels", eventID)
// 两种 basis 都要拦,且提示要点名小项、说清原因是 compare=none、并给出可执行的改法。
for _, basis := range []string{"result", "aggregate"} {
status, resp := env.do(client, http.MethodPut, channels, map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": basis, "profileId": profileID}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil {
t.Fatalf("compare=none 配 basis=%s 应 400,得到 %d %+v", basis, status, resp.Error)
}
for _, want := range []string{"台球", "compare=none", basis, "手动录名次"} {
if !strings.Contains(resp.Error.Message, want) {
t.Fatalf("basis=%s 的 400 文案应含 %q,得到 %q", basis, want, resp.Error.Message)
}
}
}
// 整包拒收:一个字节都不写。
status, resp := env.do(client, http.MethodGet, channels, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error)
}
if list := decodeData[[]map[string]any](t, resp); len(list) != 0 {
t.Fatalf("被拒的保存不该写下任何通道,得到 %+v", list)
}
// 合法的改法照常放行:改成 basis=manual。
status, resp = env.do(client, http.MethodPut, channels, map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": "manual", "profileId": profileID}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("compare=none 配 basis=manual 应 200,得到 %d %+v", status, resp.Error)
}
}
// 对照:有比较方向的小项照常可以配 basis=result / aggregate,这条守卫不能误伤正常配置。
func TestReplaceChannelsAllowsResultBasisWhenCompareIsSet(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event), map[string]any{
"channels": []map[string]any{
{"scope": "athlete", "basis": "result", "profileId": profileID},
{"scope": "team", "basis": "aggregate", "profileId": profileID},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("compare=asc 的小项配 result/aggregate 应 200,得到 %d %+v", status, resp.Error)
}
}
+9 -4
View File
@@ -112,10 +112,15 @@ type profileDTO struct {
} }
type entryDTO struct { type entryDTO struct {
ID int64 `json:"id"` ID int64 `json:"id"`
EventID int64 `json:"eventId"` EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"` CategoryID int64 `json:"categoryId"`
TeamID int64 `json:"teamId"` TeamID int64 `json:"teamId"`
// Squad 是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
// 空串 = 不区分队伍:单队单位与升级前的老数据都是它,行为与升级前完全一致(见迁移 0025)。
// 只对纯团体报名(athleteId 为空)有意义:带运动员的报名按 (组别, 单位, 运动员) 唯一,
// 同一个人在同一组别不会因为 squad 而多报一次。
Squad string `json:"squad"`
AthleteID *int64 `json:"athleteId"` AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"` MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"` Bib *string `json:"bib"`
@@ -436,8 +436,8 @@ func entryTemplateHeader(language string) []string {
// 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位, // 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位,
// 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。 // 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。
// //
// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、12 列 // 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、列名与
// 一字不动),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表 // importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
// 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见) // 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见)
// 与这一列的**输入提示**(点中格子就弹出来)。 // 与这一列的**输入提示**(点中格子就弹出来)。
const ( const (
@@ -505,6 +505,7 @@ var entryTemplateWidths = map[string]float64{
"heat": 8, "heat": 8,
"status": 12, "status": 12,
"member_names": 26, "member_names": 26,
"squad": 12,
} }
const defaultEntryTemplateWidth = 14 const defaultEntryTemplateWidth = 14
@@ -659,7 +660,7 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro
// 整列一条输入提示(点中格子就弹出)。 // 整列一条输入提示(点中格子就弹出)。
// //
// 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是 // 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是
// 「第 1 行 12 列表头 + 数据行」,批注与输入提示不是数据。 // 「第 1 行表头 + 数据行」(列名与 importColumns 一致),批注与输入提示不是数据。
func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error { func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error {
column := entryTeamCodeColumn() column := entryTeamCodeColumn()
if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" { if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" {
@@ -187,16 +187,16 @@ func TestEntriesTemplateOneSheetPerEventCategory(t *testing.T) {
} }
} }
// sheetImportHeader 是分工作表模板的完整 12 列表头(含批次列),与服务端模板逐列一致。 // sheetImportHeader 是分工作表模板的完整 13 列表头(含批次列与队伍列),与服务端模板逐列一致。
// 既有测试用的 importHeader 少了 heat 列,这里不能复用它,否则每一列都会串位。 // 既有测试用的 importHeader 少了 heat 列,这里不能复用它,否则每一列都会串位。
const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names" const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names,squad"
// entryTemplateHeaderForTest 是中文表头(与 importColumns 的中文别名、前端模板同源)。 // entryTemplateHeaderForTest 是中文表头(与 importColumns 的中文别名、前端模板同源)。
func entryTemplateHeaderForTest(t *testing.T) []string { func entryTemplateHeaderForTest(t *testing.T) []string {
t.Helper() t.Helper()
return []string{ return []string{
"项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄", "项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄",
"号码布", "种子", "批次", "状态", "队员名单", "号码布", "种子", "批次", "状态", "队员名单", "队伍",
} }
} }
@@ -214,7 +214,7 @@ func TestEntriesTemplateEnglishHeader(t *testing.T) {
} }
want := []string{ want := []string{
"event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age", "event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age",
"bib", "seed", "heat", "status", "member_names", "bib", "seed", "heat", "status", "member_names", "squad",
} }
if !equalStrings(rows[0], want) { if !equalStrings(rows[0], want) {
t.Fatalf("英文模板表头应为规范列名,得到 %v", rows[0]) t.Fatalf("英文模板表头应为规范列名,得到 %v", rows[0])
+2 -1
View File
@@ -7,7 +7,8 @@ import (
"testing" "testing"
) )
// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。 // importHeaderHeat 是带批次列的 12 列表头(升级前的列数,比现在少一个「队伍」列)。
// 刻意写成字面量:它同时证明「现场手里列少的旧表格照常导入」,不跟着模板一起变。
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names" const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。 // 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
+107
View File
@@ -0,0 +1,107 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
)
// 本文件是「队名」的手工修改:PUT /api/entries/{id}/squad(队名本身见迁移 0025)。
//
// 为什么需要这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。
// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**(老那条仍挂在库里)——
// 「填错一个字就得删掉重建」在现场是不能接受的。
// updateEntrySquad 改一条团体报名的队名,返回更新后的完整读数。
//
// **不重算**,这是有意的:squad 只是给现场看的标签(榜单、导出件、大屏把「第一分公司」
// 显示成「第一分公司青年队」)。它不参与名次 / 积分 / 奖牌的任何一个口径——全仓读 squad 的
// 只有导出与报名读数这两处展示代码,引擎(internal/domain)与 score_ledger / results /
// channel_ranks 一个字都不看它。所以改它时算分输出的每个字节都不变,这里也就没有
// recomputeLedgerTx:白算一遍不但慢,还会白白推一次榜(大屏会跟着闪一下没变的名次)。
// 同档的另一个先例是批次 entries.heat,同样是纯展示字段,改它也不重算(见 setEntriesHeat)。
func (s *server) updateEntrySquad(ctx context.Context, entryID int64, squad string) (entryDTO, error) {
// trim 统一收在这里:导入侧读单元格时已经 trim 过(handlers_config.go 的 cell()),
// 手工改走的是另一条路,不 trim 就会把「一队」与「一队 」当成两个队。
squad = strings.TrimSpace(squad)
entry, err := s.getEntry(ctx, entryID)
if err != nil {
return entryDTO{}, err
}
// 带运动员的报名按 (组别, 单位, 运动员) 唯一,队名根本不参与它的查重键
// (0025 的部分索引带 WHERE athlete_id IS NULL)。写进去既不生效,又会让人以为
// 「这个人报了两个队」,所以直接拒掉并说清原因。
if entry.AthleteID != nil {
return entryDTO{}, badRequest("这条报名带着运动员,队名只对团体报名生效:队名分的是「同一个单位在同一个组别的第几支队」,带运动员的报名按(组别、单位、运动员)唯一,改队名变不出第二支队", nil)
}
// 同组别同单位已经有这个队名时先拦下来:撞索引的报错是英文的约束原文,
// 现场看不出是哪个单位、哪个队,也不知道该改哪一格。
var other int64
err = s.ev.QueryRowContext(ctx,
`SELECT id FROM entries
WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ? AND id <> ?`,
entry.CategoryID, entry.TeamID, squad, entryID).Scan(&other)
switch {
case err == nil:
return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil)
case !errors.Is(err, sql.ErrNoRows):
return entryDTO{}, internal(err)
}
if _, err := s.ev.ExecContext(ctx, `UPDATE entries SET squad = ? WHERE id = ?`, squad, entryID); err != nil {
// 预检与 UPDATE 之间不是原子的(两个录入员同时把两条报名改成同一个队名,
// 或导入正好在这两步之间落库):真撞上唯一索引时也要翻成同一句人话。
if isUniqueViolation(err) {
return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil)
}
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, entryID)
}
// squadTakenMessage 是「这个队名在本组别已经被同单位占了」的现场文案。
//
// 队名非空那一档导入侧也在用(importedEntryConflictError 的并发分支):两处说的若不是
// 同一句话,操作员在导入里学到的那句到了改队名页面就不成立,只能重新猜系统在拦什么。
//
// 队名留空那一档与导入侧**故意不同**:那边点的是报名表里的「队伍列」,这里没有那一格可点,
// 只能点「队名留空」。其余口径两边一致:空 = 不区分队伍,一个单位在一个组别只能有一条。
func squadTakenMessage(teamCode, squad string) string {
if squad == "" {
return fmt.Sprintf("单位 %s 在本组别已经有一条不分队的团体报名:队名留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请把两支队分别写清队名(例如 一队 / 二队)", teamCode)
}
return fmt.Sprintf("单位 %s 在本组别已经有一条队伍「%s」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队)", teamCode, squad)
}
// updateEntrySquadHandler 是 PUT /api/entries/{id}/squad:改一条团体报名的队名,
// 成功后返回这条报名的完整读数(与份额那条接口一样,前端拿它直接刷新那一行)。
//
// 权限 / CSRF 与 PUT /api/entries/{id}/team-shares 同档:它改的也是报名。
func (s *server) updateEntrySquadHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in entrySquadPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
before, err := s.getEntry(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEntrySquad(r.Context(), id, in.Squad)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.squad.update", "entry", id,
map[string]any{"squad": before.Squad}, map[string]any{"squad": updated.Squad})
return updated, nil
})
}
@@ -0,0 +1,325 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
)
// 手工改队名:PUT /api/entries/{id}/squad。
//
// 为什么要有这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。
// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**,老那条还挂在库里——
// 现场「填错一个字就得删掉重建」是不能接受的。
//
// 口径(迁移 0025):队名是自由文本、首尾空白 trim 掉;空串 = 不区分队伍(合法值,
// 不是「把这条报名清掉」);只对纯团体报名(athlete_id 为空)有意义。
// updateSquad 调一次改队名接口。
func updateSquad(t *testing.T, env *testEnv, client *http.Client, entryID int64, squad string, csrf bool) (int, envelope) {
t.Helper()
return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/squad", entryID),
map[string]any{"squad": squad}, csrf)
}
// squadDumpThree 是「A 一队 / A 二队 / B 不分队」三条团体报名的落库事实。
const squadDumpThree = "team_code=A squad=一队 \nteam_code=A squad=二队 \nteam_code=B squad= \n"
// 现场场景:A 的「二队」填错了一个字,改成「青年队」——改的是**原来那一条**,
// 不是新增第二支队,老那条也不该留下。
func TestUpdateEntrySquadRenamesInPlace(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
if got := squadDump(t, env, eventID); got != squadDumpThree {
t.Fatalf("前置条件不成立:\n得到 %s期望 %s", got, squadDumpThree)
}
entryID := entryIDOfSquad(t, env, eventID, "二队")
status, resp = updateSquad(t, env, client, entryID, "青年队", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改队名应 200,得到 %d %+v", status, resp.Error)
}
// 返回更新后的完整读数(前端拿它直接刷新那一行)。
updated := decodeData[map[string]any](t, resp)
if updated["id"] != float64(entryID) {
t.Fatalf("回的应是原来那一条报名 %d,得到 %+v", entryID, updated["id"])
}
if updated["squad"] != "青年队" {
t.Fatalf("回显的队名 = %v,期望 青年队", updated["squad"])
}
if updated["teamCode"] != "A" || updated["teamName"] != "第一分公司" {
t.Fatalf("回显应带单位编码与名称(前端要显示「A 青年队」),得到 %+v", updated)
}
if updated["memberNames"] == nil {
t.Fatalf("回显应是完整读数(含队员名单),得到 %+v", updated)
}
// 落库:还是 3 条,队名换了,没有多出第四支队伍。
want := "team_code=A squad=一队 \nteam_code=A squad=青年队 \nteam_code=B squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("改完队名落库不对:\n得到 %s期望 %s", got, want)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 3 {
t.Fatalf("改队名不该新增报名,得到 %d 条", n)
}
// 报名列表(录入页 / 大屏读的就是它)要带上新队名。
board := getEntryBoard(t, env, client, eventID)
found := false
for _, entry := range board.Entries {
if entry["id"] == float64(entryID) {
found = true
if entry["squad"] != "青年队" {
t.Fatalf("报名列表里的队名 = %v,期望 青年队", entry["squad"])
}
}
}
if !found {
t.Fatalf("报名列表里找不到报名 %d", entryID)
}
// 审计:照份额那套记前后值,事后对账能看出改过哪一格。
// waitAudit 取的是 after_json,改动前的队名在 before_json 里(同一行,等它落库后再读)。
after := waitAudit(t, env, "entry.squad.update", entryID)
if !strings.Contains(after, "青年队") {
t.Fatalf("审计的 after 应记下新队名,得到 %s", after)
}
var before sql.NullString
if err := env.ev.QueryRow(`SELECT before_json FROM audit_logs WHERE action = ? AND entity_id = ?`,
"entry.squad.update", entryID).Scan(&before); err != nil {
t.Fatalf("读审计的 before: %v", err)
}
if !strings.Contains(before.String, "二队") {
t.Fatalf("审计的 before 应记下原队名,得到 %s", before.String)
}
}
// 队名首尾空白一律 trim(导入侧读单元格时已经 trim 过,手工改走的是另一条路);
// 只留空白等于空串 = **不区分队伍**,是合法值,不是「把这条报名清掉」。
func TestUpdateEntrySquadTrimsAndAcceptsBlank(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
two := entryIDOfSquad(t, env, eventID, "二队")
status, resp = updateSquad(t, env, client, two, " 青年队 ", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("队名带空白应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["squad"]; got != "青年队" {
t.Fatalf("队名应 trim 首尾空白(含全角空格),得到 %q", got)
}
want := "team_code=A squad=一队 \nteam_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("trim 后落库不对:\n得到 %s期望 %s", got, want)
}
// 只留空白 → 空串(不区分队伍):报名还在,只是这条不再挂队名。
status, resp = updateSquad(t, env, client, two, " ", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("清空队名应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["squad"]; got != "" {
t.Fatalf("只留空白的队名应落成空串,得到 %q", got)
}
want = "team_code=A squad=一队 \nteam_code=A squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("清空队名后落库不对:\n得到 %s期望 %s", got, want)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 2 {
t.Fatalf("空队名是「不区分队伍」,不是删报名:应仍是 2 条,得到 %d 条", n)
}
}
// 拦下的请求必须给人话,且一个字节都不落库。
func TestUpdateEntrySquadRejectsBadPayloads(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 第一个小项:A 报了「一队」与不分队各一条,B 也有一条「一队」。
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "一队", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
baseline := squadDump(t, env, eventID)
soloEntry := entryIDOfSquad(t, env, eventID, "一队")
// 第二个小项:A 只有「一队」与「二队」两条,没有不分队那条——用来试「改成不分队会不会撞」。
soloEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'VOL2', '排球', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, soloEvent)
if status, resp := importCSVWithHeader(t, env, client, "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names,squad",
squadImportRow("VOL2", "男子", "A", "一队", "张伟/李强"),
squadImportRow("VOL2", "男子", "A", "二队", "王五/赵六")); status != http.StatusOK || !resp.OK {
t.Fatalf("第二个小项导入失败:%d %+v", status, resp.Error)
}
twoEntry := entryIDOfSquad(t, env, soloEvent, "二队")
cases := []struct {
name string
id int64
squad string
csrf bool
status int
wantMsg string
}{
{
name: "撞上同单位同组别的另一个队", id: twoEntry, squad: "一队", csrf: true,
status: http.StatusBadRequest,
wantMsg: "单位 A 在本组别已经有一条队伍「一队」的报名",
},
{
name: "改成不分队,但已经有一条不分队的", id: soloEntry, squad: " ", csrf: true,
status: http.StatusBadRequest,
wantMsg: "单位 A 在本组别已经有一条不分队的团体报名",
},
{
// 队名只对纯团体报名生效:带运动员的报名按 (组别, 单位, 运动员) 唯一。
name: "带运动员的报名", id: ids.entryB, squad: "一队", csrf: true,
status: http.StatusBadRequest,
wantMsg: "队名只对团体报名生效",
},
{
name: "报名不存在", id: 999999, squad: "一队", csrf: true,
status: http.StatusNotFound, wantMsg: "报名项不存在",
},
{
// 与 PUT /api/entries/{id}/team-shares 同档:改动报名必须带 CSRF。
name: "缺 CSRF", id: twoEntry, squad: "青年队", csrf: false,
status: http.StatusForbidden, wantMsg: "CSRF 校验失败",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, resp := updateSquad(t, env, client, tc.id, tc.squad, tc.csrf)
if status != tc.status {
t.Fatalf("应 %d,得到 %d %+v", tc.status, status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.wantMsg) {
t.Fatalf("报错应含 %q,得到 %+v", tc.wantMsg, resp.Error)
}
// 报错里不该出现 SQLite 的英文原文:现场读不懂,照着它也找不到该改哪一格。
if strings.Contains(resp.Error.Message, "UNIQUE constraint failed") {
t.Fatalf("不该把 SQLite 原文丢给现场:%s", resp.Error.Message)
}
})
}
if got := squadDump(t, env, eventID); got != baseline {
t.Fatalf("被拒的请求改动了报名表:\n之前 %s之后 %s", baseline, got)
}
wantSolo := "team_code=A squad=一队 \nteam_code=A squad=二队 \n"
if got := squadDump(t, env, soloEvent); got != wantSolo {
t.Fatalf("被拒的请求改动了第二个小项的报名表:\n得到 %s期望 %s", got, wantSolo)
}
}
// 未登录一样进不来(与其它报名写接口同档)。
func TestUpdateEntrySquadRequiresLogin(t *testing.T) {
env, _, client := recorderWithSeed(t)
anon, _ := env.client()
if status, resp := updateSquad(t, env, anon, 1, "一队", false); status != http.StatusUnauthorized {
t.Fatalf("未登录改队名应 401,得到 %d %+v", status, resp)
}
// 登录了但这条报名不存在 → 404(照本仓既有风格)。
if status, resp := updateSquad(t, env, client, 999999, "一队", true); status != http.StatusNotFound {
t.Fatalf("改不存在的报名应 404,得到 %d %+v", status, resp)
}
}
// 同一个 requestId 重复提交回放首次结果,不重复执行(与其它写接口同一套幂等)。
func TestUpdateEntrySquadIdempotentByRequestID(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "二队", "张伟/李强"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := entryIDOfSquad(t, env, eventID, "二队")
path := fmt.Sprintf("/api/entries/%d/squad", entryID)
if status, resp := env.do(client, http.MethodPut, path,
map[string]any{"squad": "青年队", "requestId": "squad-1"}, true); status != http.StatusOK {
t.Fatalf("第一次改队名应 200,得到 %d %+v", status, resp.Error)
}
// 同一个 requestId、换一个队名再来一次:必须是回放,库里仍是第一次的结果。
status, resp = env.do(client, http.MethodPut, path,
map[string]any{"squad": "少年队", "requestId": "squad-1"}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("重复提交应回放首次结果,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["squad"]; got != "青年队" {
t.Fatalf("回放的应是首次结果(青年队),得到 %v", got)
}
want := "team_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("重复提交不该再改一次库:\n得到 %s期望 %s", got, want)
}
}
// 队名只是显示用的标签:改名前后**一切算分输出**逐字节相同。
//
// 这是「改队名不重算」这条决定的正面证据:积分流水、成绩名次、积分榜上的总分与金银铜
// 一个都不该动。真要动了,说明队名混进了算分口径,那才是 bug。
func TestUpdateEntrySquadDoesNotChangeScoring(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "squad",
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"},
)
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
// 算分输出:积分流水(含作废标记)与成绩名次,逐字节对拍。
scoring := func() string {
return tableDump(t, env, `SELECT id, entry_id, team_id, points, voided FROM score_ledger ORDER BY id`) +
tableDump(t, env, `SELECT entry_id, COALESCE(rank, 0), state FROM results ORDER BY entry_id`)
}
before := scoring()
status, resp = updateSquad(t, env, client, entryIDOfSquad(t, env, eventID, "二队"), "青年队", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改队名应 200,得到 %d %+v", status, resp.Error)
}
if after := scoring(); after != before {
t.Fatalf("改队名动了算分输出:\n之前 %s之后 %s", before, after)
}
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
}
@@ -0,0 +1,93 @@
package httpapi
import (
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 撞上唯一索引时给现场的必须是人话:SQLite 的原文是
// 「UNIQUE constraint failed: entries.category_id, entries.team_id, entries.squad」,
// 里面没有单位、没有队伍,操作员照着它找不出该改哪一格。
//
// 这条分支只有**并发导入同一份名单**(或库里那条报名正好在查重与落库之间建好)才会走到,
// HTTP 层造不稳定,所以在函数级别钉住文案:三种情形(同一个队 / 不分队 / 同一个人)
// 说的都得是「哪个单位、哪个队、怎么办」。
func TestImportedEntryConflictErrorMessage(t *testing.T) {
db := openSquadTestDB(t)
s := &server{ev: db}
unique := errors.New("UNIQUE constraint failed: entries.category_id, entries.team_id, entries.squad")
athleteID := int64(7)
cases := []struct {
name string
in entryDTO
want []string
}{
{
name: "同一个单位的同一个队",
in: entryDTO{TeamID: 1, Squad: "一队"},
want: []string{"单位 A", "队伍「一队」", "同一个单位同一个组别的同一个队", "二队"},
},
{
name: "同一个单位的不分队报名",
in: entryDTO{TeamID: 1},
want: []string{"单位 A", "不分队", "队伍列留空", "一队 / 二队"},
},
{
name: "同一个人在同一组别",
in: entryDTO{TeamID: 1, AthleteID: &athleteID, Squad: "一队"},
want: []string{"单位 A", "同一个人在同一组别只能报一条"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := s.importedEntryConflictError(t.Context(), tc.in, unique)
if err == nil {
t.Fatal("唯一键冲突应被翻译成人话,得到 nil")
}
msg := err.Error()
for _, want := range tc.want {
if !strings.Contains(msg, want) {
t.Errorf("报错里少了 %q:%s", want, msg)
}
}
// 英文的约束原文留给日志,不该出现在给现场看的这一句里。
if strings.Contains(msg, "UNIQUE constraint failed") {
t.Errorf("报错不该把 SQLite 原文丢给现场:%s", msg)
}
})
}
// 不是唯一键冲突的错误(外键、CHECK、锁超时)原样返回:
// 把它们说成「重复报名」会把排查方向带偏。
other := errors.New("FOREIGN KEY constraint failed")
if got := s.importedEntryConflictError(t.Context(), entryDTO{TeamID: 1}, other); got != other {
t.Fatalf("非唯一键冲突应原样返回,得到 %v", got)
}
}
// openSquadTestDB 开一个空的赛事库(跑完全部迁移),造一个单位 A 供报错文案使用。
func openSquadTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := "file:" + filepath.Join(t.TempDir(), "event.db") + "?_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`); err != nil {
t.Fatalf("造单位: %v", err)
}
return db
}
+331
View File
@@ -0,0 +1,331 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 队伍(squad):同一个单位在同一个组别可以派多个队参赛(羽毛球双打那种:一个单位两个队,
// 两个队各占一个名次)。
//
// 口径(迁移 0025):
// - squad 是自由文本(一队 / 二队 / 青年队 都能写),**空串 = 不区分队伍**——老数据与单队单位的
// 默认值,行为与升级前一模一样(一个单位在这个组别一条团体报名);
// - 只对**纯团体报名**(athlete_id 为空)放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,
// 同一个人在同一组别不会因为这次改动而报两次;
// - 一个单位两个队都进前三就是 2 枚牌,引擎本来就按报名逐条记牌,不走特判。
// importHeaderSquad 是带「队伍」列的表头:新列**加在最后**,既有表格一列不错位。
const importHeaderSquad = importHeader + ",squad"
// importHeaderSquadZH 是中文别名表头(队伍 = importColumns 里 squad 的首选别名)。
const importHeaderSquadZH = importHeaderZH + ",队伍"
// squadImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeaderSquad 一致。
func squadImportRow(eventCode, category, teamCode, squad, members string) string {
return strings.Join([]string{eventCode, category, teamCode, "",
"", "", "", "", "", "registered", members, squad}, ",")
}
// importCSVWithHeader 用指定表头上传一份单表 CSV。
func importCSVWithHeader(t *testing.T, env *testEnv, client *http.Client, header string, rows ...string) (int, envelope) {
t.Helper()
content := header + "\n" + strings.Join(rows, "\n") + "\n"
return env.postImport(t, client, "/api/entries/import", content, "")
}
// seedDoublesEvent 建一个「羽毛球双打」形态的小项:team 类型 + 按成绩排名的团体通道
// (团体报名项各录各的成绩、各占各的名次),返回小项与组别 id。
func seedDoublesEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
return eventID, categoryID
}
// squadDump 导出某小项「单位 → 队伍」的落库事实(按报名 id 排序,稳定可比)。
// 刻意不带报名 id:id 由种子数据占位,断言里写死 id 只会让用例变脆。
func squadDump(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT t.code AS team_code, e.squad
FROM entries e JOIN teams t ON t.id = e.team_id
WHERE e.event_id = %d ORDER BY e.id`, eventID))
}
// 现场场景:一个单位派两个队报名。升级前第二条会被唯一索引拦住(一条团体报名),
// 现在两条都要落库、各自有名次、各自拿牌。
func TestImportSquadCreatesSecondTeamOfSameUnit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if len(result.Errors) != 0 {
t.Fatalf("不该有报错行:%v", result.Errors)
}
if result.Created != 3 {
t.Fatalf("一个单位两个队应是 3 条报名(A 两条 + B 一条),得到 created=%d updated=%d:%s",
result.Created, result.Updated, squadDump(t, env, eventID))
}
// 落库:两个队的 squad 各自留着,空串那条就是老口径「不区分队伍」。
rows, err := env.ev.Query(`SELECT t.code, e.squad FROM entries e JOIN teams t ON t.id = e.team_id
WHERE e.event_id = ? ORDER BY e.id`, eventID)
if err != nil {
t.Fatalf("读报名: %v", err)
}
defer func() { _ = rows.Close() }()
var got []string
for rows.Next() {
var code, squad string
if err := rows.Scan(&code, &squad); err != nil {
t.Fatalf("扫描报名: %v", err)
}
got = append(got, code+":"+squad)
}
want := "A:一队 A:二队 B:"
if joined := strings.Join(got, " "); joined != want {
t.Fatalf("落库的队伍不对:\n得到 %s\n期望 %s", joined, want)
}
// 名次:一队第 1、二队第 2、B 第 3 —— 两个队都进前三,A 拿 2 枚牌(金 1 银 1)。
publishResults(t, env, client, eventID, "squad",
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"},
)
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
// 报名列表要把队伍带出来(前端据此区分「A 一队 / A 二队」)。
board := getEntryBoard(t, env, client, eventID)
byID := map[float64]string{}
for _, entry := range board.Entries {
id, _ := entry["id"].(float64)
squad, _ := entry["squad"].(string)
byID[id] = squad
}
for _, want := range []struct {
squad string
}{{"一队"}, {"二队"}, {""}} {
id := float64(entryIDOfSquad(t, env, eventID, want.squad))
if byID[id] != want.squad {
t.Errorf("报名 %v 的队伍 = %q,期望 %q", id, byID[id], want.squad)
}
}
}
// 老表零变化:表里没有队伍列(或整列留空)时,一个单位在一个组别仍然只有一条团体报名——
// 第二条是**更新**那一条,不是新增(升级前的行为一字不改)。
func TestImportSquadKeepsLegacyBehaviourWhenColumnEmpty(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
// 先走升级前的老表头(连队伍列都没有)。
status, resp := importCSV(t, env, client, jointImportRow("BADM", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("不带队伍列的老表应照常导入,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("老表应新建 1 条:%+v", result)
}
// 同一份老表再传一次:更新那一条,不新增(老行为)。
status, resp = importCSV(t, env, client, jointImportRow("BADM", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("老表重传应照常更新,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 0 || result.Updated != 1 || result.Skipped != 0 {
t.Fatalf("同一份老表重传应是「新建 0 / 更新 1」,得到 %+v", result)
}
// 带队伍列的表格、但那一格留空:同样是不区分队伍的老口径(B 与 A 各一条)。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "B", "", "孙七/周八"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("队伍留空应照常导入,得到 %d %+v", status, resp.Error)
}
want := "team_code=A squad= \nteam_code=B squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("老口径落库不对:\n得到 %s期望 %s", got, want)
}
}
// 同一个队重复导入是幂等的(后一行覆盖前一行);换一个队伍名才是第二个队。
func TestImportSquadSameTeamTwiceIsIdempotent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强/王五"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 {
t.Fatalf("同一个队重传应是「新建 1 / 更新 1」(后一行覆盖前一行),得到 %+v", result)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 {
t.Fatalf("同一个队重传后库里应只有 1 条报名,得到 %d 条:%s", got, squadDump(t, env, eventID))
}
// 后一行覆盖前一行:队员名单以最后一行写的为准。
if got := tableDump(t, env, fmt.Sprintf(`SELECT member_names FROM entries WHERE event_id = %d`, eventID)); !strings.Contains(got, "王五") {
t.Fatalf("后一行应覆盖前一行的队员名单,得到 %s", got)
}
// 换一个队伍名 = 第二个队:新增一条。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "二队", "李四/赵六"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入第二个队应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("第二个队应新增 1 条:%+v", result)
}
}
// 中文别名:队伍 / 队名 / 队伍名 都认(现场拿到的是中文模板)。
func TestImportSquadChineseAliases(t *testing.T) {
for _, alias := range []string{"队伍", "队名", "队伍名"} {
t.Run(alias, func(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
header := strings.TrimSuffix(importHeaderSquadZH, "队伍") + alias
status, resp := importCSVWithHeader(t, env, client, header,
squadImportRow("BADM", "男子", "A", "青年队", "张伟/李强"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("中文表头 %s 应能导入,得到 %d %+v", alias, status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("应新建 1 条:%+v", result)
}
want := "team_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("中文表头落库不对:\n得到 %s期望 %s", got, want)
}
})
}
}
// 带运动员的报名不受队伍影响:同一个人在同一组别写两个队伍名,仍是同一条报名(不会报两次),
// 并且要有一条 notices 说清「队伍没写进库里」——那一格填了就不该悄悄消失。
func TestImportSquadIgnoredForAthleteRows(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 另开一个个人小项:种子里的 M100 有两条报名,掺进来没法一眼看清新增了几条。
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
athleteRow := func(squad string) string {
return strings.Join([]string{"M200", "男子", "A", "张三", "110101199001011234", "男", "26",
"101", "", "registered", "", squad}, ",")
}
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("一队"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 {
t.Fatalf("个人行应新建 1 条:%+v", result)
}
if joined := strings.Join(result.Notices, ";"); !strings.Contains(joined, "队伍只对团体报名") {
t.Fatalf("个人行填了队伍要给一条提示,得到 %v", result.Notices)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND squad <> ''`, eventID); got != 0 {
t.Fatalf("个人报名的 squad 必须是空串,得到 %d 条非空", got)
}
// 同一个人换个队伍名重传:还是同一条(同一个人在同一组别不会因为队伍报两次)。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("二队"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error)
}
result = decodeData[entryImportResultBody](t, resp)
if result.Created != 0 || result.Updated != 1 {
t.Fatalf("同一个人换个队伍名重传应仍是那一条(新建 0 / 更新 1),得到 %+v", result)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 {
t.Fatalf("同一个人在同一组别应只有 1 条报名,得到 %d 条", got)
}
}
// 模板要在最后多一列「队伍 / squad」:新列追加在表尾,现场手里少一列的旧表格一列都不会错位。
func TestEntriesTemplatePutsSquadLast(t *testing.T) {
env, _, client := recorderWithSeed(t)
for _, tc := range []struct{ lang, want string }{{"zh", "队伍"}, {"en", "squad"}} {
f, _ := env.fetchTemplate(t, client, tc.lang)
rows, err := f.GetRows(f.GetSheetList()[0])
if err != nil {
t.Fatalf("[%s] 读模板: %v", tc.lang, err)
}
header := rows[0]
if len(header) != 13 {
t.Fatalf("[%s] 模板表头应是 13 列(原 12 列 + 队伍),得到 %d 列:%v", tc.lang, len(header), header)
}
if header[len(header)-1] != tc.want {
t.Fatalf("[%s] 队伍列必须在最后一列(%q),得到 %q", tc.lang, tc.want, header[len(header)-1])
}
}
}
// 老模板(12 列,没有队伍列)照常导入:这是「新列加在最后」的全部意义——
// 现场手里那些列少的表格一列都不会错位,导入结果与升级前完全一样。
func TestImportSheetTemplateWithoutSquadColumnStillWorks(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
const legacyHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
data := buildTestXLSX(t, []testSheet{
{name: "羽毛球双打@男子", rows: [][]string{
strings.Split(legacyHeader, ","),
{"", "", "A", "", "", "", "", "301", "", "1", "registered", "张伟/李强"},
}},
})
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
if status != http.StatusOK || !resp.OK {
t.Fatalf("老模板应照常导入,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("应新建 1 条且无报错:%+v", result)
}
want := "team_code=A squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("老模板落库的队伍应是空串(不区分队伍):\n得到 %s期望 %s", got, want)
}
}
// entryIDOfSquad 取某小项里某个队的报名 id(空串 = 不区分队伍的那条)。
func entryIDOfSquad(t *testing.T, env *testEnv, eventID int64, squad string) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND squad = ? ORDER BY id LIMIT 1`,
eventID, squad).Scan(&id); err != nil {
t.Fatalf("读队伍 %q 的报名: %v", squad, err)
}
return id
}
@@ -1048,6 +1048,9 @@ var importColumns = []importColumn{
{name: "heat", aliases: []string{"批次", "批次号"}, optional: true}, {name: "heat", aliases: []string{"批次", "批次号"}, optional: true},
{name: "status", aliases: []string{"状态", "报名状态"}}, {name: "status", aliases: []string{"状态", "报名状态"}},
{name: "member_names", aliases: []string{"队员名单"}}, {name: "member_names", aliases: []string{"队员名单"}},
// squad(队伍)挂在**最后**:新列追加在表尾,现场手里那些少一列的旧表格一列都不会错位。
// 空串 = 不区分队伍,与老口径一致;同一个单位在同一个组别派两个队就靠它区分(迁移 0025)。
{name: "squad", aliases: []string{"队伍", "队名", "队伍名"}, optional: true},
} }
// label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列, // label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列,
@@ -1228,6 +1231,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
categoryName := cell("category") categoryName := cell("category")
teamCode := cell("team_code") teamCode := cell("team_code")
athleteName := cell("athlete_name") athleteName := cell("athlete_name")
// 队伍是自由文本(一队 / 二队 / 青年队),不做任何归一:现场怎么写就怎么存,
// 「一队」与「一 队」是两个队(系统没有立场替现场合并)。
squad := cell("squad")
if teamCode == "" || (table.scope == nil && eventCode == "") { if teamCode == "" || (table.scope == nil && eventCode == "") {
result.Skipped++ result.Skipped++
if table.scope == nil { if table.scope == nil {
@@ -1432,11 +1438,21 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
teamShares = nil teamShares = nil
} }
// 队伍只对**纯团体报名**(这一行没填姓名)生效。个人报名按 (组别, 单位, 运动员) 唯一,
// squad 不参与那条判断——填了就写不进库,所以要说一句,不能悄悄丢掉这一格。
if athleteID != nil && squad != "" {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行填了队伍「%s」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里",
line, squad))
squad = ""
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{ outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID, EventID: eventID,
CategoryID: categoryID, CategoryID: categoryID,
TeamID: teamID, TeamID: teamID,
TeamShares: teamShares, TeamShares: teamShares,
Squad: squad,
AthleteID: athleteID, AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")), MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(bib), Bib: nilIfEmpty(bib),
@@ -10,7 +10,7 @@ import (
// 积分平均分、奖牌两边各记一枚。 // 积分平均分、奖牌两边各记一枚。
// //
// 说明只能挂在「不会变成数据」的载体上:表头行是导入器认模板的锚点(必须留在第 1 行、 // 说明只能挂在「不会变成数据」的载体上:表头行是导入器认模板的锚点(必须留在第 1 行、
// 12 列一成不变),工作表名是「小项@组别」的范围锚点(也不能多出说明表), // 列名与 importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出说明表),
// 所以这里用**单元格批注**(表头「单位编码」那一格)+ **该列的输入提示**(选中即弹出)。 // 所以这里用**单元格批注**(表头「单位编码」那一格)+ **该列的输入提示**(选中即弹出)。
func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) { func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) {
env, _, client := recorderWithSeed(t) env, _, client := recorderWithSeed(t)
+8
View File
@@ -53,6 +53,14 @@ type entryTeamSharesPayload struct {
RequestID string `json:"requestId"` RequestID string `json:"requestId"`
} }
// entrySquadPayload 是 PUT /api/entries/{id}/squad 的请求体:改一条团体报名的队名。
// 队名是自由文本,服务端只 trim 首尾空白;空串(或只有空白)是**合法值**,
// 表示「不区分队伍」,不是「清掉这条报名」(见迁移 0025 与 updateEntrySquad)。
type entrySquadPayload struct {
Squad string `json:"squad"`
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体: // entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名), // action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。 // ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
+24 -3
View File
@@ -712,10 +712,29 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
// 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时 // 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时
// 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。 // 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil { event, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return nil, err return nil, err
} }
// compare=none 的小项排不出名次(名次靠人工录入),把 basis=result / aggregate 的通道存进去,
// 要等到发布时才会被 domain.ScoreEvent 以 ErrCompareRequired 拒绝(409 STATE_INVALID)——
// 那时成绩已经录了一半,回退代价高。规则包导入侧早有这条守卫
// (rules/diff.go checkCompareCompatible),手工在管理端配的这条路一直没人拦,在这里补上:
// 保存前整包拒收,一个字节都不写,并把「哪条通道、为什么」说清楚。
if event.Compare == string(domain.CompareNone) {
for i, c := range in {
if c.Basis != string(domain.BasisResult) && c.Basis != string(domain.BasisAggregate) {
continue
}
return nil, badRequest(fmt.Sprintf(
"小项「%s」的比较方向是「不比较」(compare=none),排不出名次:第 %d 条通道的 basis=%s 要按成绩排名,"+
"服务端算不出分、发布会失败。请把它改成「手动录名次」(basis=manual),"+
"或先给小项设置比较方向(compare=asc/desc)",
event.Name, i+1, c.Basis), map[string]any{"index": i, "basis": c.Basis, "compare": event.Compare})
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err) return nil, internal(err)
} }
@@ -777,8 +796,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
// ---------- entries ---------- // ---------- entries ----------
// squad 挂在最后:列的追加不该动到既有列的下标(scanEntry 按位置扫,加在中间会整列错位)。
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name` e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name, e.squad`
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
var ( var (
@@ -794,7 +814,8 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
categoryName sql.NullString categoryName sql.NullString
) )
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil { &bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName,
&e.Squad); err != nil {
return e, err return e, err
} }
e.AthleteID = i64Ptr(athleteID) e.AthleteID = i64Ptr(athleteID)
+52 -6
View File
@@ -646,6 +646,10 @@ func staleScoredEntryError(item staleScopeEntry) error {
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。 // upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
// //
// 纯团体报名(athlete_id 为空)另加**队伍**这一段:同一个单位在同一个组别可以派多个队参赛
// (迁移 0025),所以查重键是「组别 + 单位 + 队伍」,队伍为空串就是「不区分队伍」的老口径。
// 带运动员的报名照旧只认 (组别, 单位, 运动员):同一个人在同一组别不会因为队伍多报一次。
//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把 // 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。 // 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
// //
@@ -663,8 +667,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus) in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus)
} else { } else {
err = s.ev.QueryRowContext(ctx, err = s.ev.QueryRowContext(ctx,
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`, `SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ?`,
in.CategoryID, in.TeamID).Scan(&existing, &oldStatus) in.CategoryID, in.TeamID, entrySquad(in)).Scan(&existing, &oldStatus)
} }
status := orDefault(in.Status, "registered") status := orDefault(in.Status, "registered")
@@ -675,12 +679,12 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
return entryImportOutcome{}, err return entryImportOutcome{}, err
} }
res, err := s.ev.ExecContext(ctx, res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status) `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, squad)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status) nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, entrySquad(in))
if err != nil { if err != nil {
return entryImportOutcome{}, err return entryImportOutcome{}, s.importedEntryConflictError(ctx, in, err)
} }
newID, err := res.LastInsertId() newID, err := res.LastInsertId()
if err != nil { if err != nil {
@@ -726,6 +730,48 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
} }
} }
// entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。
//
// 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串,
// 两件事同时成立:同一个人换个队伍写法重传仍是同一条(UPDATE,不会撞唯一键),
// 库里也不会出现「个人报名带着一队 / 二队」这种没人会去看的字段值。
func entrySquad(in entryDTO) string {
if in.AthleteID != nil {
return ""
}
return in.Squad
}
// importedEntryConflictError 把 INSERT 撞上唯一索引翻译成人话。
//
// 索引 idx_entries_team_squad_unique 的键是 (组别, 单位, 队伍) WHERE athlete_id IS NULL;
// 上面的查重与这次 INSERT 不是原子的,两个人同时导同一份名单、或库里那条报名正好在这两步之间
// 落库,就会撞上它。SQLite 原文是「UNIQUE constraint failed: entries.category_id,
// entries.team_id, entries.squad」——现场看不出是哪个单位、哪个队,也不知道该改哪一格,
// 所以在这里换成点名单位与队伍的一句话。
//
// 非唯一键冲突(外键、CHECK、锁超时)原样返回:那些不是「重复报名」,换成重复报名的说法会把人带偏。
func (s *server) importedEntryConflictError(ctx context.Context, in entryDTO, err error) error {
if !isUniqueViolation(err) {
return err
}
teamCode, codeErr := s.teamCodeByID(ctx, in.TeamID)
if codeErr != nil {
// 连单位编码都查不出来时宁可回原始错误:猜一句「单位 X 重复」会把排查方向带错。
return err
}
if in.AthleteID != nil {
return fmt.Errorf("单位 %s 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入", teamCode)
}
if in.Squad == "" {
return fmt.Errorf("单位 %s 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;"+
"要派两个队请在队伍列分别写清(例如 一队 / 二队)", teamCode)
}
// 队名非空那一句与「手工改队名」接口共用(entry_squad.go):两处说的若不是同一句话,
// 操作员在导入里学到的那句到了改队名页面就不成立。
return errors.New(squadTakenMessage(teamCode, in.Squad))
}
// recomputeImportedScopes 重算报名导入动过的 (小项, 组别),并推小项与榜单。 // recomputeImportedScopes 重算报名导入动过的 (小项, 组别),并推小项与榜单。
// //
// 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径: // 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径:
+3
View File
@@ -279,6 +279,9 @@ func (s *server) routes(mux *http.ServeMux) {
// 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。 // 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。
// 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。 // 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。
mux.HandleFunc("PUT /api/entries/{id}/team-shares", s.write(auth.RoleRecorder, s.updateEntryTeamSharesHandler)) mux.HandleFunc("PUT /api/entries/{id}/team-shares", s.write(auth.RoleRecorder, s.updateEntryTeamSharesHandler))
// 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。
// 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。
mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler))
// 成绩 // 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
@@ -0,0 +1,82 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 现场那份数据的服务端侧:台球(纯团体、名次靠人工录入)六个单位的名次只填在录入页底部的
// 「团体名次」卡片里(channel_manual_ranks),服务端要按规则包 team_top6 算出 15/10/8/4/2/1,
// 六个单位合计 40 分。
//
// 与 web/src/admin/tests/scoring-team-manual.test.ts 的第一个用例同输入同期望:录入页的实时预览
// 曾只读行上的 rank,这份数据在预览里显示成每个单位 0 分,而服务端一直算的是 40 分。
func TestTeamManualRanksSixUnitsProduce40Points(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'BALL', '台球', 'team', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport)
categoryID := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '混合组', 1, 0)`, eventID)
addTeamManualChannel(t, env, eventID)
// 六个参赛单位:名次不挂报名项,单位本身就是主体。
units := make([]int64, 0, 6)
ranks := make([]map[string]any, 0, 6)
for i := 1; i <= 6; i++ {
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active)
VALUES (?, ?, ?, ?, 1)`, fmt.Sprintf("T%d", i), fmt.Sprintf("第 %d 单位", i), fmt.Sprintf("T%d", i), i)
units = append(units, teamID)
ranks = append(ranks, map[string]any{"teamId": teamID, "rank": i})
}
status, resp := putTeamRanks(t, env, client, eventID, categoryID, ranks)
if status != http.StatusOK || !resp.OK {
t.Fatalf("写六个单位的团体名次应 200,得到 %d %+v", status, resp.Error)
}
got := pointsByTeamDB(t, env, eventID)
want := []float64{15, 10, 8, 4, 2, 1}
total := 0.0
for i, teamID := range units {
total += got[teamID]
if got[teamID] != want[i] {
t.Fatalf("单位 %d(第 %d 名)= %v,期望 %v(全部:%v)", teamID, i+1, got[teamID], want[i], got)
}
}
if total != 40 {
t.Fatalf("六个单位合计 = %v,期望 40", total)
}
// 团体名次的分不挂任何报名项(现场成绩表里一行 rank 都没有)。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND (entry_id IS NOT NULL OR result_id IS NOT NULL)`); n != 0 {
t.Fatalf("团体名次的流水不该挂报名项,得到 %d 条", n)
}
}
// pointsByTeamDB 读某小项未作废流水里各单位的分值合计。
func pointsByTeamDB(t *testing.T, env *testEnv, eventID int64) map[int64]float64 {
t.Helper()
rows, err := env.ev.Query(`SELECT team_id, COALESCE(SUM(points), 0) FROM score_ledger
WHERE voided = 0 AND event_id = ? AND team_id IS NOT NULL GROUP BY team_id`, eventID)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]float64)
for rows.Next() {
var teamID int64
var points float64
if err := rows.Scan(&teamID, &points); err != nil {
t.Fatalf("扫流水: %v", err)
}
out[teamID] = points
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历流水: %v", err)
}
return out
}
@@ -0,0 +1,20 @@
-- 团体项目的「队伍」:同一个单位在同一组别可以派多个队参赛(例如羽毛球双打,一个单位两个队,
-- 名次可能出现「一个单位两个队各占一个名次」)。队伍的成绩、名次、积分各自记,最后都算到同一个
-- 单位的总分上(两条报名各拿各的分,单位总分自然就是两队相加)。
--
-- 为什么加一列而不是新建「队伍单位」:那样榜单会多出单位、分数也归不到原单位头上。队伍只是
-- 「这个单位在这一组别里的第几支队」,本质仍是同一个单位的报名。
--
-- squad 是**自由文本**(一队 / 二队 / 青年队 都能写),空串 = 不区分队伍 —— 这是老数据与
-- 单队单位的默认值,行为与升级前完全一致。
--
-- 只对**纯团体报名**(athlete_id IS NULL)放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,
-- 同一个运动员不会因为这次改动而在同一组别报两次。
ALTER TABLE entries ADD COLUMN squad TEXT NOT NULL DEFAULT '';
-- 老的唯一索引只看 (组别, 单位),一个单位在一个组别只能有一条团体报名 —— 正是它拦住第二个队。
-- 换成带上 squad 的版本:squad 为空的老数据在新索引下仍然「一个单位一条」,等价。
DROP INDEX IF EXISTS idx_entries_team_unique;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_team_squad_unique
ON entries(category_id, team_id, squad) WHERE athlete_id IS NULL;
@@ -0,0 +1,174 @@
package event
import (
"database/sql"
"fmt"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0025:团体项目的「队伍」(entries.squad)。
//
// 三条底线:
// - 老数据零变化:升级后既有报名的 squad 一律是空串,一个单位在一个组别仍然只能有一条
// 纯团体报名(唯一索引换成带 squad 的版本后,空串这一档的约束与升级前**等价**);
// - 只给纯团体报名放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,同一个人在同一组别
// 不会因为这次改动多报一次(表级 UNIQUE + 新的部分索引都不覆盖 athlete_id 非空的行);
// - 幂等:重复执行迁移不报错、不改动任何一行。
func TestMigration0025EntrySquad(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0025") // 回到「升级前」:库结构到 0024 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (1, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张伟', '男', 1)`,
// 团体报名(athlete_id 为空)与个人报名各一条,升级后都要一字不动。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
VALUES (1, 1, 1, NULL, '["张伟","李强"]', 'registered')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, 1, 'registered')`,
}
// 升级前:老索引拦住第二个队 —— 同一个单位在同一个组别只能有一条团体报名。
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 1, NULL, 'registered')`); err == nil {
t.Fatal("升级前同一个单位的第二条团体报名应被唯一索引拦住")
}
if _, err := db.Exec(`SELECT squad FROM entries`); err == nil {
t.Fatal("升级前不该有 squad 列")
} else if !strings.Contains(err.Error(), "squad") {
t.Fatalf("查询报错应点明缺列 squad,实际 %v", err)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 老数据零变化:既有报名一条不少、squad 一律空串。
if got := countRows(t, db, "entries"); got != 2 {
t.Fatalf("升级后报名数 = %d,期望 2", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad <> ''`); got != 0 {
t.Errorf("老数据的 squad 必须是空串(不区分队伍),得到 %d 条非空", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id = 1 AND squad <> ''`); got != 0 {
t.Errorf("老数据的 squad 必须全部是空串,得到 %d 条非空", got)
}
// 老索引没了,换成带 squad 的那条(部分索引:只管 athlete_id 为空的行)。
if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_unique'`); got != 0 {
t.Errorf("老索引 idx_entries_team_unique 应已被删除,还剩 %d 条", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_squad_unique'`); got != 1 {
t.Errorf("应存在新索引 idx_entries_team_squad_unique,得到 %d 条", got)
}
// 空串这一档的约束与升级前**等价**:同一单位再加一条不分队的团体报名仍然被拒绝。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 1, NULL, 'registered')`); err == nil {
t.Error("同一个单位同一个组别的第二条「不分队」团体报名应被拒绝(老口径不变)")
}
// 另一个单位的不分队报名照旧合法。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 2, NULL, 'registered')`); err != nil {
t.Fatalf("另一个单位的团体报名应合法: %v", err)
}
// 这就是这次改动要放开的事:同一个单位派两个队。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, NULL, 'registered', '一队')`); err != nil {
t.Fatalf("同一个单位的第二个队应能报名: %v", err)
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, NULL, 'registered', '二队')`); err != nil {
t.Fatalf("同一个单位的第三个队应能报名: %v", err)
}
// 同一个单位的同一个队仍然只能一条。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, NULL, 'registered', '一队')`); err == nil {
t.Error("同一个单位同一个组别的同一个队重复应被唯一索引拒绝")
}
// 带运动员的报名不受影响:同一个人换个单位也是同一个人,仍按 (组别, 单位, 运动员) 唯一。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 2, 1, 'registered', '一队')`); err == nil {
t.Error("同一个人在同一组别同一单位再报一次应被表级 UNIQUE 拒绝(squad 不参与个人报名)")
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, 1, 'registered', '一队')`); err != nil {
t.Fatalf("同一个人在另一个单位报名应合法: %v", err)
}
// squad 是 NOT NULL DEFAULT '':老代码(或漏写这一列的语句)插入后仍是空串,不会变成 NULL。
if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('C', '第三分公司')`); err != nil {
t.Fatalf("补一个单位: %v", err)
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 3, NULL, 'registered')`); err != nil {
t.Fatalf("漏写 squad 的插入应照常成功(默认空串): %v", err)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad IS NULL`); got != 0 {
t.Errorf("squad 不允许为 NULL,得到 %d 条", got)
}
// 幂等:再跑一次迁移,行数与内容都不变(这条 SQL 里 ADD COLUMN 不可重放,靠记账跳过)。
before := dumpEntriesSquad(t, db)
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
if got := dumpEntriesSquad(t, db); got != before {
t.Fatalf("重复迁移改动了报名:\n之前 %s\n之后 %s", before, got)
}
// 外键悬挂检查。
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("迁移后存在外键悬挂")
}
// 迁移登记数对得上(这条迁移被记账一次)。
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
}
// dumpEntriesSquad 把报名按稳定顺序导成文本(含 upgrade 后新增的队伍)。
func dumpEntriesSquad(t *testing.T, db *sql.DB) string {
t.Helper()
var b strings.Builder
rows, err := db.Query(`SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(status, ''), squad
FROM entries ORDER BY id`)
if err != nil {
t.Fatalf("读报名: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id, teamID, athleteID int64
status, squad string
)
if err := rows.Scan(&id, &teamID, &athleteID, &status, &squad); err != nil {
t.Fatalf("扫描报名: %v", err)
}
fmt.Fprintf(&b, "%d:%d:%d:%s:%s\n", id, teamID, athleteID, status, squad)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历报名: %v", err)
}
return b.String()
}
+190 -7
View File
@@ -9,6 +9,7 @@ import type {
ResultStatus, ResultStatus,
ScoreChannel, ScoreChannel,
ScoringProfile, ScoringProfile,
TeamRankEntry,
TieMode, TieMode,
} from '@/shared' } from '@/shared'
@@ -18,9 +19,29 @@ import type {
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、 * 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加, * 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。 * 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
* 「按单位录名次」的通道(scope=team + basis=manual)另有一条候选来源,
* 见 collectTeamManualCandidates——它读的是 channel_manual_ranks,不是行上的名次。
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。 * 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
*/ */
/** 通道「名次范围」的界面称谓;管理端下拉与预览报错文案共用这一份,免得同一件事两处措辞不同。 */
export const CHANNEL_SCOPE_LABELS: Record<ChannelScope, string> = {
athlete: '个人名次',
team: '团体名次',
}
/** 通道「名次来源」的界面称谓。 */
export const CHANNEL_BASIS_LABELS: Record<ChannelBasis, string> = {
result: '按成绩自动排名',
manual: '手动录名次',
aggregate: '按队内平均',
}
/** 通道在界面上的全称,如「团体名次 / 手动录名次」。 */
export function channelLabelOf(scope: ChannelScope, basis: ChannelBasis): string {
return `${CHANNEL_SCOPE_LABELS[scope]} / ${CHANNEL_BASIS_LABELS[basis]}`
}
export interface ScorableRow { export interface ScorableRow {
entryId: number entryId: number
teamId: number teamId: number
@@ -107,8 +128,11 @@ function groupPoints(
categoryMultiplier: number, categoryMultiplier: number,
): number { ): number {
if (startRank <= 0 || size <= 0) return 0 if (startRank <= 0 || size <= 0) return 0
// lastPlaceZero 覆盖「最后一名」;并列在末位的整组都按最后一名处理,保证并列同分。 // lastPlaceZero 只抹「末位那一档」,判据是起始名次**正好落在这档**(scoring.go groupPoints)。
if (profile.lastPlaceZero === true && startRank <= lastRank && lastRank <= startRank + size - 1) return 0 // 这种组必然只有一个人(startRank == lastRank 意味着 size == 1,lastRank 是所有组占位名次的
// 最大值)。判据若写成「区间跨过末位」,两人并列第 1(占 1、2,末位档 = 2)会被整组归零,
// 而服务端照发第 1 名的分——这就是现场「预览 0、服务端 10」的来历。
if (profile.lastPlaceZero === true && startRank === lastRank) return 0
const mode = tieModeOf(profile) const mode = tieModeOf(profile)
if (mode === 'average') { if (mode === 'average') {
@@ -144,6 +168,91 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C
return out return out
} }
/** 一行的归属份额是否构成联队(与 Go 的 jointTeamEntry 同一判据:至少两个单位)。 */
function isJointShares(row: ScorableRow): boolean {
return row.shares !== undefined && row.shares.length >= 2
}
/**
* 找「主单位 teamID 在本组别的联队报名」(scoring.go jointTeamEntry)。
*
* 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上,
* 所以录名次时得回头找这条报名,否则 A+B 的名次会整份记在 A 头上、B 一分没有。
* 只认团体行(athleteId 为 0):个人报名的名次是个人名次,不进团体通道。
*/
function jointTeamRow(rows: readonly ScorableRow[], teamId: number): ScorableRow | null {
let found: ScorableRow | null = null
for (const row of rows) {
if ((row.athleteId ?? 0) !== 0 || row.teamId !== teamId || !isJointShares(row)) continue
// 同一主单位出现份额不一致的两条时宁可退回独占,也不能随便挑一条把分拆错。
if (found !== null && !sameShares(found, row)) return null
found = row
}
return found
}
function sameShares(a: ScorableRow, b: ScorableRow): boolean {
const left = a.shares ?? []
const right = b.shares ?? []
if (left.length !== right.length) return false
return left.every((share, index) => share.teamId === right[index]?.teamId && share.weight === right[index]?.weight)
}
/**
* 「按单位录名次」通道(scope=team + basis=manual)的候选来源(scoring.go collectTeamManualCandidates)。
*
* 两个来源,且**只看团体行**(athleteId 为 0):
* 1. teamRanks(channel_manual_ranks,录入页底部的「团体名次」卡片):没有对应的报名项,
* 用合成行(entryId 0)承载,分直接记在单位头上;
* 2. 兜底:团体行上录的名次(results.rank),纯团体项目(篮球等)沿用原录入流程。
*
* 同一单位两个来源都有时以 teamRanks 为准;rank <= 0 视为未填 / 清空,直接忽略。
* 个人报名行(athleteId != 0)的名次绝不进团体通道——否则双通道项目第 2 次发布时会把
* 刚写回 results.rank 的个人名次当成团体名次再发一次分。
* 录入页的「团体名次」卡片此前完全没喂给预览,于是「名次填在卡片、预览全 0」成了现场的 P0。
*/
function collectTeamManualCandidates(
rows: readonly ScorableRow[],
teamRanks: readonly TeamRankEntry[],
): Candidate[] {
const overrideOf = new Map<number, number>()
const overrides: number[] = []
for (const entry of teamRanks) {
if (entry.teamId === 0 || entry.rank <= 0) continue
if (overrideOf.has(entry.teamId)) continue
overrideOf.set(entry.teamId, entry.rank)
overrides.push(entry.teamId)
}
const out: Candidate[] = []
const taken = new Set<number>()
for (const row of rows) {
if ((row.athleteId ?? 0) !== 0 || row.status !== 'ok') continue
if (row.rank === null || row.rank <= 0) continue
const rankIn = overrideOf.get(row.teamId) ?? row.rank
taken.add(row.teamId)
out.push({ row, value: 0, rankIn, subject: 0, teamId: row.teamId })
}
for (const teamId of overrides) {
if (taken.has(teamId)) continue
const rankIn = overrideOf.get(teamId) ?? 0
const joint = jointTeamRow(rows, teamId)
if (joint !== null) {
out.push({ row: joint, value: 0, rankIn, subject: 0, teamId })
continue
}
// 合成行:entryId 0 表示「不对应任何一条报名项」,团体名次的主体是单位。
out.push({
row: { entryId: 0, teamId, athleteId: 0, resultValue: null, rank: rankIn, status: 'ok', isTie: false },
value: 0,
rankIn,
subject: 0,
teamId,
})
}
return out
}
function sortByValue(cands: Candidate[], compare: CompareMode): void { function sortByValue(cands: Candidate[], compare: CompareMode): void {
cands.sort((a, b) => { cands.sort((a, b) => {
if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId
@@ -249,9 +358,11 @@ function scoreChannel(
event: Event, event: Event,
category: EventCategory | null, category: EventCategory | null,
scoreEnabled: boolean, scoreEnabled: boolean,
teamRanks: readonly TeamRankEntry[],
): RankedRow[] { ): RankedRow[] {
// compare=none 无法按值排名,退回录入名次;服务端当前会拒绝该组合(见 P1-6)。 // compare=none 的降级(照旧按录入名次)已在这里去掉:服务端对这种配置直接返回
const basis: ChannelBasis = channel.basis === 'manual' || event.compare === 'none' ? 'manual' : channel.basis // ErrCompareRequired(发布 409),调用方先拦下并报错,见 computeEventScore。
const basis: ChannelBasis = channel.basis
const eventMultiplier = effectiveMultiplier(event.multiplier) const eventMultiplier = effectiveMultiplier(event.multiplier)
const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1) const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1)
@@ -261,7 +372,9 @@ function scoreChannel(
sortByValue(aggregated, event.compare) sortByValue(aggregated, event.compare)
groups = groupByValue(aggregated) groups = groupByValue(aggregated)
} else if (basis === 'manual') { } else if (basis === 'manual') {
const cands = collectCandidates(rows, 'manual') // 「按单位录名次」读 channel_manual_ranks(+ 团体行兜底),个人名次才读行上的 rank。
const cands =
channel.scope === 'team' ? collectTeamManualCandidates(rows, teamRanks) : collectCandidates(rows, 'manual')
sortManual(cands) sortManual(cands)
groups = groupByManualRank(cands) groups = groupByManualRank(cands)
} else { } else {
@@ -313,6 +426,22 @@ export interface RowTeamShare {
points: number points: number
} }
/**
* 预览与服务端**都算不出来**的通道配置。
*
* 目前只有一种:compare=none 的小项配了 basis=result / basis=aggregate 的通道。
* 服务端 domain.ScoreEvent 对这种配置直接返回 ErrCompareRequired,发布会 409(STATE_INVALID);
* 预览以前把它静默降级成「按录入名次」算出一份服务端根本拿不到的分,操作员照着这个数发布只会失败。
*/
export interface ScoreConfigError {
/** 出问题的通道 id(通道还没入库时可能是 0)。 */
channelId: number
/** 通道在界面上的称谓,如「团体名次 / 按成绩自动排名」。 */
channelLabel: string
/** 人话原因,页面直接显示给操作员。 */
message: string
}
export interface EventScoreResult { export interface EventScoreResult {
/** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */ /** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */
rowPoints: Map<number, number> rowPoints: Map<number, number>
@@ -329,8 +458,33 @@ export interface EventScoreResult {
ranks: Map<number, number | null> ranks: Map<number, number | null>
/** 按单位聚合的得分增量,用于预览面板。 */ /** 按单位聚合的得分增量,用于预览面板。 */
teamPoints: Map<number, number> teamPoints: Map<number, number>
/**
* 不挂在任何报名项上的单位分(entryId = 0 的名次:按单位录的团体名次、按队内平均的团体成绩)。
*
* 「已发布的分从单位总分里扣掉」时要把它一起扣:这些分已经随发布 / 保存团体名次进了榜单,
* 只扣行分会让预览把它们当成新增量报出去(现场那张预览正是发布确认框「最大分差」的来源)。
*/
unitPoints: Map<number, number>
/** 配置错误(非 null 时所有分都是空的,页面应显示 message 而不是数字)。 */
error: ScoreConfigError | null
} }
/** compare=none 的小项排不出名次:basis=result / aggregate 的通道服务端一律拒绝(scoring.go 的两处 ErrCompareRequired)。 */
function compareRequiredError(event: Event, channel: ScoreChannel): ScoreConfigError | null {
if (event.compare !== 'none') return null
if (channel.basis !== 'result' && channel.basis !== 'aggregate') return null
const label = channelLabelOf(channel.scope, channel.basis)
return {
channelId: channel.id,
channelLabel: label,
message:
`通道「${label}」要按成绩排名,而小项「${event.name}」是「不比较」(compare=none):` +
'服务端算不出名次、发布会直接拒绝(STATE_INVALID)。' +
'请到「基础数据 → 计分通道」把它改成「手动录名次」,或先给小项设置比较方向(升序 / 降序)。',
}
}
/** /**
* 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。 * 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。
* *
@@ -358,6 +512,12 @@ export interface ScoreContext {
profiles: readonly ScoringProfile[] profiles: readonly ScoringProfile[]
/** standings.individualToTeamWeight,个人分折算到单位的系数。 */ /** standings.individualToTeamWeight,个人分折算到单位的系数。 */
weight: number weight: number
/**
* channel_manual_ranks:录入页「团体名次」卡片里按单位录的名次,只有
* scope=team + basis=manual 的通道读它(服务端 EventScoreInput.TeamRanks 同口径)。
* 不传 = 该组别还没读到团体名次(预览会按「一行都没有」算,而不是按行上的名次兜底)。
*/
teamRanks?: readonly TeamRankEntry[]
} }
/** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */ /** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */
@@ -372,6 +532,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
const scoreEnabled = event.scored && !rankOnly const scoreEnabled = event.scored && !rankOnly
const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels) const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels)
const weight = context.weight > 0 ? context.weight : 1 const weight = context.weight > 0 ? context.weight : 1
const teamRanks = context.teamRanks ?? []
const rowPoints = new Map<number, number>() const rowPoints = new Map<number, number>()
const rowTeamPoints = new Map<number, number>() const rowTeamPoints = new Map<number, number>()
@@ -379,6 +540,17 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
const ranks = new Map<number, number | null>() const ranks = new Map<number, number | null>()
const rankScope = new Map<number, ChannelScope>() const rankScope = new Map<number, ChannelScope>()
const teamPoints = new Map<number, number>() const teamPoints = new Map<number, number>()
const unitPoints = new Map<number, number>()
// 配置本身就出不来分的(compare=none + basis=result/aggregate):与服务端一样停在这里,
// 一分不算、名次也不给,页面按 error 显示原因——绝不再降级算出一份服务端拿不到的分。
for (const channel of channels) {
const error = compareRequiredError(event, channel)
if (error !== null) {
for (const row of rows) ranks.set(row.entryId, null)
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error }
}
}
const sharesByEntry = new Map<number, readonly { teamId: number; weight: number }[]>() const sharesByEntry = new Map<number, readonly { teamId: number; weight: number }[]>()
for (const row of rows) { for (const row of rows) {
@@ -388,7 +560,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
for (const channel of channels) { for (const channel of channels) {
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。 // 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
const ranked = scoreChannel(rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null) const ranked = scoreChannel(
rows,
channel,
profile ?? EMPTY_PROFILE,
event,
context.category,
scoreEnabled && profile !== null,
teamRanks,
)
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。 // countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
const countToTeam = normalizeCountToTeam(channel.countToTeam) const countToTeam = normalizeCountToTeam(channel.countToTeam)
for (const row of ranked) { for (const row of ranked) {
@@ -424,6 +604,9 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
if (!countToTeam) continue if (!countToTeam) continue
for (const part of parts) { for (const part of parts) {
teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points) teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points)
// 不挂在报名项上的那一份单独留一份账:它已经随发布 / 保存团体名次进过榜单,
// 预览要扣已发布的分时得能把它扣掉(见 EventScoreResult.unitPoints)。
if (row.entryId <= 0) unitPoints.set(part.teamId, (unitPoints.get(part.teamId) ?? 0) + part.points)
} }
} }
} }
@@ -431,5 +614,5 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
for (const row of rows) { for (const row of rows) {
if (!ranks.has(row.entryId)) ranks.set(row.entryId, null) if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
} }
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints } return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error: null }
} }
@@ -11,22 +11,25 @@ import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue' import ConfirmDialog from '../ConfirmDialog.vue'
import { useBaseData } from './useBaseData' import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast' import { useToastStore } from '../../stores/toast'
import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../../api/scoring'
import type { BaseDataTabKey } from './tabs' import type { BaseDataTabKey } from './tabs'
import { newChannelRow, type ChannelRow } from './channelRows' import { newChannelRow, compareConflictMessage, type ChannelRow } from './channelRows'
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>() const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
const data = useBaseData() const data = useBaseData()
const toast = useToastStore() const toast = useToastStore()
// 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts),两处措辞漂移会让操作员
// 对不上「说的是哪条通道」。
const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [ const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [
{ value: 'athlete', label: '个人名次' }, { value: 'athlete', label: CHANNEL_SCOPE_LABELS.athlete },
{ value: 'team', label: '团体名次' }, { value: 'team', label: CHANNEL_SCOPE_LABELS.team },
] ]
const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [ const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
{ value: 'result', label: '按成绩自动排名' }, { value: 'result', label: CHANNEL_BASIS_LABELS.result },
{ value: 'manual', label: '手动录名次' }, { value: 'manual', label: CHANNEL_BASIS_LABELS.manual },
{ value: 'aggregate', label: '按队内平均' }, { value: 'aggregate', label: CHANNEL_BASIS_LABELS.aggregate },
] ]
/** /**
@@ -40,6 +43,7 @@ const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint:
interface RowError { interface RowError {
scope?: string scope?: string
basis?: string
profile?: string profile?: string
} }
@@ -128,6 +132,11 @@ function validate(): boolean {
if (row.profileId === '' || data.profileById(Number(row.profileId)) === undefined) { if (row.profileId === '' || data.profileById(Number(row.profileId)) === undefined) {
rowError.profile = '必须选择一个积分模板' rowError.profile = '必须选择一个积分模板'
} }
// compare=none 的小项排不出名次(名次全靠人工录入):basis=result / aggregate 保存下去,
// 到发布时才会被服务端拒绝(409 STATE_INVALID),那时成绩已经录了一半。规则包导入早有这条
// 守卫(rules/diff.go checkCompareCompatible),管理端手工配也得在保存前拦下。
const conflict = compareConflictMessage(selectedEvent.value, row.basis)
if (conflict !== null) rowError.basis = conflict
if (Object.keys(rowError).length > 0) next[row.key] = rowError if (Object.keys(rowError).length > 0) next[row.key] = rowError
}) })
errors.value = next errors.value = next
@@ -258,12 +267,13 @@ async function save(): Promise<void> {
</select> </select>
</td> </td>
<td> <td>
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }"> <select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.basis }">
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option> <option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select> </select>
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note"> <span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }} 团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }}
</span> </span>
<span v-if="errors[row.key]?.basis" class="row-error">{{ errors[row.key]?.basis }}</span>
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span> <span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
</td> </td>
<td> <td>
@@ -13,9 +13,11 @@ import {
emptyTeamPick, emptyTeamPick,
registeredAthleteIds, registeredAthleteIds,
registeredTeamIds, registeredTeamIds,
registeredTeamSquads,
selectableTeamIds, selectableTeamIds,
selectionAllowsSubmit, selectionAllowsSubmit,
setPickedTeams, setPickedTeams,
setTeamPickSquad,
teamSelectionAllowsSubmit, teamSelectionAllowsSubmit,
toggleMemberPick, toggleMemberPick,
toggleTeamPick, toggleTeamPick,
@@ -116,6 +118,8 @@ const filteredAthletes = computed(() => {
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id))) const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id)))
const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id)) const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id))
/** 各单位已报名的队名:徽标上写出来,操作员才知道第二支队该填哪个名字。 */
const registeredSquads = computed(() => registeredTeamSquads(entries.value, currentCategory.value?.id))
const registeredAthletes = computed(() => const registeredAthletes = computed(() =>
registeredAthleteIds(entries.value, currentCategory.value?.id, currentTeam.value?.id), registeredAthleteIds(entries.value, currentCategory.value?.id, currentTeam.value?.id),
) )
@@ -184,6 +188,23 @@ function memberCount(teamId: number): number {
return teamPick.value.memberIds[teamId]?.length ?? 0 return teamPick.value.memberIds[teamId]?.length ?? 0
} }
/** 某个单位输入框里的队名原文(没填过 = 空串 = 不区分队伍)。 */
function squadTextOf(teamId: number): string {
return teamPick.value.squads?.[teamId] ?? ''
}
function onSquadInput(teamId: number, event: Event): void {
const input = event.target
if (!(input instanceof HTMLInputElement)) return
teamPick.value = setTeamPickSquad(teamPick.value, teamId, input.value)
}
/** 「已报名」徽标的文案:报过队名的写出来(`已报名(一队、二队)`),没队名的老报名只写「已报名」。 */
function registeredSquadLabel(teamId: number): string {
const squads = registeredSquads.value.get(teamId) ?? []
return squads.length === 0 ? '已报名' : `已报名(${squads.join('、')})`
}
function isExpanded(teamId: number): boolean { function isExpanded(teamId: number): boolean {
return expandedTeams.value.includes(teamId) return expandedTeams.value.includes(teamId)
} }
@@ -560,6 +581,8 @@ async function submitTeam(): Promise<void> {
团体 / 对抗项目按单位报名:从已配置的参赛单位里勾选参加本组别的单位(默认一个都不勾, 团体 / 对抗项目按单位报名:从已配置的参赛单位里勾选参加本组别的单位(默认一个都不勾,
部分单位不参加就不勾它)。每个单位生成一条报名,队员名单可选——展开某个单位即可登记队员, 部分单位不参加就不勾它)。每个单位生成一条报名,队员名单可选——展开某个单位即可登记队员,
一个队员都不勾则只报单位。「全选」只勾启用中且未报名的单位,已停用单位要单独勾。 一个队员都不勾则只报单位。「全选」只勾启用中且未报名的单位,已停用单位要单独勾。
同一个单位要派多支队(一队 / 二队):勾上它,在旁边的「队伍」格里填队名,
每个队名各是一条报名、各占各的名次;不填就与过去完全一样。
</p> </p>
<div class="team-list"> <div class="team-list">
@@ -580,8 +603,24 @@ async function submitTeam(): Promise<void> {
<span class="picker-name">{{ team.name }}{{ team.active ? '' : '(已停用)' }}</span> <span class="picker-name">{{ team.name }}{{ team.active ? '' : '(已停用)' }}</span>
<span class="bd-note bd-mono">{{ team.code }}</span> <span class="bd-note bd-mono">{{ team.code }}</span>
</label> </label>
<!--
队伍:勾上单位后才出现。留空 = 不区分队伍(老数据与单队单位的默认,
提交出来的报名行与改造前一模一样);填「一队 / 二队」就是给这个单位再报一支队,
同一个单位在同一组别就能有两支队伍各占一个名次。
-->
<input
v-if="isTeamPicked(team.id)"
class="mx-cell-input team-squad-input"
:value="squadTextOf(team.id)"
:disabled="!data.canWrite.value"
placeholder="队伍(留空=不区分)"
title="同一个单位在一个组别可以派多个队:填「一队 / 二队」后每支队各留一条报名、各占各的名次;留空就是过去的老样子。"
@input="onSquadInput(team.id, $event)"
/>
<div class="team-actions"> <div class="team-actions">
<span v-if="registeredTeams.has(team.id)" class="mx-badge mx-badge-neutral">已报名</span> <span v-if="registeredTeams.has(team.id)" class="mx-badge mx-badge-neutral">
{{ registeredSquadLabel(team.id) }}
</span>
<span v-if="memberCount(team.id) > 0" class="mx-badge mx-badge-info"> <span v-if="memberCount(team.id) > 0" class="mx-badge mx-badge-info">
队员 {{ memberCount(team.id) }} 人 队员 {{ memberCount(team.id) }} 人
</span> </span>
@@ -618,9 +657,10 @@ async function submitTeam(): Promise<void> {
</div> </div>
<p class="bd-note"> <p class="bd-note">
每个单位生成一条报名(同组别 + 单位只留一条),队员名单按列表顺序写入。 每个单位生成一条报名(同组别 + 单位 + 队伍只留一条),队员名单按列表顺序写入。
已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空, 已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空,
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位。 所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位,
队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。
</p> </p>
<p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p> <p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p>
</template> </template>
@@ -714,6 +754,12 @@ async function submitTeam(): Promise<void> {
.team-check { .team-check {
min-width: 0; min-width: 0;
} }
/* 队伍输入框:只在勾上单位后出现,固定一档宽度,不挤掉右侧的徽标与「登记队员」按钮。
输入框里是普通文本(不是数字),可以直接当字符串用。 */
.team-squad-input {
flex: none;
width: 150px;
}
.team-actions { .team-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,4 +1,4 @@
import type { AggregateMode, ChannelBasis, ChannelScope, EventType } from '@/shared' import type { AggregateMode, ChannelBasis, ChannelScope, CompareMode, EventType } from '@/shared'
import { DEFAULT_AGGREGATE_MODE } from '@/shared' import { DEFAULT_AGGREGATE_MODE } from '@/shared'
/** 计分通道编辑器里的一行:新建行或已存库通道的回填(已存库的行见 ChannelsSection.vue 的 toRow)。 */ /** 计分通道编辑器里的一行:新建行或已存库通道的回填(已存库的行见 ChannelsSection.vue 的 toRow)。 */
@@ -51,3 +51,26 @@ export function newChannelRow(key: number, eventType: EventType, profileId: stri
countToTeamMedals: true, countToTeamMedals: true,
} }
} }
/**
* 「compare=none 的小项不得配 basis=result / basis=aggregate 通道」的界面守卫,返回人话原因(合法时 null)。
*
* 这两种通道都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算,服务端
* domain.ScoreEvent 会直接返回 ErrCompareRequired:以前这条配置能存下去,一直到发布会 409
* (STATE_INVALID),那时成绩已经录了一半。规则包导入侧早有同样的守卫
* (server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧见
* repo_events.go 的 replaceChannels;这里补的是手工在管理端点「保存全部」的那条路。
*
* 抽成纯函数放在这里:校验规则要能被测试钉住,页面(ChannelsSection.vue)只负责显示。
*/
export function compareConflictMessage(
event: { name: string; compare: CompareMode } | null,
basis: ChannelBasis,
): string | null {
if (event === null || event.compare !== 'none') return null
if (basis !== 'result' && basis !== 'aggregate') return null
return (
`小项「${event.name}」是「不比较」(compare=none),排不出名次:` +
'这条通道要按成绩排名,服务端算不出分、发布会失败。请改成「手动录名次」,或先给小项设置比较方向。'
)
}
@@ -3,7 +3,8 @@
* *
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口, * 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert * 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。 * (纯团体行是 (组别, 单位, 队伍),见迁移 0025)与行级报错回显都与文件导入完全一致,
* 不必为选择器新增服务端接口。
*/ */
import type { import type {
@@ -12,11 +13,22 @@ import type {
ImportConflictDecision, ImportConflictDecision,
ImportConflictField, ImportConflictField,
} from '@/shared' } from '@/shared'
import { teamSquadLabel } from '@/shared'
/**
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
* 这一列就是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
*
* 声明在列清单之前:它要出现在清单里(列在最后),而清单是模块加载期求值的。
*/
export const SQUAD_COLUMN = 'squad'
/** /**
* 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。 * 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。
* *
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。 * heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
* 队伍(squad)同样可选、同样在最后:留空 = 不区分队伍,老数据与单队单位都是它,
* 报名行为与升级前完全一致(服务端那一列也是 optional,缺列不报错)。
* 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名; * 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名;
* 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。 * 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。
*/ */
@@ -33,13 +45,14 @@ export const REQUIRED_COLUMNS = [
'heat', 'heat',
'status', 'status',
'member_names', 'member_names',
SQUAD_COLUMN,
] as const ] as const
/** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */ /** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */
export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number] export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number]
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */ /** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat', SQUAD_COLUMN] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */ /** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS
@@ -69,6 +82,7 @@ export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
heat: ['批次', '批次号'], heat: ['批次', '批次号'],
status: ['状态', '报名状态'], status: ['状态', '报名状态'],
member_names: ['队员名单'], member_names: ['队员名单'],
squad: ['队伍', '队名', '队伍名'],
} }
/** /**
@@ -177,11 +191,11 @@ export function atSignNameError(kind: string, name: string): string | null {
export const TEMPLATE_SHEET_MOCK_HINT = export const TEMPLATE_SHEET_MOCK_HINT =
'示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板' '示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板'
/** 模板里的示例行:两种语言共用,只有表头不同。 */ /** 模板里的示例行:两种语言共用,只有表头不同。队名列留空(示例行本来就该被填表人删掉)。 */
const TEMPLATE_ROWS: readonly string[][] = [ const TEMPLATE_ROWS: readonly string[][] = [
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', ''], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', '', ''],
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', ''], ['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', '', ''],
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队'], ['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队', ''],
] ]
/** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */ /** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */
@@ -201,14 +215,24 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
return mode === 'team' || chosenCount > 0 return mode === 'team' || chosenCount > 0
} }
/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */ /**
* 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。
*
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
*/
export function entryRowLabel(row: { export function entryRowLabel(row: {
athleteName?: string | null athleteName?: string | null
memberNames?: string[] | null memberNames?: string[] | null
teamShort?: string | null teamShort?: string | null
teamName?: string | null teamName?: string | null
squad?: string | null
}): string { }): string {
return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || '' return (
row.athleteName ||
row.memberNames?.[0] ||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
)
} }
export interface SelectionInput { export interface SelectionInput {
@@ -227,7 +251,8 @@ export interface SelectionInput {
* 生成与导入表头一致的二维数组。 * 生成与导入表头一致的二维数组。
* *
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。 * 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。 * 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名;
* 队名列留空——个人报名不带队伍(服务端也只对纯团体报名写它)。
*/ */
export function buildSelectionRows(input: SelectionInput): string[][] { export function buildSelectionRows(input: SelectionInput): string[][] {
const status = input.status ?? 'registered' const status = input.status ?? 'registered'
@@ -248,6 +273,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
'', '',
status, status,
'', '',
// 队伍留空:个人报名按人走,没有「这个单位的第几支队」这回事。
'',
]) ])
} }
return rows return rows
@@ -266,6 +293,11 @@ export interface TeamSelection {
teamCode: string teamCode: string
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */ /** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
memberNames: string[] memberNames: string[]
/**
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
*/
squad?: string
} }
export interface TeamSelectionInput { export interface TeamSelectionInput {
@@ -277,10 +309,12 @@ export interface TeamSelectionInput {
} }
/** /**
* 多单位团体报名:每个单位一行,队员姓名进 member_names。 * 多单位团体报名:每个单位一行,队员姓名进 member_names,队伍进最后一列。
* *
* 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert, * 服务端对 athlete_name 为空的行按 (组别, 单位, 队伍) upsert,
* 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。 * 所以同组别 + 同单位 + 同队伍仍然只留一条报名(重复提交是更新队员名单而不是新增),
* 而同一个单位换个队名再提交就是**第二条报名** —— 一个单位派两个队正是这样报的。
* 队伍留空 = 不区分队伍,与服务端那一列(optional)同口径。
*/ */
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] { export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
const status = input.status ?? 'registered' const status = input.status ?? 'registered'
@@ -300,6 +334,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
'', '',
status, status,
team.memberNames.join('/'), team.memberNames.join('/'),
// 队名两侧空白去掉再写:服务端读表时就 TrimSpace,这里先归一,免得「一队 」与「一队」成了两个队。
(team.squad ?? '').trim(),
]) ])
} }
return rows return rows
@@ -316,6 +352,13 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
export interface TeamPickState { export interface TeamPickState {
teamIds: number[] teamIds: number[]
memberIds: Record<number, number[]> memberIds: Record<number, number[]>
/**
* 每个单位填的队名(输入框原文):没填 / 不在表里 = 空串 = 不区分队伍。
*
* 可选字段是为了让「不填队名」这条老路径原样保留:老代码构造的状态里根本没有它,
* 产出的报名行也就与改造前一模一样。
*/
squads?: Record<number, string>
} }
export function emptyTeamPick(): TeamPickState { export function emptyTeamPick(): TeamPickState {
@@ -348,11 +391,21 @@ export function toggleMemberPick(
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } } return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
} }
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */ /** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单与队名保留。 */
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState { export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
return { ...state, teamIds: [...teamIds] } return { ...state, teamIds: [...teamIds] }
} }
/**
* 记下某个单位填的队名(输入框原文)。
*
* 存原文不存处理过的值:界面上「空」与「填了一队」是两件事(前者 = 不区分队伍),
* 归一留给产出报名行的那一步(buildTeamSelections / buildTeamSelectionRows)。
*/
export function setTeamPickSquad(state: TeamPickState, teamId: number, squad: string): TeamPickState {
return { ...state, squads: { ...state.squads, [teamId]: squad } }
}
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */ /** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
export function selectableTeamIds( export function selectableTeamIds(
teams: readonly { id: number; active: boolean }[], teams: readonly { id: number; active: boolean }[],
@@ -366,6 +419,7 @@ export function selectableTeamIds(
* *
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人, * 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
* 保证同一份勾选每次生成的 member_names 完全一致。 * 保证同一份勾选每次生成的 member_names 完全一致。
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
*/ */
export function buildTeamSelections( export function buildTeamSelections(
state: TeamPickState, state: TeamPickState,
@@ -380,7 +434,8 @@ export function buildTeamSelections(
const memberNames = athletes const memberNames = athletes
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id)) .filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
.map((athlete) => athlete.name) .map((athlete) => athlete.name)
selections.push({ teamCode: team.code, memberNames }) const squad = (state.squads?.[team.id] ?? '').trim()
selections.push(squad === '' ? { teamCode: team.code, memberNames } : { teamCode: team.code, memberNames, squad })
} }
return selections return selections
} }
@@ -403,6 +458,30 @@ export function registeredTeamIds(
return ids return ids
} }
/**
* 组别下各单位**已报名的队名**(去重、按报名表出现顺序)。
*
* 「已报名」这一个徽标在有了队伍之后不够用了:同一个单位可以再报第二支队,
* 操作员得先看见它已经报过哪些队,才不会把「二队」又写成「一队」(那样是更新原来那条,不是新增)。
* 空串(不区分队伍的老报名)不进表,那一行的徽标仍只说「已报名」,与升级前一样。
*/
export function registeredTeamSquads(
entries: readonly { categoryId: number; teamId: number; squad?: string | null }[],
categoryId: number | undefined,
): Map<number, string[]> {
const out = new Map<number, string[]>()
if (categoryId === undefined) return out
for (const entry of entries) {
if (entry.categoryId !== categoryId) continue
const squad = (entry.squad ?? '').trim()
if (squad === '') continue
const list = out.get(entry.teamId)
if (list === undefined) out.set(entry.teamId, [squad])
else if (!list.includes(squad)) list.push(squad)
}
return out
}
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */ /** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
export function registeredAthleteIds( export function registeredAthleteIds(
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[], entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
+397 -51
View File
@@ -7,9 +7,11 @@ import {
hasAggregateChannel, hasAggregateChannel,
isJointTeam, isJointTeam,
jointTeamLabel, jointTeamLabel,
normalizeSquad,
normalizeTeamShares, normalizeTeamShares,
parseResultValue, parseResultValue,
shareText, shareText,
teamSquadLabel,
type AvgMode, type AvgMode,
type DraftResultInput, type DraftResultInput,
type EntryStatus, type EntryStatus,
@@ -17,6 +19,7 @@ import {
type Event, type Event,
type ResultState, type ResultState,
type ResultStatus, type ResultStatus,
type TeamRankEntry,
type TeamRanksView, type TeamRanksView,
type TeamShareView, type TeamShareView,
} from '@/shared' } from '@/shared'
@@ -36,6 +39,8 @@ import {
import { entryProgress } from './entryProgress' import { entryProgress } from './entryProgress'
import { import {
hasTeamManualChannel, hasTeamManualChannel,
mergeJointTeamRankRows,
mergedAwayRankNames,
teamRankFlaggedTeams, teamRankFlaggedTeams,
teamRankRowsOf, teamRankRowsOf,
teamRankWarnings, teamRankWarnings,
@@ -57,6 +62,12 @@ import {
submitEntryTeamShares, submitEntryTeamShares,
type ShareDraftRow, type ShareDraftRow,
} from './entryShares' } from './entryShares'
import {
entrySquadEditable,
squadDraftDirty,
squadDraftOf,
submitEntrySquad,
} from './entrySquad'
import { import {
entryBatchOutcome, entryBatchOutcome,
entryBatchSizeError, entryBatchSizeError,
@@ -110,6 +121,11 @@ interface EditableRow {
athleteId: number | null athleteId: number | null
teamName: string teamName: string
teamShort: string teamShort: string
/**
* 队名(空串 = 不区分队伍):同一个单位在同一组别可以派多个队,两条报名各占一个名次。
* 只对团体行有意义(个人项目按人报名,服务端根本不给 squad)。
*/
squad: string
/** /**
* 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。 * 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。
* *
@@ -264,7 +280,20 @@ const teamRanksSaving = ref(false)
/** 当前组别的团体名次行(表格直接绑它)。 */ /** 当前组别的团体名次行(表格直接绑它)。 */
const teamRankRows = computed<TeamRankRow[]>(() => { const teamRankRows = computed<TeamRankRow[]>(() => {
const categoryId = selectedCategoryId.value const categoryId = selectedCategoryId.value
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? []) if (categoryId === null) return []
// 联队(一条报名挂多个单位)在本组报名时,成员单位合并成主单位那一行:名次只录一处,
// 成员单位不再各占一行,也就没有地方给它们顺手录第二份名次(重复计分)。
// 报名行就在录入页手里(categoryRows 的 teamShares),不为它多开一个接口。
return mergeJointTeamRankRows(teamRanksByCategory.value.get(categoryId) ?? [], categoryRows.value)
})
/**
* 被并进联队行、不再单独占行的成员单位原来录过的名次:保存(整组替换)后会被一起清掉,
* 界面上先点名说清——这是操作员库里本来就有的数,不能悄悄改。
*/
const teamRankMergedAwayNames = computed(() => {
const categoryId = selectedCategoryId.value
if (categoryId === null) return []
return mergedAwayRankNames(teamRanksByCategory.value.get(categoryId) ?? [], teamRankRows.value)
}) })
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value)) const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */ /** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
@@ -385,7 +414,14 @@ function scorableOf(rows: EditableRow[]): ScorableRow[] {
})) }))
} }
function scoreWith(rows: EditableRow[]) { /**
* 一次算分。
*
* teamRanks 必须显式给:这就是 channel_manual_ranks(录入页底部「团体名次」卡片里的名次)。
* 以前预览从不喂它,「按单位录名次」的通道就永远算不出分——服务端算对了(六个单位合计 40 分),
* 预览却每个单位都显示 0,操作员据此以为没分。
*/
function scoreWith(rows: EditableRow[], teamRanks: readonly TeamRankEntry[]) {
const event = selectedEvent.value const event = selectedEvent.value
if (event === null) return null if (event === null) return null
return computeEventScore(scorableOf(rows), { return computeEventScore(scorableOf(rows), {
@@ -394,11 +430,27 @@ function scoreWith(rows: EditableRow[]) {
channels: channels.value, channels: channels.value,
profiles: data.profiles, profiles: data.profiles,
weight: weight.value, weight: weight.value,
teamRanks,
}) })
} }
/** 预览用的团体名次:卡片里**当前填的**那一份(含未保存的编辑,与成绩的未保存编辑同口径)。 */
const previewTeamRanks = computed<TeamRankEntry[]>(() => teamRanksPayload(teamRankRows.value).ranks)
/**
* 榜单上已经在算的那一份团体名次(服务端存的值)。
*
* 「已发布的分」基线要用它:卡片里改了名次还没点「保存团体名次」时,榜单并没有跟着变,
* 拿改后的名次去扣已发布的分,扣出来的差值就不是本次发布的真实影响。
*/
const savedTeamRanks = computed<TeamRankEntry[]>(() => {
const categoryId = selectedCategoryId.value
if (categoryId === null) return []
return embeddedTeamRanksFor(categoryId)?.ranks ?? []
})
/** 全量可见行(含未录行,但没成绩不参与排名)= 本次发布后服务端会算出的口径。 */ /** 全量可见行(含未录行,但没成绩不参与排名)= 本次发布后服务端会算出的口径。 */
const score = computed(() => scoreWith(visibleRows.value)) const score = computed(() => scoreWith(visibleRows.value, previewTeamRanks.value))
/** /**
* 只含「已发布」行的一次独立算分:这是该组此刻在榜上的口径。 * 只含「已发布」行的一次独立算分:这是该组此刻在榜上的口径。
@@ -406,7 +458,12 @@ const score = computed(() => scoreWith(visibleRows.value))
* 分批比赛时必须单独算:若拿全量口径里已发布行的分去扣,新一批会把老一批的名次挤下去, * 分批比赛时必须单独算:若拿全量口径里已发布行的分去扣,新一批会把老一批的名次挤下去,
* 扣掉的却是老一批在新口径下的(可能更低的)分,差值就不等于本次发布对榜单的真实影响。 * 扣掉的却是老一批在新口径下的(可能更低的)分,差值就不等于本次发布对榜单的真实影响。
*/ */
const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) => row.resultState === 'published'))) const publishedScore = computed(() =>
scoreWith(
visibleRows.value.filter((row) => row.resultState === 'published'),
savedTeamRanks.value,
),
)
/** /**
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。 * 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
@@ -416,17 +473,25 @@ const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) =
*/ */
const publishedContribution = computed(() => { const publishedContribution = computed(() => {
const map = new Map<number, number>() const map = new Map<number, number>()
const rowTeamShares = publishedScore.value?.rowTeamShares const published = publishedScore.value
for (const row of visibleRows.value) { for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue if (row.resultState !== 'published') continue
// 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。 // 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。
for (const part of rowTeamShares?.get(row.entryId) ?? []) { for (const part of published?.rowTeamShares.get(row.entryId) ?? []) {
map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points) map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points)
} }
} }
// 不挂在报名项上的那部分分(按单位录的团体名次、按队内平均的团体成绩)同样已经在榜上,
// 不一起扣掉就会被预览当成本次发布的新增量报出去(发布确认框的「最大分差」跟着虚高)。
for (const [teamId, points] of published?.unitPoints ?? []) {
map.set(teamId, (map.get(teamId) ?? 0) + points)
}
return map return map
}) })
/** 通道配置错误(compare=none 配了要按成绩排名的通道):服务端会拒绝发布,预览不报数字。 */
const scoreConfigError = computed(() => score.value?.error ?? null)
const preview = computed<PreviewRow[]>(() => { const preview = computed<PreviewRow[]>(() => {
const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints])) const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints]))
const next = score.value?.teamPoints ?? new Map<number, number>() const next = score.value?.teamPoints ?? new Map<number, number>()
@@ -742,6 +807,11 @@ const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnin
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */ /** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
const publishMessage = computed(() => { const publishMessage = computed(() => {
// 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409),
// 先在这里把原因说清,别让操作员对着一份算不出来的预览按发布。
if (scoreConfigError.value !== null) {
return `计分通道配置有问题,发布会失败:${scoreConfigError.value.message}`
}
const pending = pendingRows.value.length const pending = pendingRows.value.length
const missing = unrecordedRows.value.length const missing = unrecordedRows.value.length
const head = const head =
@@ -766,6 +836,32 @@ function rowLabel(row: EditableRow): string {
return entryRowLabel(row) return entryRowLabel(row)
} }
/**
* 表格「单位」列的单位名:填了队名就拼在后面(`第一分公司二队`,不加括号——大屏列宽紧张),
* 队名为空时与改造前逐字一样。同一个单位的两个队只差队名,不写出来就分不出哪一行是谁。
*/
function teamCellText(row: EditableRow): string {
return teamSquadLabel(row.teamShort || row.teamName, row.squad)
}
/**
* 单位格的悬停提示:点开的弹窗除了份额,对团体行还多一个「队伍」输入框,提示里得说出来,
* 否则操作员根本不知道队名还能改。个人行没有这一项,文案与改造前逐字一样。
*/
function teamCellTitle(row: EditableRow): string {
const name = teamSquadLabel(row.teamName, row.squad)
return entrySquadEditable(row)
? `${name}|点击设置成员单位、份额与队伍(同一个单位派多个队时用)`
: `${name}|点击设置成员单位与份额(两个单位合并参赛时用)`
}
function jointCellTitle(row: EditableRow): string {
const name = teamSquadLabel(jointTeamLabel(row.teamShares), row.squad)
return entrySquadEditable(row)
? `${name}|点击修改成员单位、份额与队伍`
: `${name}|点击修改成员单位与份额`
}
/** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */ /** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */
function issueLabel(row: EditableRow, index: number): string { function issueLabel(row: EditableRow, index: number): string {
const name = rowLabel(row) const name = rowLabel(row)
@@ -794,6 +890,8 @@ function toRow(entry: EntryWithResult): EditableRow {
athleteId: entry.athleteId, athleteId: entry.athleteId,
teamName, teamName,
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '', teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
// 老服务端不下发 squad 时按空串(= 不区分队伍),与升级前的显示逐字一致。
squad: normalizeSquad(entry.squad),
// 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。 // 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。
teamShares: normalizeTeamShares(entry.teamShares, { teamShares: normalizeTeamShares(entry.teamShares, {
teamId: entry.teamId, teamId: entry.teamId,
@@ -839,6 +937,34 @@ function pointsOf(row: EditableRow): number {
return round(score.value?.rowPoints.get(row.entryId) ?? 0) return round(score.value?.rowPoints.get(row.entryId) ?? 0)
} }
/**
* 本组的计分是不是「全按单位录名次、行上没有分」。
*
* 「积分(自动)」这一列算的是**行**的积分(按成绩 / 个人名次),而按单位录的名次产生的分
* 挂在单位上、不挂到任何一行报名:这一列在这个项目里永远是 0。现场操作员正是被这个 0
* 误导,以为「没分」。所以这种情况不显示 0,直接说「见下方团体名次」。
*
* 两条判据缺一不可:
* 1. 计分通道全是 scope=team + basis=manual;
* 2. 没有任何行是「带名次的团体行」——纯团体项目(篮球等)走 results.rank 兜底,
* 那种行上的分是真的,列照旧显示。
*/
const pointsColumnUnrelated = computed(() => {
const list = channels.value
if (list.length === 0) return false
if (!list.every((channel) => channel.scope === 'team' && channel.basis === 'manual')) return false
return !visibleRows.value.some((row) => (row.athleteId ?? 0) === 0 && (row.rank ?? 0) > 0)
})
/** 表头与单元格共用的口径说明:这一列算的是什么、为什么这里没有它的事。 */
const POINTS_COLUMN_TITLE = '「积分(自动)」是每一条报名行自己的分:按成绩自动排名或个人名次算出来的。'
const pointsColumnHint = computed(() =>
pointsColumnUnrelated.value
? `${POINTS_COLUMN_TITLE}本组按单位录名次计分,分记在单位上、不挂到任何一行,所以这一列没有可显示的分——请见下方「团体名次」与右侧「实时积分预览」。`
: POINTS_COLUMN_TITLE,
)
function issueFor(entryId: number): Issue | undefined { function issueFor(entryId: number): Issue | undefined {
return issues.value.find((issue) => issue.entryId === entryId) return issues.value.find((issue) => issue.entryId === entryId)
} }
@@ -1008,6 +1134,10 @@ async function onSupplementSubmitted(eventId: number, categoryId: number | null)
* *
* `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换 * `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换
* 发出去(服务端会照着做,只是白重算一遍本组)。 * 发出去(服务端会照着做,只是白重算一遍本组)。
*
* 团体行还多一个「队伍」输入框(迁移 0025):队名同样是这条报名的归属配置,与份额共用
* 这一个弹窗与这一个保存按钮。改队名走的是另一个接口(只改 squad,不碰份额),所以
* 保存时按「哪一块改了发哪一个请求」拆开,没改的那一块一个请求都不发。
*/ */
interface ShareEditor { interface ShareEditor {
entryId: number entryId: number
@@ -1016,8 +1146,16 @@ interface ShareEditor {
/** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */ /** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */
primaryTeamId: number primaryTeamId: number
primaryName: string primaryName: string
/** 表格「单位」列用的单位名(短名优先),队名预览要拼在它后面,口径与 teamCellText 一致。 */
teamCellBase: string
original: ShareDraftRow[] original: ShareDraftRow[]
draft: ShareDraftRow[] draft: ShareDraftRow[]
/** 这条报名是不是纯团体报名:只有它才出现「队伍」输入框(个人报名服务端也会 400)。 */
teamEntry: boolean
/** 队名输入框的原文;空串 = 不区分队伍。 */
squad: string
/** 打开时的队名:判断改没改,与份额的 original 同理。 */
originalSquad: string
saving: boolean saving: boolean
error: string | null error: string | null
} }
@@ -1034,6 +1172,18 @@ const shareChanged = computed(() => {
const editor = shareEditor.value const editor = shareEditor.value
return editor === null ? false : shareDraftDirty(editor.draft, editor.original) return editor === null ? false : shareDraftDirty(editor.draft, editor.original)
}) })
/** 队名改了没有:与份额一样按**归一后**的值比,多打一个空格不算改动。 */
const squadChanged = computed(() => {
const editor = shareEditor.value
return editor === null ? false : squadDraftDirty(editor.squad, editor.originalSquad)
})
/** 这个弹窗里有没有任何改动(份额或队名):决定保存按钮亮不亮、「关闭」是不是「放弃修改」。 */
const shareEditorDirty = computed(() => shareChanged.value || squadChanged.value)
/** 队名会拼进单位列(`第一分公司青年队`),改之前先让操作员看见改完长什么样。 */
const squadPreviewText = computed(() => {
const editor = shareEditor.value
return editor === null ? '' : teamSquadLabel(editor.teamCellBase, editor.squad)
})
const shareView = computed(() => { const shareView = computed(() => {
const editor = shareEditor.value const editor = shareEditor.value
return editor === null ? [] : shareDraftView(editor.draft) return editor === null ? [] : shareDraftView(editor.draft)
@@ -1050,9 +1200,20 @@ const canSaveShares = computed(
canWrite.value && canWrite.value &&
shareEditor.value !== null && shareEditor.value !== null &&
!shareEditor.value.saving && !shareEditor.value.saving &&
shareChanged.value && shareEditorDirty.value &&
shareIssues.value.length === 0, shareIssues.value.length === 0,
) )
/**
* 保存按钮上的字:这一下到底会保存什么。改了什么就说什么,别让只改了队名的人
* 面对一个「保存成员单位」按钮猜自己那一下会不会把份额也一起重发。
*/
const shareSaveLabel = computed(() => {
const shares = shareChanged.value
const squad = squadChanged.value
if (shares && squad) return '保存成员单位与队伍'
if (squad) return '保存队伍'
return '保存成员单位'
})
function openShareEditor(row: EditableRow): void { function openShareEditor(row: EditableRow): void {
pendingShareTeamId.value = '' pendingShareTeamId.value = ''
@@ -1061,8 +1222,13 @@ function openShareEditor(row: EditableRow): void {
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`, label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
primaryTeamId: row.teamId, primaryTeamId: row.teamId,
primaryName: row.teamName, primaryName: row.teamName,
teamCellBase: row.teamShort || row.teamName,
original: shareDraftOf(row), original: shareDraftOf(row),
draft: shareDraftOf(row), draft: shareDraftOf(row),
// 个人报名连这个输入框都不渲染:队名对它没有意义,服务端也会 400。
teamEntry: entrySquadEditable(row),
squad: squadDraftOf(row),
originalSquad: squadDraftOf(row),
saving: false, saving: false,
error: null, error: null,
} }
@@ -1095,12 +1261,24 @@ function editShareWeight(teamId: number, event: InputEvent): void {
editor.draft = setShareDraftWeight(editor.draft, teamId, input.value) editor.draft = setShareDraftWeight(editor.draft, teamId, input.value)
} }
/** 队名输入框:存**原文**,归一留给「改没改」与提交那一步(输入到一半的队名不该被 trim 掉)。 */
function editShareSquad(event: InputEvent): void {
const editor = shareEditor.value
if (editor === null) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
editor.squad = input.value
}
/** /**
* 保存份额:整组替换一条报名的成员单位。 * 保存这个弹窗里的改动:份额(整组替换)与队名(只改 squad)是两个接口。
* *
* 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉, * 按「哪一块改了发哪一个」拆开——只改队名的人不该顺便把没动过的份额整组替换一遍,
* 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。 * 服务端会照着做,只是白重算本组一次;两块都改了才各发一个。
* 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。 *
* 就地更新这一行的份额 / 队名并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑
* 一并覆盖掉,而这两项只影响分记在谁头上、单位列怎么写,不影响任何一行的成绩与名次
* (那些由本地预览算)。服务端的 400(单位重复 / 权重为负 / 队名重复…)原样展示在弹窗里,不吞。
*/ */
async function saveShareEditor(): Promise<void> { async function saveShareEditor(): Promise<void> {
const editor = shareEditor.value const editor = shareEditor.value
@@ -1112,26 +1290,45 @@ async function saveShareEditor(): Promise<void> {
} }
editor.saving = true editor.saving = true
editor.error = null editor.error = null
const sharesDirty = shareChanged.value
const squadDirty = squadChanged.value
const payload = shareDraftPayload(editor.draft) const payload = shareDraftPayload(editor.draft)
const row = allRows.value.find((item) => item.entryId === editor.entryId)
try { try {
const outcome = await submitEntryTeamShares(editor.entryId, payload) if (sharesDirty) {
const shares = outcome.teamShares ?? payload const outcome = await submitEntryTeamShares(editor.entryId, payload)
const row = allRows.value.find((item) => item.entryId === editor.entryId) const shares = outcome.teamShares ?? payload
if (row !== undefined) { if (row !== undefined) {
const fallbackName = row.teamName row.teamShares = normalizeTeamShares(shares, {
row.teamShares = normalizeTeamShares(shares, { teamId: row.teamId,
teamId: row.teamId, weight: 1,
weight: 1, teamCode: row.teamShares[0]?.teamCode ?? '',
teamCode: row.teamShares[0]?.teamCode ?? '', teamName: row.teamName,
teamName: fallbackName, })
}) }
// 份额这一份已经落库:把初值跟着推上去,队名那一步万一失败,再点保存不会把份额重发一遍。
editor.original = editor.draft
}
if (squadDirty) {
const outcome = await submitEntrySquad(editor.entryId, editor.squad)
// 服务端回显优先(它 trim 过);响应里没有这个字段时按手里的草稿,界面照改不误。
const saved = outcome.squad ?? editor.squad
if (row !== undefined) row.squad = saved
editor.squad = saved
editor.originalSquad = saved
} }
closeShareEditor() closeShareEditor()
void data.refreshStandings() void data.refreshStandings()
toast.success( if (row !== undefined) {
'成员单位已保存', const title =
isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位', sharesDirty && squadDirty ? '成员单位与队伍已保存' : sharesDirty ? '成员单位已保存' : '队伍已保存'
) // 只改了队名时,提示里把改完的单位列原文念一遍——队名到底拼成了什么,一眼就能核对。
let detail = `单位列已改成「${teamCellText(row)}」`
if (sharesDirty) {
detail = isJointTeam(row.teamShares) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位'
}
toast.success(title, detail)
}
} catch (caught) { } catch (caught) {
editor.error = caught instanceof Error ? caught.message : '保存成员单位失败' editor.error = caught instanceof Error ? caught.message : '保存成员单位失败'
} finally { } finally {
@@ -1560,7 +1757,8 @@ function batchRows(): EntryBatchRow[] {
entryId: row.entryId, entryId: row.entryId,
name: rowLabel(row), name: rowLabel(row),
bib: row.bib, bib: row.bib,
team: row.teamShort || row.teamName, // 单位带上队名:同一个单位的两个队都在这张表里时,确认框上要能分清删的是哪一条。
team: teamCellText(row),
categoryId: row.categoryId, categoryId: row.categoryId,
entryStatus: row.entryStatus, entryStatus: row.entryStatus,
})) }))
@@ -2269,7 +2467,14 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<th>成绩输入</th> <th>成绩输入</th>
<th class="center">名次</th> <th class="center">名次</th>
<th>状态</th> <th>状态</th>
<th class="center">积分(自动)</th> <th class="center" :title="pointsColumnHint">
积分(自动)
<!--
按单位录名次的项目里行上没有分(分挂在单位上):表头直接说清这一列算什么,
并指向真正有分的「团体名次」,别让那个 0 被读成「这个项目没分」。
-->
<span v-if="pointsColumnUnrelated" class="th-sub">见下方团体名次</span>
</th>
<th>备注</th> <th>备注</th>
<th class="center action-col">操作</th> <th class="center action-col">操作</th>
</tr> </tr>
@@ -2328,19 +2533,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span <span
v-if="!isJointTeam(row.teamShares)" v-if="!isJointTeam(row.teamShares)"
class="team-cell is-editable" class="team-cell is-editable"
:title="`${row.teamName}|点击设置成员单位与份额(两个单位合并参赛时用)`" :title="teamCellTitle(row)"
@click="openShareEditor(row)" @click="openShareEditor(row)"
>{{ row.teamShort || row.teamName }}</span> >{{ teamCellText(row) }}</span>
<span <span
v-else v-else
class="team-cell is-editable is-joint" class="team-cell is-editable is-joint"
:title="`${jointTeamLabel(row.teamShares)}|点击修改成员单位与份额`" :title="jointCellTitle(row)"
@click="openShareEditor(row)" @click="openShareEditor(row)"
> >
<span class="mx-badge mx-badge-info">联队</span> <span class="mx-badge mx-badge-info">联队</span>
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member"> <span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
{{ shareText(member) }} {{ shareText(member) }}
</span> </span>
<!-- 联队这一格是按份额逐行列的,队名单独一行写;单单位那一格才是直接拼在单位名后面。 -->
<span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span>
</span> </span>
</td> </td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 --> <!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
@@ -2437,8 +2644,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</option> </option>
</select> </select>
</td> </td>
<td class="center"> <td class="center" :title="pointsColumnHint">
<span class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }"> <span v-if="pointsColumnUnrelated" class="points-cell mono unrelated">—</span>
<span v-else class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
{{ formatPoints(pointsOf(row)) }} {{ formatPoints(pointsOf(row)) }}
</span> </span>
</td> </td>
@@ -2501,21 +2709,39 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<aside class="mx-card preview-card"> <aside class="mx-card preview-card">
<div class="mx-card-head"> <div class="mx-card-head">
<span class="mx-card-title">实时积分预览</span> <span class="mx-card-title">实时积分预览</span>
<span class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span> <span v-if="scoreConfigError === null" class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
<span v-else class="mx-badge mx-badge-error">配置有问题</span>
</div> </div>
<div class="mx-card-body"> <div class="mx-card-body">
<p class="preview-hint"> <!--
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分) 配置本身就出不来分(compare=none 却配了要按成绩排名的通道)时,这里不显示任何数字:
</p> 服务端算不出名次、发布会 409,给一组「0 分」只会让操作员以为这套配置还能用。
<ul class="preview-list"> -->
<li v-for="row in preview" :key="row.teamId" class="preview-item"> <div v-if="scoreConfigError !== null" class="preview-config-error">
<span class="preview-name">{{ row.name }}</span> <MxIcon name="warning" :size="14" />
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''"> <span>{{ scoreConfigError.message }}</span>
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }} </div>
</span> <template v-else>
<span class="preview-total mono">{{ formatPoints(row.next) }}</span> <p class="preview-hint">
</li> 若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
</ul> </p>
<!--
团体名次是独立保存的(有自己的「保存团体名次」按钮):没保存时预览按卡片里
填的名次算,但发布会用的是服务端已存的那一份,这里必须点破,别让人以为已经生效。
-->
<p v-if="teamRankDirty" class="preview-hint preview-rank-dirty">
团体名次有未保存的改动:预览按卡片里填的名次算,点「保存团体名次」后才会写入并重算榜单。
</p>
<ul class="preview-list">
<li v-for="row in preview" :key="row.teamId" class="preview-item">
<span class="preview-name">{{ row.name }}</span>
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
</span>
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
</li>
</ul>
</template>
</div> </div>
</aside> </aside>
</div> </div>
@@ -2553,11 +2779,29 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<p class="team-ranks-desc"> <p class="team-ranks-desc">
按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道), 按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
<strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。 <strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
配了联队的组别,成员单位合并成一行,名次录在主单位上(成员单位不再单独录,避免同一个名次重复计分)。
</p> </p>
<ul class="team-rank-list"> <ul class="team-rank-list">
<li v-for="row in teamRankRows" :key="row.teamId" class="team-rank-item"> <li
v-for="row in teamRankRows"
:key="row.teamId"
class="team-rank-item"
:class="{ 'is-joint': row.members.length > 0 }"
>
<span class="team-rank-code mono">{{ row.code }}</span> <span class="team-rank-code mono">{{ row.code }}</span>
<span class="team-rank-name">{{ row.shortName || row.name }}</span> <span class="team-rank-name">{{ row.shortName || row.name }}</span>
<!--
联队行:成员单位与份额并排列出(与大屏、成绩表里那一格同一读法),
让「这一场其实是 A+B 联队」一眼看得见,也点明名次录在主单位这一行上。
-->
<span v-if="row.members.length > 0" class="mx-badge mx-badge-info">联队</span>
<span
v-if="row.members.length > 0"
class="team-rank-members"
title="这一行是联队:名次录在主单位上,按份额拆给各成员单位;成员单位不再单独占一行"
>
<span v-for="member in row.members" :key="member.teamId" class="joint-member">{{ shareText(member) }}</span>
</span>
<input <input
v-model="row.input" v-model="row.input"
class="mx-cell-input rank-input mono" class="mx-cell-input rank-input mono"
@@ -2580,6 +2824,14 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span>{{ message }}(只提示,不阻止保存)</span> <span>{{ message }}(只提示,不阻止保存)</span>
</li> </li>
</ul> </ul>
<!--
被并进联队行的成员单位原来单独录过的名次:保存时会随整组替换一起清掉
(那正是要堵的重复计分),但这是库里本来就有的数,得说出来再清。
-->
<p v-if="teamRankMergedAwayNames.length > 0" class="team-ranks-hint team-rank-merged-note">
{{ teamRankMergedAwayNames.join('、') }} 已并入联队那一行,不再单独录入;
原来单独录过的名次会在保存后清除(同一个名次不再重复计分)。
</p>
<div v-if="teamRankRows.length > 0" class="team-ranks-foot"> <div v-if="teamRankRows.length > 0" class="team-ranks-foot">
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span> <span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
</div> </div>
@@ -2740,6 +2992,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
当前比例:<strong>{{ shareRatioText }}</strong> 当前比例:<strong>{{ shareRatioText }}</strong>
<span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span> <span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span>
</p> </p>
<!--
队伍(迁移 0025):同一个单位在同一组别可以派多个队,队名就写在这里——报完才发现
「二队」写成「一队」的,不用删了重报(重报会把已经录进去的成绩一起丢掉)。
只对纯团体报名出现:个人报名按人报,服务端也会 400,别让操作员对着一个注定失败的
输入框白点一次保存。留空 = 不区分队伍(老数据与单队单位的默认值),单位列的文案与
保存前后的行为都跟过去逐字一致。
-->
<div v-if="shareEditor.teamEntry" class="share-squad">
<span class="share-squad-label">队伍</span>
<input
class="mx-cell-input squad-input"
type="text"
placeholder="队伍(留空=不区分)"
:value="shareEditor.squad"
:disabled="!canWrite || shareEditor.saving"
title="同一个单位在同一组别可以派多个队(羽毛球双打的一队 / 二队):填上队名就能区分,留空=不区分队伍"
@input="editShareSquad"
/>
<span class="share-squad-preview">单位列:<strong>{{ squadPreviewText }}</strong></span>
</div>
<ul v-if="shareIssues.length > 0" class="issue-list"> <ul v-if="shareIssues.length > 0" class="issue-list">
<li v-for="(message, index) in shareIssues" :key="index" class="error"> <li v-for="(message, index) in shareIssues" :key="index" class="error">
<MxIcon name="warning" :size="14" /> <MxIcon name="warning" :size="14" />
@@ -2750,17 +3023,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
{{ shareEditor.error }} {{ shareEditor.error }}
</div> </div>
<p class="bd-note"> <p class="bd-note">
保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的, 保存成员单位后本组的名次与积分会当场重算,大屏榜单跟着刷新;队名是自由文本,
点「关闭」不会发出任何请求。 改了当场反映到单位列与大屏。没改动的话保存按钮是灰的,点「关闭」不会发出任何请求。
</p> </p>
</div> </div>
<div class="mx-modal-foot"> <div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="closeShareEditor"> <button class="mx-btn mx-btn-secondary" type="button" @click="closeShareEditor">
{{ shareChanged ? '放弃修改' : '关闭' }} {{ shareEditorDirty ? '放弃修改' : '关闭' }}
</button> </button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor"> <button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor">
<MxIcon name="check" :size="14" /> <MxIcon name="check" :size="14" />
{{ shareEditor.saving ? '保存中…' : '保存成员单位' }} {{ shareEditor.saving ? '保存中…' : shareSaveLabel }}
</button> </button>
</div> </div>
</div> </div>
@@ -3209,6 +3482,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 13px; font-size: 13px;
color: var(--mx-t2); color: var(--mx-t2);
} }
/* 队伍:一整行的「标签 + 输入框 + 单位列预览」,与上面的成员单位列表隔开一段。 */
.share-squad {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 8px;
padding: 8px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.share-squad-label {
font-size: 13px;
color: var(--mx-t2);
white-space: nowrap;
}
.squad-input {
flex: 1 1 auto;
min-width: 140px;
}
/* 预览是给眼睛核对的,单位名长的时候宁可省略号,也不要把输入框挤没。 */
.share-squad-preview {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--mx-t3);
text-overflow: ellipsis;
white-space: nowrap;
}
.share-squad-preview strong {
color: var(--mx-t2);
}
.share-readonly { .share-readonly {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3292,6 +3597,19 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
.team-rank-name { .team-rank-name {
min-width: 72px; min-width: 72px;
} }
/* 联队行:成员单位与份额并排列出(名字写全、比例看得见),与成绩表里那一格同一读法。
行本身就带边框,免得「A+B联队」和旁边那些普通单位行看上去一模一样。 */
.team-rank-item.is-joint {
border: 1px solid var(--mx-primary-glow);
}
.team-rank-members {
display: inline-flex;
gap: 8px;
}
.team-rank-merged-note {
display: block;
margin-top: 8px;
}
.team-rank-empty { .team-rank-empty {
font-size: 12px; font-size: 12px;
color: var(--mx-t3); color: var(--mx-t3);
@@ -3411,6 +3729,34 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
color: var(--mx-t3); color: var(--mx-t3);
font-weight: 600; font-weight: 600;
} }
/* 「按单位录名次」的项目里行上没有分:不给数字,给一句「见下方团体名次」,免得看着像没分。 */
.points-cell.unrelated {
color: var(--mx-t3);
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
/* 表头里的一句副标:「积分(自动)」这一列在本组没有可显示的分。 */
.th-sub {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--mx-t3);
}
.preview-config-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
color: var(--mx-red);
font-size: 12px;
line-height: 1.7;
}
.preview-rank-dirty {
color: var(--mx-amber);
}
.preview-card { .preview-card {
position: sticky; position: sticky;
top: 0; top: 0;
+22 -3
View File
@@ -5,7 +5,9 @@ import {
formatPoints, formatPoints,
isJointTeam, isJointTeam,
jointTeamLabel, jointTeamLabel,
normalizeSquad,
normalizeTeamShares, normalizeTeamShares,
teamSquadLabel,
type EntryWithResult, type EntryWithResult,
type Event, type Event,
type EventCategory, type EventCategory,
@@ -29,6 +31,8 @@ interface DraftRow {
teamId: number teamId: number
teamName: string teamName: string
teamShort: string teamShort: string
/** 队名(空串 = 不区分队伍):同一单位的两个队各占一个名次,复核时要分得清是哪一条。 */
squad: string
/** 归属份额(联队):复核时也要看得出这一条其实是几个单位合并报的(devdocs/19)。 */ /** 归属份额(联队):复核时也要看得出这一条其实是几个单位合并报的(devdocs/19)。 */
teamShares: TeamShareView[] teamShares: TeamShareView[]
athleteName: string athleteName: string
@@ -145,6 +149,14 @@ function teamLabel(teamId: number, fallback: string): string {
return team?.shortName ?? team?.name ?? fallback return team?.shortName ?? team?.name ?? fallback
} }
/**
* 复核表「单位」列的单位名:填了队名就拼在后面(`第一分公司二队`,不加括号——大屏列宽紧张),
* 队名为空时与改造前逐字一样。
*/
function teamCellText(row: DraftRow): string {
return teamSquadLabel(row.teamShort || row.teamName, row.squad)
}
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */ /** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
function isFallback(error: unknown): boolean { function isFallback(error: unknown): boolean {
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code) return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
@@ -159,7 +171,10 @@ function unitOf(type: Event['resultType']): string {
} }
function rowLabel(row: DraftRow, index: number): string { function rowLabel(row: DraftRow, index: number): string {
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}` // 团体行带上队名:同一单位的两个队只差这几个字,否则两行的报错文案一模一样。
// teamShort 为空时维持原来的 #id 占位(不拿全称顶上),老数据的文案一个字都不变。
const team = row.teamShort === '' ? '' : teamSquadLabel(row.teamShort, row.squad)
const name = row.athleteName || row.memberNames?.join('、') || team || `#${row.entryId}`
return `第 ${index + 1} 行 ${name}` return `第 ${index + 1} 行 ${name}`
} }
@@ -267,6 +282,8 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
teamId: entry.teamId, teamId: entry.teamId,
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'), teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
teamShort: entry.shortName ?? '', teamShort: entry.shortName ?? '',
// 老服务端不下发 squad 时按空串(= 不区分队伍),显示与升级前逐字一致。
squad: normalizeSquad(entry.squad),
teamShares: normalizeTeamShares(entry.teamShares, { teamShares: normalizeTeamShares(entry.teamShares, {
teamId: entry.teamId, teamId: entry.teamId,
weight: 1, weight: 1,
@@ -632,14 +649,16 @@ watch(
<span <span
v-if="isJointTeam(row.teamShares)" v-if="isJointTeam(row.teamShares)"
class="team-cell is-joint" class="team-cell is-joint"
:title="jointTeamLabel(row.teamShares)" :title="teamSquadLabel(jointTeamLabel(row.teamShares), row.squad)"
> >
<span class="mx-badge mx-badge-info">联队</span> <span class="mx-badge mx-badge-info">联队</span>
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member"> <span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
{{ member.teamName }} {{ member.percent }}% {{ member.teamName }} {{ member.percent }}%
</span> </span>
<span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span>
</span> </span>
<span v-else class="team-cell">{{ row.teamShort || row.teamName }}</span> <!-- 单单位:填了队名就拼在单位名后面(不加括号),没填时与改造前逐字一样。 -->
<span v-else class="team-cell">{{ teamCellText(row) }}</span>
</td> </td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td> <td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td class="mono">{{ row.resultText || '—' }}</td> <td class="mono">{{ row.resultText || '—' }}</td>
+160
View File
@@ -0,0 +1,160 @@
import { ApiError } from '@/shared'
import type { ApiErrorCode, Entry } from '@/shared'
import { normalizeSquad } from '@/shared'
/**
* 「队伍」(squad)改名:`PUT /api/entries/{id}/squad`(见迁移 0025)。
*
* 队名过去是报名时一次写死的(导入表 / 补报名),报完就再也改不了:同一个单位把「一队」
* 写成「青年队」只能删了重报,而重报会把已经录进去的成绩一起丢掉。这个写请求把队名变成
* 一件随时可改的归属配置,与成员单位、份额放在同一个弹窗里——那本来就是「这条报名归谁」的地方。
*
* 网络层直接 fetch 而不是走 adminApi:与 entryShares.ts / entryBatch.ts 同一套理由(请求构造与收口
* 都在这一个模块里,能直接单测),CSRF 与 X-Request-Id 的补法也必须与 shared/api.ts 的写请求一致,
* 否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。
*/
export function entrySquadPath(entryId: number): string {
return `/api/entries/${entryId}/squad`
}
/**
* 这条报名的队名能不能改:只对**纯团体报名**(没有运动员的那种)放开。
*
* 与服务端同口径——迁移 0025 的唯一索引 (组别, 单位, 队名) 只建在 `athlete_id IS NULL` 上:
* 个人项目按人报名,一个人在同一组别只能有一条,队名对它没有意义,服务端也会回 400。
* 界面上先拦住,别让操作员对着一个注定失败的输入框白点一次保存。
*/
export function entrySquadEditable(row: { athleteId: number | null }): boolean {
return row.athleteId === null
}
/**
* 打开弹窗时输入框的初值:这条报名当前的队名。
*
* 空串(老数据 / 单队单位)= 不区分队伍,也是这里不填时的值——与升级前的行为逐字一致。
*/
export function squadDraftOf(row: { squad?: string | null }): string {
return normalizeSquad(row.squad)
}
/**
* 队名有没有改动。
*
* 比的是**归一后**的值:首尾空白服务端会 trim 掉,用户多打一个空格再删掉不算改动——
* 算了改动会让保存按钮亮起来,点一下发一个什么都没改的请求出去。
*/
export function squadDraftDirty(draft: string, original: string): boolean {
return normalizeSquad(draft) !== normalizeSquad(original)
}
const KNOWN_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
const safe: ApiErrorCode =
typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
return new ApiError(safe, message, status, detail)
}
function readCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
}
function defaultRequestId(): string {
const cryptoApi: Crypto | undefined = globalThis.crypto
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
interface EntrySquadResult {
ok: boolean
data?: unknown
error?: unknown
}
export interface EntrySquadOutcome {
entry: Entry | null
/**
* 服务端回显的队名(已归一,空串 = 不区分队伍——用户清空了队名也是合法值)。
*
* 响应里没有这个字段(老服务端)时为 null,调用方退回手里那份草稿。
*/
squad: string | null
}
/**
* 改名(空串 = 不区分队伍)。写操作不降级到示例数据:示例数据模式下的「保存成功」
* 会让操作员以为队名已经改好了,页面必须先看 data.isMock 再决定是否调用这里。
*
* 400 时服务端回的是**给人看的中文**(其中「队名与同组别同单位的另一条报名重复」那条会说清
* 是哪个单位哪个队),这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。
*/
export async function submitEntrySquad(
entryId: number,
squad: string,
requestId?: string,
): Promise<EntrySquadOutcome> {
// 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。
// 两处各生成一个就永远命中不了缓存,超时重试会把同一次改动落两遍。
const key = requestId ?? defaultRequestId()
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'X-Request-Id': key,
}
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
let response: Response
try {
response = await fetch(entrySquadPath(entryId), {
method: 'PUT',
headers,
credentials: 'include',
// 队名发归一后的值(首尾空白服务端也会 trim,先去掉就不会因为一个空格被当成改动)。
body: JSON.stringify({ squad: normalizeSquad(squad), requestId: key }),
})
} catch (caught) {
throw apiError('NETWORK', '无法连接服务端,队名没有改成功', 0, caught)
}
let body: unknown
try {
body = await response.json()
} catch (caught) {
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
}
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
}
const payload = body as unknown as EntrySquadResult
if (payload.ok !== true) {
const error = isRecord(payload.error) ? payload.error : {}
const message =
typeof error['message'] === 'string' ? error['message'] : `保存队名失败(HTTP ${response.status})`
throw apiError(error['code'], message, response.status, error['detail'])
}
const data = isRecord(payload.data) ? (payload.data as unknown as Entry) : null
return {
entry: data,
squad: data !== null && typeof data.squad === 'string' ? normalizeSquad(data.squad) : null,
}
}
+129 -4
View File
@@ -1,4 +1,5 @@
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared' import { isJointTeam, jointTeamLabel } from '@/shared'
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView, TeamShareView } from '@/shared'
/** /**
* 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。 * 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。
@@ -7,6 +8,9 @@ import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared
* `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。 * `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。
* 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」, * 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」,
* 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。 * 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。
*
* 联队(一条报名挂多个单位,devdocs/19)也在这里收口:该组别有联队报名时,成员单位
* 合并成主单位那一行(见 mergeJointTeamRankRows),否则每个单位各占一行。
*/ */
export interface TeamRankRow { export interface TeamRankRow {
@@ -23,6 +27,13 @@ export interface TeamRankRow {
input: string | number input: string | number
/** 载入时的原文:用来判断这一行有没有被改过。 */ /** 载入时的原文:用来判断这一行有没有被改过。 */
original: string original: string
/**
* 联队行的成员单位(顺序与报名份额一致);普通单位行是空数组。
*
* 非空即表示「这一行代表多个单位」,名次写到 `teamId`(联队主单位)上,成员单位不再
* 单独占一行。空数组的普通行渲染与今天一字不差,页面据此分支。
*/
members: TeamShareView[]
} }
/** /**
@@ -79,7 +90,8 @@ export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName })) .map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName }))
return source.map((team) => { return source.map((team) => {
const input = inputOfRank(ranks.get(team.teamId)) const input = inputOfRank(ranks.get(team.teamId))
return { ...team, input, original: input } // 这里先按「一个单位一行」铺开,联队合并在 mergeJointTeamRankRows 里做(它还要看报名)。
return { ...team, input, original: input, members: [] }
}) })
} }
@@ -89,11 +101,124 @@ export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean {
return rows.some((row) => String(row.input) !== row.original) return rows.some((row) => String(row.input) !== row.original)
} }
// ── 联队(devdocs/19):一条报名挂多个单位时,把成员单位合并成一行 ─────────────────
/**
* 联队判定要读的报名字段。
*
* 录入页表格里的行(EditableRow)结构上就有这两个,所以页面直接把该组别的报名行
* 传进来即可——份额早就在表格数据里,不必为它多开一个接口。
*/
export interface TeamRankEntryRef {
/** 报名的主单位(entries.team_id):联队的名次录在这一行。 */
teamId: number
/** 归属份额(读侧恒为至少一条):多于一条就是「一条报名挂多个单位」的联队报名。 */
teamShares: readonly TeamShareView[]
}
/** 联队行的编码:成员编码用 + 连起来(与导入 / 导出写 `A+B` 同一写法)。 */
function jointTeamCode(members: readonly TeamShareView[]): string {
return members
.map((member) => member.teamCode.trim())
.filter((code) => code !== '')
.join('+')
}
/** 把主单位那一行改写成联队行:名字走 jointTeamLabel(与大屏 / 导出同一份实现)。 */
function jointRankRow(row: TeamRankRow, members: readonly TeamShareView[]): TeamRankRow {
const code = jointTeamCode(members)
const line: TeamRankRow = {
...row,
code: code === '' ? row.code : code,
// 联队名已经把成员单位写全,简称反而是少一家,所以这一行的名字一律走全名那一份。
name: jointTeamLabel(members),
shortName: null,
members: [...members],
}
// input 必须**写回主单位原来那一行**,不能在副本上停着:v-model 直接绑它,未保存判断
// (teamRanksDirty)与保存载荷(teamRanksPayload)读的也都是原行。副本上的输入只活在
// 这一个对象里,行一重建(切组别、改份额、保存后回填)就没了,保存按钮也不会亮——
// 现场看起来就是「填了名次却存不下去」。
Object.defineProperty(line, 'input', {
get: () => row.input,
set: (value: string | number) => {
row.input = value
},
enumerable: true,
configurable: true,
})
return line
}
/**
* 把「联队报名」的成员单位合并成一行。
*
* 引擎的口径是**联队的名次录在主单位那一行**(`domain.collectTeamManualCandidates` 拿主单位
* 那条名次按份额拆给各成员单位)。界面上把成员单位各列一行不只是显示问题:操作员顺手给
* 成员单位也录一个名次,那个单位会再拿一份分(重复计分)。合并成一行正好把这个坑堵死——
* 成员单位不再有属于自己的输入框,也就没有地方录第二份名次。
*
* 只在**该组别真有联队报名**时才动列表:没有联队时按原数组返回(连对象都不换一个),
* 于是没有联队的组别与今天逐字段一致。
*
* 成员单位自己的行在两种情况下照旧保留,免得静默吃掉一条报名:
* 1. 它以主单位身份另有报名——自己也是这个组别里的一条参赛单位,独立于联队那一条
* (它同时还是另一个联队的主单位时也走这一条,那一行就是那个联队的名次);
* 2. 主单位那一行根本不在列表里(单位停用 / 假删除会被接口过滤掉):这时宁可不合并,
* 也不让成员单位凭空消失,否则这个组别的团体名次就没地方录了。
*/
export function mergeJointTeamRankRows(
rows: readonly TeamRankRow[],
entries: readonly TeamRankEntryRef[],
): TeamRankRow[] {
const groups = new Map<number, TeamShareView[]>()
for (const entry of entries) {
if (!isJointTeam(entry.teamShares)) continue
// 同一主单位出现两条联队报名时只认第一条:引擎遇到份额不一致的两条会退回独占,
// 界面这边少合并一个顶多是不显示联队名,不会把分拆错。
if (groups.has(entry.teamId)) continue
groups.set(entry.teamId, [...entry.teamShares])
}
if (groups.size === 0) return [...rows]
const byTeamId = new Set(rows.map((row) => row.teamId))
const ownEntry = new Set(entries.map((entry) => entry.teamId))
const swallowed = new Set<number>()
for (const [primary, members] of groups) {
if (!byTeamId.has(primary)) continue
for (const member of members) {
if (member.teamId === primary || ownEntry.has(member.teamId)) continue
swallowed.add(member.teamId)
}
}
return rows
.filter((row) => !swallowed.has(row.teamId))
.map((row) => {
const members = groups.get(row.teamId)
return members === undefined ? row : jointRankRow(row, members)
})
}
/**
* 被并进联队行、不再单独占行的成员单位里**原来录过名次**的那些名字。
*
* 它们的名次会在下一次保存(整组替换)时随载荷一起被清掉——这正是要堵的重复计分,
* 但那是操作员的库里本来就有的数,界面上得说出来,不能悄悄改。
*/
export function mergedAwayRankNames(base: readonly TeamRankRow[], shown: readonly TeamRankRow[]): string[] {
const kept = new Set(shown.map((row) => row.teamId))
return base
.filter((row) => !kept.has(row.teamId) && parseTeamRankInput(row.input) >= 1)
.map((row) => row.shortName || row.name)
}
/** /**
* 保存载荷:整组回传。 * 保存载荷:整组回传。
* *
* 服务端按 (小项, 组别, scope, basis) 整组替换,所以每个单位都要出现, * 服务端按 (小项, 组别, scope, basis) 整组替换,所以**页面上看到的每一行**都要出现,
* 没填的显式给 0(清空)——漏掉单位等于把它原来的名次留在库里,页面看着却像已经清掉了。 * 没填的显式给 0(清空)——漏掉一行等于把它原来的名次留在库里,页面看着却像已经清掉了。
* 行由 mergeJointTeamRankRows 给:联队只出主单位这一行,被并掉的成员单位**故意**不在载荷里,
* 它们原来那份名次会随之清掉(那正是要堵的重复计分)。
*/ */
export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput { export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput {
return { return {
+6 -1
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode' import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode'
import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../api/scoring'
// 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。 // 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
@@ -69,7 +70,11 @@ test('录入页只在存在 aggregate 通道时显示口径选择,并整体回
}) })
test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => { test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => {
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/) // 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts 的 CHANNEL_*_LABELS),
// 所以这里同时钉住两件事:页面用的是这份常量,常量就是现场认识的那句话。
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: CHANNEL_BASIS_LABELS\.aggregate \}/)
assert.equal(CHANNEL_BASIS_LABELS.aggregate, '按队内平均')
assert.equal(CHANNEL_SCOPE_LABELS.team, '团体名次')
assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/) assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/)
assert.match(CHANNELS_SOURCE, /计入单位总分/) assert.match(CHANNELS_SOURCE, /计入单位总分/)
assert.match(CHANNELS_SOURCE, /只记在个人头上/) assert.match(CHANNELS_SOURCE, /只记在个人头上/)
@@ -0,0 +1,38 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { compareConflictMessage } from '../components/baseData/channelRows'
/**
* 管理端保存计分通道前的守卫:compare=none 的小项不得配 basis=result / aggregate。
*
* 规则包导入侧早有这条(server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧
* 见 server/internal/httpapi/repo_events.go——手工在管理端配一直没人拦,一直配到发布才 409。
*/
const MANUAL_EVENT = { name: '台球', compare: 'none' as const }
test('compare=none 配 basis=result / aggregate:给出人话原因并点名小项', () => {
for (const basis of ['result', 'aggregate'] as const) {
const message = compareConflictMessage(MANUAL_EVENT, basis)
assert.ok(message, `${basis} 必须被拦下`)
assert.match(message, /台球/)
assert.match(message, /不比较/)
assert.match(message, /手动录名次/)
}
})
test('compare=none 配 basis=manual:放行', () => {
assert.equal(compareConflictMessage(MANUAL_EVENT, 'manual'), null)
})
test('有比较方向的小项:三种 basis 一律放行', () => {
for (const compare of ['asc', 'desc'] as const) {
for (const basis of ['result', 'manual', 'aggregate'] as const) {
assert.equal(compareConflictMessage({ name: '100 米', compare }, basis), null)
}
}
})
test('还没选小项时不拦(页面显示的是空态)', () => {
assert.equal(compareConflictMessage(null, 'result'), null)
})
+170 -24
View File
@@ -6,6 +6,7 @@ import {
MANDATORY_COLUMNS, MANDATORY_COLUMNS,
OPTIONAL_COLUMNS, OPTIONAL_COLUMNS,
REQUIRED_COLUMNS, REQUIRED_COLUMNS,
SQUAD_COLUMN,
buildSelectionRows, buildSelectionRows,
buildTeamSelectionRows, buildTeamSelectionRows,
buildTemplateCsv, buildTemplateCsv,
@@ -17,9 +18,11 @@ import {
missingColumnMessage, missingColumnMessage,
registeredAthleteIds, registeredAthleteIds,
registeredTeamIds, registeredTeamIds,
registeredTeamSquads,
selectableTeamIds, selectableTeamIds,
selectionAllowsSubmit, selectionAllowsSubmit,
setPickedTeams, setPickedTeams,
setTeamPickSquad,
teamSelectionAllowsSubmit, teamSelectionAllowsSubmit,
templateFileName, templateFileName,
templateHeader, templateHeader,
@@ -37,24 +40,26 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c') assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
}) })
test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => { test('表头含证件号 / 性别 / 年龄 / 批次 / 队伍可选列,必填列不含这五列', () => {
assert.deepEqual( assert.deepEqual(
[...REQUIRED_COLUMNS], [...REQUIRED_COLUMNS],
[ [
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
'bib', 'seed', 'heat', 'status', 'member_names', 'bib', 'seed', 'heat', 'status', 'member_names', 'squad',
], ],
) )
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后') assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后') assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat']) assert.equal(REQUIRED_COLUMNS.at(-1), SQUAD_COLUMN, '队伍列在最后:老表格一列都不会错位')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat', 'squad'])
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false) assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
assert.equal(MANDATORY_COLUMNS.includes('age'), false) assert.equal(MANDATORY_COLUMNS.includes('age'), false)
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批') assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
assert.equal(MANDATORY_COLUMNS.includes('squad'), false, '队伍列可缺省,缺省即不区分队伍')
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true) assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
}) })
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => { test('buildSelectionRows 个人项目:一人一行,共 13 列并带上证件号 / 号码布', () => {
const rows = buildSelectionRows({ const rows = buildSelectionRows({
eventCode: 'M100', eventCode: 'M100',
categoryName: '男子组', categoryName: '男子组',
@@ -68,13 +73,14 @@ test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件
}) })
assert.equal(rows.length, 4, '表头 + 3 名运动员') assert.equal(rows.length, 4, '表头 + 3 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头') assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 12) assert.equal(rows[1]?.length, 13)
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。 // 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排);
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '']) // 队伍列留空 = 个人报名没有「第几支队」这回事。
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '', ''])
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。 // 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '']) assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '', ''])
// 只有证件号时 bib 列留空。 // 只有证件号时 bib 列留空。
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '']) assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '', ''])
}) })
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => { test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
@@ -86,8 +92,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }], athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
}) })
assert.equal(rows.length, 2, '表头 + 1 条团体报名') assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 12) assert.equal(rows[1]?.length, 13)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨']) assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨', ''])
}) })
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => { test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
@@ -100,7 +106,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
athletes: [{ name: '王芳' }], athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'], memberNames: ['刘洋', '王芳'],
}) })
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳']) assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳', ''])
}) })
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => { test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
@@ -111,7 +117,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
mode: 'individual', mode: 'individual',
athletes: [{ name: '无标识' }], athletes: [{ name: '无标识' }],
}) })
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '']) assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '', ''])
}) })
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。 // 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
@@ -124,7 +130,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', ()
athletes: [], athletes: [],
}) })
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名') assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '']) assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
}) })
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => { test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
@@ -147,13 +153,14 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,
}) })
assert.equal(rows.length, 4, '表头 + 3 个单位') assert.equal(rows.length, 4, '表头 + 3 个单位')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头') assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 12) assert.equal(rows[1]?.length, 13)
for (const row of rows.slice(1)) { for (const row of rows.slice(1)) {
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert') assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
assert.equal(row[12], '', '不填队名时队伍列留空 = 不区分队伍')
} }
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋']) assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', ''])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '']) assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟']) assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟', ''])
}) })
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => { test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
@@ -165,7 +172,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可
status: 'scratched', status: 'scratched',
teams: [{ teamCode: 'F1', memberNames: [] }], teams: [{ teamCode: 'F1', memberNames: [] }],
}) })
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '']) assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '', ''])
}) })
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => { test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
@@ -283,8 +290,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行',
const lines = toCsv(rows).split('\n') const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(',')) assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines.length, 3, '表头 + 两个单位') assert.equal(lines.length, 3, '表头 + 两个单位')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义') assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强",', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空') assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,', '只报单位时 member_names 留空')
}) })
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => { test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
@@ -303,6 +310,135 @@ test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], []) assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
}) })
// ── 队伍(迁移 0025):同一个单位在一个组别可以派多个队 ────────────────────────
test('不填队名时报名行为与改造前完全一致:队伍列留空、其余列一个字都没变', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳'], squad: '' },
{ teamCode: 'F2', memberNames: [] },
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '表头就是导入表本身(队伍列在最后、可空)')
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳', ''])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
assert.equal(SQUAD_COLUMN, 'squad', '列名是服务端 importColumns 里的规范名')
assert.equal(lookupImportColumn('队伍'), SQUAD_COLUMN, '中文表头「队伍」也认')
assert.equal(lookupImportColumn('队名'), SQUAD_COLUMN)
})
test('填了队名:队名进最后一列,其它列一个字不动', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'], squad: '一队' },
// 同一个单位换个队名 = 这个单位的第二支队(服务端按 组别 + 单位 + 队伍 upsert)。
{ teamCode: 'F1', memberNames: [], squad: '二队' },
// 没填队名的单位照样一行,队名格留空。
{ teamCode: 'F2', memberNames: ['张伟'] },
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', '一队'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '', '二队'])
assert.deepEqual(rows[3], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '张伟', ''])
for (const line of toCsv(rows).split('\n')) {
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length)
}
})
test('队名两侧空白去掉再上表:服务端存的就是去空白后的文本', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [{ teamCode: 'F1', memberNames: [], squad: ' 二队 ' }],
})
assert.equal(rows[1]?.[REQUIRED_COLUMNS.indexOf(SQUAD_COLUMN)], '二队')
})
test('setTeamPickSquad 只改这一个单位的队名,逐个字输入都保留', () => {
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = setTeamPickSquad(state, 1, '一')
state = setTeamPickSquad(state, 1, '一队')
assert.equal(state.squads?.[1], '一队')
assert.equal(state.squads?.[2], undefined, '另一个单位没填')
assert.deepEqual(state.teamIds, [1, 2], '填队名不动勾选')
// 清空输入框 = 改回不区分队伍,与从没填过一样。
assert.equal(setTeamPickSquad(state, 1, '').squads?.[1], '')
})
test('buildTeamSelections:填了队名的单位带上 squad,没填的与改造前逐字段相同', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
const athletes = [{ id: 11, teamId: 1, name: '张伟', active: true }]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'] },
{ teamCode: 'F2', memberNames: [] },
])
const withSquad = setTeamPickSquad(state, 1, ' 二队 ')
assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'], squad: '二队' },
{ teamCode: 'F2', memberNames: [] },
])
})
test('勾选状态 → CSV 端到端:一个单位两个队就是两行,队名在最后一列', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = setTeamPickSquad(state, 1, '一队')
state = setTeamPickSquad(state, 2, '青年队')
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: buildTeamSelections(state, teams, []),
})
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,,一队')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,青年队')
})
test('registeredTeamSquads:按组别列出各单位已报名的队名,老报名(空串)不进表', () => {
const entries = [
{ categoryId: 7, teamId: 1, squad: '一队' },
{ categoryId: 7, teamId: 1, squad: '二队' },
{ categoryId: 7, teamId: 1, squad: '一队' },
{ categoryId: 7, teamId: 2, squad: '' },
{ categoryId: 7, teamId: 3 },
{ categoryId: 8, teamId: 4, squad: '一队' },
]
const squads = registeredTeamSquads(entries, 7)
assert.deepEqual(squads.get(1), ['一队', '二队'], '按报名表出现顺序、去重')
assert.equal(squads.has(2), false, '不区分队伍的老报名不进表,徽标仍只说「已报名」')
assert.equal(squads.has(3), false, '缺字段(老服务端)同样当空串')
assert.equal(squads.has(4), false, '别的组别不算')
assert.deepEqual([...registeredTeamSquads(entries, undefined)], [])
})
test('entryRowLabel 团体行带上队名:同一单位的两个队在报错文案里也分得开', () => {
assert.equal(entryRowLabel({ teamShort: '一司', squad: '二队' }), '一司二队')
assert.equal(entryRowLabel({ teamShort: '一司', teamName: '第一分公司', squad: '' }), '一司', '没队名时不变')
assert.equal(entryRowLabel({ teamName: '第一分公司', squad: '青年队' }), '第一分公司青年队')
assert.equal(entryRowLabel({ athleteName: '张伟', teamShort: '一司', squad: '二队' }), '张伟', '个人行只写姓名')
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名, // 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。 // 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => { test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
@@ -321,8 +457,9 @@ test('两种语言的模板表头逐列对应:列数、顺序一致,只有
assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名') assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名')
assert.equal(zh.length, en.length, '两种模板列数必须一样') assert.equal(zh.length, en.length, '两种模板列数必须一样')
assert.deepEqual(zh, [ assert.deepEqual(zh, [
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
]) ])
assert.equal(zh.at(-1), '队伍', '队伍列也列在模板最后,与服务端 importColumns 同序')
// 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。 // 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。
assert.deepEqual( assert.deepEqual(
zh, zh,
@@ -340,7 +477,7 @@ test('模板 CSV:只有表头语言不同,示例行与列数完全相同', (
assert.equal(en[0], templateHeader('en').join(',')) assert.equal(en[0], templateHeader('en').join(','))
assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言') assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言')
for (const line of zh) { for (const line of zh) {
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 12 列') assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 13 列')
} }
}) })
@@ -357,6 +494,7 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM /
assert.equal(lookupImportColumn('项目编码'), 'event_code') assert.equal(lookupImportColumn('项目编码'), 'event_code')
assert.equal(lookupImportColumn('小项编码'), 'event_code') assert.equal(lookupImportColumn('小项编码'), 'event_code')
assert.equal(lookupImportColumn('队员名单'), 'member_names') assert.equal(lookupImportColumn('队员名单'), 'member_names')
assert.equal(lookupImportColumn('队伍'), 'squad', '队伍列的中文表头')
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感') assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感')
assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格') assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格')
assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上') assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上')
@@ -368,13 +506,21 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM /
test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => { test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => {
const index = importColumnIndex([ const index = importColumnIndex([
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', 'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
]) ])
assert.equal(index.get('event_code'), 0) assert.equal(index.get('event_code'), 0)
assert.equal(index.get('category'), 1) assert.equal(index.get('category'), 1)
assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取') assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取')
assert.equal(index.get('squad'), 12, '队伍列在最后')
assert.equal(index.size, REQUIRED_COLUMNS.length) assert.equal(index.size, REQUIRED_COLUMNS.length)
// 老表格(12 列、没有队伍列)照样认得:队伍是可选列,缺列 = 不区分队伍(老流程一个字不用改)。
const legacy = importColumnIndex([
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'heat', 'status', 'member_names',
])
assert.equal(legacy.size, REQUIRED_COLUMNS.length - 1)
assert.equal(legacy.has('squad'), false)
for (const header of [ for (const header of [
['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'], ['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'], ['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
+257
View File
@@ -0,0 +1,257 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { ApiError, teamSquadLabel } from '@/shared'
import {
entrySquadEditable,
entrySquadPath,
squadDraftDirty,
squadDraftOf,
submitEntrySquad,
} from '../pages/entrySquad'
/**
* 管理端的队伍展示、录入与改名(迁移 0025)。
*
* SFC 挂不进 `node --test`,所以这里用源码契约钉住接线;口径本身(拼名字、空串不变)在
* shared/tests/squad.test.ts 与大屏 joint-teams.test.ts 里逐条测。要守住的是四件事:
* 1. 录入页 / 复核页的「单位」列显示 **单位名 + 队名**(不加括号);
* 2. 队名为空时那几个表达式与改造前逐字相同(老数据与单队单位一个像素都不变);
* 3. 队名是在补报名(团体勾选)里填的,**勾上单位之后**才出现那个输入框,
* 不勾(常规流程)时界面上根本看不到它;
* 4. 已经报好的队名在「成员单位与份额」弹窗里能改(PUT /api/entries/{id}/squad),
* 但只对纯团体报名出现,改哪个才发哪个请求,服务端的中文 400 原样上屏。
*/
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
test('录入页:单位列显示「单位名 + 队名」,队名来自报名行', () => {
assert.match(
ENTRY,
/function teamCellText\(row: EditableRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/,
'拼名字只有 shared/squad.ts 一份实现',
)
assert.match(ENTRY, /\{\{ teamCellText\(row\) \}\}/, '单位格用带队名的文案')
assert.doesNotMatch(ENTRY, /\{\{ row\.teamShort \|\| row\.teamName \}\}/, '老写法不能再留在单位格上')
assert.match(ENTRY, /squad: normalizeSquad\(entry\.squad\)/, '老服务端不下发 squad 时按空串')
})
test('录入页:批量确认框里的单位也带上队名(同一个单位的两个队要分得清删的是哪条)', () => {
assert.match(ENTRY, /team: teamCellText\(row\),/)
})
test('录入页:联队那一格仍按份额逐行列,队名单独一行写出来', () => {
assert.match(ENTRY, /teamSquadLabel\(jointTeamLabel\(row\.teamShares\), row\.squad\)/, '联队的悬停文案带队名')
assert.match(ENTRY, /v-if="row\.squad !== ''"[\s\S]{0,80}队伍 \{\{ row\.squad \}\}/)
})
test('复核页:单位列同样显示「单位名 + 队名」', () => {
assert.match(
REVIEW,
/function teamCellText\(row: DraftRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/,
)
assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/)
assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/)
assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/)
assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
})
test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
assert.match(PICKER, /v-if="isTeamPicked\(team\.id\)"/, '输入框跟着勾选出现')
assert.match(PICKER, /:value="squadTextOf\(team\.id\)"/, '回显输入框原文')
assert.match(PICKER, /@input="onSquadInput\(team\.id, \$event\)"/)
assert.match(PICKER, /teamPick\.value = setTeamPickSquad\(teamPick\.value, teamId, input\.value\)/)
assert.match(PICKER, /placeholder="队伍(留空=不区分)"/, '留空 = 不区分队伍,要写出来')
// 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。
assert.doesNotMatch(PICKER, /team-squad-input[\s\S]{0,200}type="number"/)
})
test('补报名:已报名徽标写清已报过哪些队名,避免把二队又写成一队', () => {
assert.match(PICKER, /registeredTeamSquads\(entries\.value, currentCategory\.value\?\.id\)/)
assert.match(PICKER, /registeredSquadLabel\(team\.id\)/)
})
// ── 改队名:PUT /api/entries/{id}/squad(入口在「成员单位与份额」弹窗里) ──────────────
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: unknown
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function withCsrfCookie(cookie: string, run: () => Promise<void>): Promise<void> {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie }
return run().finally(() => {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
})
}
test('改队名只对纯团体报名开放:个人报名(有运动员)连输入框都不出现', () => {
assert.equal(entrySquadEditable({ athleteId: null }), true, '纯团体行才能改队名')
assert.equal(entrySquadEditable({ athleteId: 42 }), false, '个人报名按人报,服务端也会 400')
// 弹窗里的那一块跟着这个判断走:个人报名整块不渲染,不会让人白点一次保存。
assert.match(ENTRY, /v-if="shareEditor\.teamEntry" class="share-squad"/)
assert.match(ENTRY, /placeholder="队伍(留空=不区分)"/)
assert.match(ENTRY, /teamEntry: entrySquadEditable\(row\),/)
assert.match(ENTRY, /squad: squadDraftOf\(row\),/)
assert.match(ENTRY, /originalSquad: squadDraftOf\(row\),/)
// 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。
assert.doesNotMatch(ENTRY, /squad-input[\s\S]{0,300}type="number"/)
})
test('队名留空 = 不区分队伍:老数据读出来是空串,写回去也是空串(不是不发这个字段)', async () => {
assert.equal(squadDraftOf({ squad: undefined }), '', '老服务端不下发 squad')
assert.equal(squadDraftOf({ squad: null }), '')
assert.equal(squadDraftOf({ squad: ' ' }), '', '只有空白等于没填')
assert.equal(squadDraftOf({ squad: ' 青年队 ' }), '青年队')
// 留空时单位列的文案与改造前逐字一样(队名一个字符都不多)。
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '' } }))
const outcome = await submitEntrySquad(7, '')
const body = lastRequest.body as { squad?: unknown }
assert.equal(body.squad, '', '空串是合法值:字段要在,不能被省略成 undefined')
assert.equal(outcome.squad, '', '服务端回显空串时本地也按不区分队伍写回')
})
test('队名没改动时不发请求:比的是归一后的值,多一个空格不算改', () => {
assert.equal(squadDraftDirty('', ''), false, '两边都留空 = 没动过')
assert.equal(squadDraftDirty('一队', '一队'), false)
assert.equal(squadDraftDirty(' 一队 ', '一队'), false, '首尾空白服务端也会 trim,不该算成一次改动')
assert.equal(squadDraftDirty(' ', ''), false)
assert.equal(squadDraftDirty('一队', ''), true)
assert.equal(squadDraftDirty('', '一队'), true, '清空队名也是一次改动(改回不区分队伍)')
assert.equal(squadDraftDirty('二队', '一队'), true)
// 保存按钮只在「份额或队名任一改了」时才亮(没改动就点不动,与既有做法一致)。
assert.match(ENTRY, /const shareEditorDirty = computed\(\(\) => shareChanged\.value \|\| squadChanged\.value\)/)
assert.match(ENTRY, /if \(editor === null \|\| !canSaveShares\.value\) return/)
assert.match(ENTRY, /shareEditorDirty\.value &&\s*shareIssues\.value\.length === 0/)
// 改哪一块发哪一个:只改队名时不会顺手把没动过的份额整组重发一遍。
assert.match(ENTRY, /const sharesDirty = shareChanged\.value\s*const squadDirty = squadChanged\.value/)
assert.match(ENTRY, /if \(sharesDirty\) \{[\s\S]{0,400}submitEntryTeamShares\(/)
assert.match(ENTRY, /if \(squadDirty\) \{[\s\S]{0,400}submitEntrySquad\(/)
})
test('保存走 PUT /api/entries/{id}/squad,队名 trim 后发出,带 CSRF 与同一个幂等键', async () => {
await withCsrfCookie('a=1; csrf_token=tok-9; b=2', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '青年队' } }))
const outcome = await submitEntrySquad(7, ' 青年队 ')
assert.equal(lastRequest.url, entrySquadPath(7))
assert.equal(lastRequest.url, '/api/entries/7/squad')
assert.equal(lastRequest.method, 'PUT')
assert.equal(lastRequest.credentials, 'include')
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
assert.deepEqual(lastRequest.body, {
squad: '青年队',
requestId: lastRequest.headers?.['X-Request-Id'],
})
assert.equal(outcome.entry?.id, 7)
assert.equal(outcome.squad, '青年队')
})
})
test('幂等键由调用方给定时 header 与 body 用同一把(超时重试不会落两遍)', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 7, squad: '二队' } }))
await submitEntrySquad(7, '二队', 'req-fixed-1')
assert.equal(lastRequest.headers?.['X-Request-Id'], 'req-fixed-1')
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, 'req-fixed-1')
})
test('回显里没有 squad 字段(老服务端)时交给调用方退回草稿,不把队名抹成空', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1 } }))
const outcome = await submitEntrySquad(7, '青年队')
assert.equal(outcome.squad, null, 'null 而不是空串:空串是「用户清空了队名」这个合法值')
assert.match(ENTRY, /const saved = outcome\.squad \?\? editor\.squad/)
})
test('保存后当场改本地那一行:单位列文案从「第一分公司」变成「第一分公司青年队」', () => {
assert.match(ENTRY, /if \(row !== undefined\) row\.squad = saved/)
assert.match(ENTRY, /editor\.squad = saved\s*editor\.originalSquad = saved/, '保存过的值成为新的初值')
// 单位列的文案只认这一份实现:row.squad 一改,表格与大屏同时跟着变。
assert.match(ENTRY, /return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/)
// 输入框旁边先念一遍改完长什么样(空串时就是单位名本身)。
assert.match(ENTRY, /return editor === null \? '' : teamSquadLabel\(editor\.teamCellBase, editor\.squad\)/)
assert.match(ENTRY, /teamCellBase: row\.teamShort \|\| row\.teamName,/)
assert.equal(teamSquadLabel('第一分公司', '青年队'), '第一分公司青年队')
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
})
test('服务端 400 的中文原文原样抛出(尤其「队名重复」那条,界面直接展示这句话)', async () => {
const message = '组别「男子双打」下单位「第一分公司」已经有「一队」了:同一个单位在同一组别里队名不能重复'
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message } }, 400))
await assert.rejects(
() => submitEntrySquad(7, '一队'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.equal(error.message, message, '一个字都不能被前端改写')
return true
},
)
// 弹窗直接把这句话放到错误条里,不换成自己的措辞。
assert.match(ENTRY, /editor\.error = caught instanceof Error \? caught\.message/)
assert.match(ENTRY, /\{\{ shareEditor\.error \}\}/)
})
test('报名不存在 / 网络不可达都说清「队名没有改成功」,不让人以为已经落库', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '报名不存在或已被移出' } }, 404),
)
await assert.rejects(
() => submitEntrySquad(999, '一队'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.message, '报名不存在或已被移出')
return true
},
)
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntrySquad(7, '一队'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /队名没有改成功/)
return true
},
)
})
@@ -0,0 +1,289 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { Event, ScoreChannel, ScoringProfile, TeamRankEntry } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
/**
* 「按单位录名次」(scope=team + basis=manual)的预览口径,逐条对齐服务端
* domain/scoring.go 的 collectTeamManualCandidates 与 groupPoints。
*
* 现场报的 bug:台球按单位在录入页底部的「团体名次」卡片里录名次(channel_manual_ranks),
* 服务端算对了(六个单位合计 40 分),录入页的实时预览却每个单位都是 0——预览只读行上的
* rank,从不读 channel_manual_ranks。而那张预览又是发布确认框里「最大分差」的来源。
*/
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'BALL',
name: '台球',
eventType: 'team',
resultMode: 'manual',
resultType: 'none',
compare: 'none',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
}
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return { id: 1, eventId: 1, scope: 'team', basis: 'manual', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
}
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
}
/** 团体名次分表:与规则包 team_top6 同一张表,第 1–6 名 15/10/8/4/2/1(六个单位合计 40 分,正是现场那组数)。 */
const TEAM_PROFILE = makeProfile({
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 10 },
{ rank: 3, points: 8 },
{ rank: 4, points: 4 },
{ rank: 5, points: 2 },
{ rank: 6, points: 1 },
],
})
const SIX_UNITS: TeamRankEntry[] = [
{ teamId: 100, rank: 1 },
{ teamId: 101, rank: 2 },
{ teamId: 102, rank: 3 },
{ teamId: 103, rank: 4 },
{ teamId: 104, rank: 5 },
{ teamId: 105, rank: 6 },
]
function close(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
}
test('名次只填在「团体名次」卡片(channel_manual_ranks):六个单位合计 40 分', () => {
// 与服务端 httpapi 侧 team_manual_ranks_score_test.go 同输入同期望:同一份 channel_manual_ranks,
// 预览与引擎必须给出同一组数字。
// 团体名次不挂在任何报名项上:六个单位各录一个名次,成绩表里一行都没有 rank。
const rows: ScorableRow[] = [100, 101, 102, 103, 104, 105].map((teamId, index) => ({
entryId: index + 1,
teamId,
athleteId: index + 1,
resultValue: null,
rank: null,
status: 'ok',
isTie: false,
}))
const result = computeEventScore(
rows,
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
)
const expected = [15, 10, 8, 4, 2, 1]
let total = 0
for (const [index, teamId] of [100, 101, 102, 103, 104, 105].entries()) {
const points = result.teamPoints.get(teamId) ?? 0
close(points, expected[index] ?? 0)
total += points
}
close(total, 40)
})
test('团体手工通道只认按单位录的名次:填在个人行上的名次一律不进团体通道', () => {
// 服务端 collectTeamManualCandidates 只收 athlete_id == 0 的团体行;
// 预览若照旧读行上的 rank,就会出现「预览有分(10/9/8…)、服务端 0」。
const rows: ScorableRow[] = [10, 9, 8, 6, 4, 3].map((rank, index) => ({
entryId: index + 1,
teamId: 100 + index,
athleteId: index + 1,
resultValue: null,
rank,
status: 'ok',
isTie: false,
}))
const result = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
assert.equal(result.teamPoints.size, 0)
assert.equal(result.rowPoints.size, 0)
})
test('两个团体行的名次照旧生效,且被卡片里的名次覆盖', () => {
// 纯团体项目(篮球等)的成绩表兜底:团体行(athleteId 为 null)上录的名次。
const rows: ScorableRow[] = [
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
]
const fromRows = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
close(fromRows.teamPoints.get(100) ?? 0, 15)
close(fromRows.teamPoints.get(101) ?? 0, 10)
assert.equal(fromRows.ranks.get(1), 1)
// 同一单位两个来源都有时以 channel_manual_ranks 为准(服务端 overrideOf)。
const overridden = computeEventScore(
rows,
makeContext({
channels: [makeChannel()],
profiles: [TEAM_PROFILE],
teamRanks: [
{ teamId: 100, rank: 2 },
{ teamId: 101, rank: 1 },
],
}),
)
close(overridden.teamPoints.get(100) ?? 0, 10)
close(overridden.teamPoints.get(101) ?? 0, 15)
assert.equal(overridden.ranks.get(1), 2)
})
test('联队:按单位录的名次按份额拆给各成员单位(与服务端 jointTeamEntry + ledgerFromRanked 同口径)', () => {
// 服务端 joint_team_test.go 的场景:联队报名(A、B 各半)+ TeamRanks{主单位 A 第 1 名}、15 分表
// → A、B 各 7.5。名次挂在联队那条报名上(entryId 1),所以行分是整份 15,单位分才拆。
const profile = makeProfile({ rules: [{ rank: 1, points: 15 }] })
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: 0,
resultValue: null,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel()], profiles: [profile], teamRanks: [{ teamId: 100, rank: 1 }] }),
)
close(result.teamPoints.get(100) ?? 0, 7.5)
close(result.teamPoints.get(200) ?? 0, 7.5)
close(result.rowPoints.get(1) ?? 0, 15)
})
test('lastPlaceZero 只抹「起始名次正好落在末位」的那一组:两人并列第 1 不归零', () => {
// 服务端判据是 startRank == lastPlace(scoring.go groupPoints)。两人并列第 1 时
// 占位名次是 1、2,末位档 = 2,起始名次 1 != 2 → 照发第 1 名的分。
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 1)],
makeContext({
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
profiles: [profile],
}),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowPoints.get(2) ?? 0, 10)
close(result.teamPoints.get(100) ?? 0, 10)
close(result.teamPoints.get(101) ?? 0, 10)
})
test('lastPlaceZero 仍要抹掉真正的末位那一组', () => {
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2), makeRowOf(3, null, 102, 3)],
makeContext({
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
profiles: [profile],
}),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0)
})
test('compare=none 配 basis=result:预览报错,不静默按录入名次兜底', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
],
})
const result = computeEventScore(
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2)],
makeContext({
event: makeEvent({ eventType: 'individual', resultMode: 'manual' }),
channels: [makeChannel({ scope: 'athlete', basis: 'result' })],
profiles: [profile],
}),
)
assert.ok(result.error, '服务端 domain.ScoreEvent 对这个配置返回 ErrCompareRequired(发布 409),预览必须同样报错')
assert.match(result.error.message, /比较方向/)
assert.equal(result.error.channelLabel, '个人名次 / 按成绩自动排名')
// 报错时不再产出任何分:预览不该给一个服务端算不出来的配置算分。
assert.equal(result.teamPoints.size, 0)
assert.equal(result.rowPoints.size, 0)
})
test('compare=none 配 basis=aggregate:同样报错', () => {
const result = computeEventScore(
[makeRowOf(1, 20, 100, null), makeRowOf(2, 16, 101, null)],
makeContext({
event: makeEvent({ eventType: 'team', resultType: 'count' }),
channels: [makeChannel({ basis: 'aggregate' })],
profiles: [TEAM_PROFILE],
}),
)
assert.ok(result.error, '聚合通道同样要先按成绩排名,compare=none 时服务端返回 ErrCompareRequired')
assert.match(result.error.message, /比较方向/)
})
test('compare=none 配 basis=manual:照常算分,不报错', () => {
const result = computeEventScore(
[],
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
)
assert.equal(result.error, null)
close(result.teamPoints.get(100) ?? 0, 15)
})
function makeRowOf(entryId: number, resultValue: number | null, teamId: number, rank: number | null): ScorableRow {
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
}
// 录入页没有组件测试工具,接线部分按仓库既有做法(avg-mode.test.ts)读源码断言:
// 纯函数再有分,页面没把「团体名次」卡片喂进去,现场看到的还是 0。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
test('录入页把「团体名次」卡片喂进预览算分(接线掉了就等于没修)', () => {
assert.match(
ENTRY_SOURCE,
/const previewTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => teamRanksPayload\(teamRankRows\.value\)\.ranks\)/,
)
assert.match(ENTRY_SOURCE, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/)
assert.match(ENTRY_SOURCE, /const savedTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => \{/)
assert.match(ENTRY_SOURCE, /teamRanks,\n \}\)/)
})
test('录入页把「不挂在行上的单位分」从已发布基线里扣掉,差值才算得对', () => {
assert.match(ENTRY_SOURCE, /for \(const \[teamId, points\] of published\?\.unitPoints \?\? \[\]\)/)
assert.match(ENTRY_SOURCE, /const scoreConfigError = computed\(\(\) => score\.value\?\.error \?\? null\)/)
})
+6 -3
View File
@@ -87,7 +87,10 @@ test('lastPlaceZero 让末位整组 0 分', () => {
close(result.rowPoints.get(3) ?? 0, 0) close(result.rowPoints.get(3) ?? 0, 0)
}) })
test('末位并列整组都按最后一名处理', () => { test('跨末位的并列组不整组归零:只有起始名次正好落在末位的那一组才 0 分', () => {
// 与服务端 domain/scoring.go groupPoints 逐字同口径(Go 用例「lastPlaceZero 不牵连跨末位的并列组」):
// lastPlaceZero 的判据是 startRank == lastPlace。3 人里两人并列第 2(占 2、3),末位档 = 3,
// 起始名次 2 != 3 → 第 2 名的 6 分照发;整组归零会连带抹掉本可拿分的第 2 名。
const profile = makeProfile({ const profile = makeProfile({
lastPlaceZero: true, lastPlaceZero: true,
rules: [ rules: [
@@ -101,8 +104,8 @@ test('末位并列整组都按最后一名处理', () => {
makeContext({ channels: [makeChannel()], profiles: [profile] }), makeContext({ channels: [makeChannel()], profiles: [profile] }),
) )
close(result.rowPoints.get(1) ?? 0, 9) close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 0) close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0) close(result.rowPoints.get(3) ?? 0, 6)
}) })
test('scored=false 只排名不计分', () => { test('scored=false 只排名不计分', () => {
+139 -1
View File
@@ -1,15 +1,18 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import type { ScoreChannel, Team, TeamRanksView } from '@/shared' import { normalizeTeamShares, type ScoreChannel, type Team, type TeamRanksView } from '@/shared'
import { import {
hasTeamManualChannel, hasTeamManualChannel,
mergeJointTeamRankRows,
mergedAwayRankNames,
parseTeamRankInput, parseTeamRankInput,
teamRankFlaggedTeams, teamRankFlaggedTeams,
teamRankRowsOf, teamRankRowsOf,
teamRankWarnings, teamRankWarnings,
teamRanksDirty, teamRanksDirty,
teamRanksPayload, teamRanksPayload,
type TeamRankEntryRef,
} from '../pages/teamRanks' } from '../pages/teamRanks'
import { mockDb } from '../api/mock' import { mockDb } from '../api/mock'
@@ -48,6 +51,130 @@ function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
} }
} }
// ── 联队(devdocs/19):一条报名挂多个单位时,团体名次区块要把成员单位合并成一行 ──────
/** 一个成员单位的名字与编码(拼联队名 / 编码用)。 */
interface Member {
id: number
code: string
name: string
}
const JG: Member = { id: 1, code: 'JG', name: '机关本部' }
const F1: Member = { id: 2, code: 'F1', name: '第一分公司' }
const F3: Member = { id: 3, code: 'F3', name: '第三分公司' }
/** 一条报名:teamId 是主单位(entries.team_id),members 是份额里的成员单位(第一条即主单位)。 */
function makeEntry(teamId: number, members: readonly Member[]): TeamRankEntryRef {
return {
teamId,
teamShares: normalizeTeamShares(
members.map((member) => ({ teamId: member.id, weight: 1, teamCode: member.code, teamName: member.name })),
),
}
}
test('联队:成员单位合并成一行,标签用大屏 / 导出同一口径的联队名', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
assert.deepEqual(merged.map((row) => row.teamId), [1], '成员单位不再单独占一行')
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
assert.equal(merged[0]?.shortName, null)
assert.equal(merged[0]?.code, 'JG+F1', '编码用导入 / 导出的 A+B 写法')
assert.deepEqual(merged[0]?.members.map((member) => member.teamId), [1, 2])
})
test('联队:名次落在主单位那一行,成员单位不再进保存载荷(重复计分就此堵死)', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
assert.equal(merged.length, 1)
assert.equal(merged[0]?.input, '2', '主单位那一行原来录的名次回填到联队行')
assert.equal(teamRanksDirty(merged), false)
// 成员单位 2 库里那条名次 3 不再出现在载荷里:它不会被当成第二个名次主体。
assert.deepEqual(teamRanksPayload(merged), {
scope: 'team',
basis: 'manual',
ranks: [{ teamId: 1, rank: 2 }],
})
})
test('联队:输入框写回主单位那一行(副本会把草稿吞掉,保存按钮也不会亮)', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
merged[0]!.input = 4
assert.equal(rows[0]?.input, 4, '联队行的输入写回主单位那一行')
assert.equal(teamRanksDirty(merged), true)
assert.deepEqual(teamRanksPayload(merged).ranks, [{ teamId: 1, rank: 4 }])
})
test('组别里没有联队报名时,行与现在一字不差', () => {
const rows = teamRankRowsOf(makeView(), [])
// 单单位报名(份额只有一条)不是联队:连对象都不该换一个。
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])]), rows)
assert.equal(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])])[0], rows[0])
// 没有报名时同理。
assert.deepEqual(mergeJointTeamRankRows(rows, []), rows)
})
test('成员单位自己也有报名时两条都列出来,且分得清', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1])])
assert.deepEqual(merged.map((row) => row.teamId), [1, 2], '别静默吞掉第一条')
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
assert.equal(merged[1]?.name, '第一分公司')
assert.deepEqual(merged[1]?.members, [], '它自己那条不是联队')
assert.equal(merged[1], rows[1], '普通行原样透传')
})
test('一个单位同时是两个联队的成员单位时,两个联队各占一行', () => {
const rows = teamRankRowsOf(
makeView({
teams: [
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
{ teamId: 3, code: 'F3', name: '第三分公司', shortName: '三分公司' },
],
}),
[],
)
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1, F3])])
assert.deepEqual(merged.map((row) => row.teamId), [1, 2])
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
assert.equal(merged[1]?.name, '第一分公司+第三分公司联队')
})
test('主单位那一行不在列表里(单位停用 / 假删除)时不合并不吞', () => {
const rows = teamRankRowsOf(
makeView({ teams: [{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }] }),
[],
)
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]), rows)
})
test('参赛单位数按合并后的行数算', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
merged[0]!.input = '2'
assert.match(teamRankWarnings(merged).join(';'), /超出参赛单位数 1/)
assert.deepEqual([...teamRankFlaggedTeams(merged)], [1])
})
test('被并掉的成员单位原来录过的名次要点名(保存后会清掉,不静默改数)', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
assert.deepEqual(mergedAwayRankNames(rows, mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])), ['一分公司'])
// 没录过名次的成员单位不点名。
const clean = teamRankRowsOf(makeView(), [])
assert.deepEqual(mergedAwayRankNames(clean, mergeJointTeamRankRows(clean, [makeEntry(1, [JG, F1])])), [])
})
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => { test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
assert.equal(hasTeamManualChannel([makeChannel()]), true) assert.equal(hasTeamManualChannel([makeChannel()]), true)
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false) assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
@@ -219,3 +346,14 @@ test('录入页按契约接线:区块可见性、随行数据优先、保存
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/) assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/) assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
}) })
test('录入页按契约接线:联队成员合并成一行、名次写到主单位', () => {
// 表格行由本组别的报名行(teamShares 现成)合并而来,不为它多开一个接口。
assert.match(ENTRY_SOURCE, /mergeJointTeamRankRows\(teamRanksByCategory\.value\.get\(categoryId\) \?\? \[\], categoryRows\.value\)/)
// 联队行要能一眼认出来:徽标 + 成员单位与份额(与大屏 / 成绩表同一读法)。
assert.match(ENTRY_SOURCE, /v-if="row\.members\.length > 0" class="mx-badge mx-badge-info">联队</)
assert.match(ENTRY_SOURCE, /shareText\(member\)/)
// 被并掉的成员单位原来录过的名次要点名,别静默清掉。
assert.match(ENTRY_SOURCE, /mergedAwayRankNames\(/)
assert.match(ENTRY_SOURCE, /teamRankMergedAwayNames/)
})
+2 -1
View File
@@ -2,6 +2,7 @@
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag } from '@/shared' import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag } from '@/shared'
import { useScoreboard } from '../useScoreboard' import { useScoreboard } from '../useScoreboard'
import { resultTextOrPlaceholder } from '../results'
import AnimatedNumber from './AnimatedNumber.vue' import AnimatedNumber from './AnimatedNumber.vue'
const { detail } = useScoreboard() const { detail } = useScoreboard()
@@ -72,7 +73,7 @@ const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
</span> </span>
<span class="col-athlete">{{ breakdownAthleteName(item, teamName) }}</span> <span class="col-athlete">{{ breakdownAthleteName(item, teamName) }}</span>
<span class="col-rank">{{ item.rank ?? '—' }}</span> <span class="col-rank">{{ item.rank ?? '—' }}</span>
<span class="col-result">{{ item.resultText ?? '—' }}</span> <span class="col-result">{{ resultTextOrPlaceholder(item.resultText) }}</span>
<span class="col-points">{{ item.points }}</span> <span class="col-points">{{ item.points }}</span>
<!-- <!--
修订痕迹占满一整行、只在这一分确实被改过时才出现:大屏不能悬停,观众要一眼看到 修订痕迹占满一整行、只在这一分确实被改过时才出现:大屏不能悬停,观众要一眼看到
+6 -4
View File
@@ -3,6 +3,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { medalBoardRows } from '@/shared' import { medalBoardRows } from '@/shared'
import type { EventResultRow } from '@/shared' import type { EventResultRow } from '@/shared'
import { tickerRowText } from '../jointTeams' import { tickerRowText } from '../jointTeams'
import { displayResult } from '../results'
import { useScoreboard } from '../useScoreboard' import { useScoreboard } from '../useScoreboard'
import { DEFAULT_ROTATE_SECONDS } from '../guards' import { DEFAULT_ROTATE_SECONDS } from '../guards'
@@ -13,16 +14,17 @@ interface TickerItem {
label: string label: string
} }
const { standings, latestResults, announceText, detail, settings, jointTeamOf } = useScoreboard() const { standings, latestResults, announceText, detail, settings, jointTeamOf, squadOf } =
useScoreboard()
/** /**
* 底栏「最新完成项目」一行里的名字:团体行是联队时写「A+B联队」。 * 底栏「最新完成项目」一行里的名字:团体行是联队时写「A+B联队」。
* *
* 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为 * 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为
* 这条成绩是它一家拿的(成员单位来自报名表,见 display/jointTeams.ts)。 * 这条成绩是它一家拿的(成员单位与队名都来自报名表,见 display/jointTeams.ts)。
*/ */
function chipName(row: EventResultRow): string { function chipName(row: EventResultRow): string {
return tickerRowText(row, jointTeamOf(row.entryId)) return tickerRowText(row, jointTeamOf(row.entryId), squadOf(row.entryId))
} }
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。 // 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
@@ -90,7 +92,7 @@ onUnmounted(() => window.clearInterval(timer))
<div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip"> <div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip">
<span class="chip-rank">{{ row.rank ?? '—' }}</span> <span class="chip-rank">{{ row.rank ?? '—' }}</span>
<span class="chip-name">{{ chipName(row) }}</span> <span class="chip-name">{{ chipName(row) }}</span>
<span class="chip-stat">{{ row.resultText }}</span> <span class="chip-stat">{{ displayResult(row) }}</span>
</div> </div>
</template> </template>
@@ -2,22 +2,25 @@
import { computed } from 'vue' import { computed } from 'vue'
import type { EventResultRow } from '@/shared' import type { EventResultRow } from '@/shared'
import { rowTeamLogos, rowTeamText } from '../../jointTeams' import { rowTeamLogos, rowTeamText } from '../../jointTeams'
import { displayName, displayResult } from '../../results' import { displayName, displayResult, eventResultColumns } from '../../results'
import { useScoreboard } from '../../useScoreboard' import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue' import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue' import TeamLogo from '../TeamLogo.vue'
const { latestResults, options, jointTeamOf } = useScoreboard() const { latestResults, options, jointTeamOf, squadOf } = useScoreboard()
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。 // 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount)) const columns = computed(() =>
eventResultColumns(latestResults.value, options.value.resultsCount),
)
/** /**
* 单位列的文案与 logo(devdocs/19):联队写「A+B联队」并画两个单位的 logo, * 单位列的文案与 logo(devdocs/19):联队写「A+B联队」并画两个单位的 logo,
* 填了队名的写「第一分公司二队」(一个单位两个队时,两行的名字要分得开);
* 普通报名与改造前逐字/逐图一致(`shortName ?? teamName` 与单个 TeamLogo)。 * 普通报名与改造前逐字/逐图一致(`shortName ?? teamName` 与单个 TeamLogo)。
*/ */
function teamText(row: EventResultRow): string { function teamText(row: EventResultRow): string {
return rowTeamText(row, jointTeamOf(row.entryId)) return rowTeamText(row, jointTeamOf(row.entryId), squadOf(row.entryId))
} }
function teamLogos(row: EventResultRow): (string | null)[] { function teamLogos(row: EventResultRow): (string | null)[] {
@@ -26,16 +29,16 @@ function teamLogos(row: EventResultRow): (string | null)[] {
</script> </script>
<template> <template>
<PageFrame title="分项成绩" :hint="`最新完成 ${groups.length} 项`"> <PageFrame title="分项成绩" :hint="`最新完成 ${columns.length} 项`">
<div v-if="groups.length > 0" class="groups"> <div v-if="columns.length > 0" class="groups">
<article v-for="group in groups" :key="`${group.eventId}-${group.categoryId}`" class="group"> <article v-for="column in columns" :key="column.key" class="group">
<header class="group-head"> <header class="group-head">
<h3>{{ group.eventName }}</h3> <h3>{{ column.group.eventName }}</h3>
<span class="cat">{{ group.categoryName }}</span> <span class="cat">{{ column.group.categoryName }}</span>
</header> </header>
<div class="lines"> <div class="lines">
<div <div
v-for="row in group.rows.slice(0, 6)" v-for="row in column.rows"
:key="row.entryId" :key="row.entryId"
class="line" class="line"
:class="`top-${Math.min(row.rank ?? 99, 4)}`" :class="`top-${Math.min(row.rank ?? 99, 4)}`"
+16 -7
View File
@@ -17,15 +17,24 @@ import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue' import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue' import TeamLogo from '../TeamLogo.vue'
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents, jointTeamOf } = const {
useScoreboard() latestResults,
settings,
events,
liveFeed,
liveStale,
refreshLiveEvents,
jointTeamOf,
squadOf,
} = useScoreboard()
/** /**
* 联队(devdocs/19):成绩行里只有主单位,成员单位来自报名表(useScoreboard 会为屏上的小项读一次)。 * 联队与队伍(devdocs/19):成绩行里只有主单位,成员单位与队名来自报名表
* 普通报名下这两件事与改造前完全一样——文案还是 `shortName ?? teamName`,logo 还是刚一张。 * (useScoreboard 会为屏上的小项读一次)。普通报名下这两件事与改造前完全一样——
* 文案还是 `shortName ?? teamName`,logo 还是刚一张。
*/ */
function teamText(row: EventResultRow): string { function teamText(row: EventResultRow): string {
return rowTeamText(row, jointTeamOf(row.entryId)) return rowTeamText(row, jointTeamOf(row.entryId), squadOf(row.entryId))
} }
function teamLogos(row: EventResultRow): (string | null)[] { function teamLogos(row: EventResultRow): (string | null)[] {
@@ -82,8 +91,8 @@ const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.valu
const resultTexts = computed(() => const resultTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))), boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
) )
// 联队行的单位名更长(两个单位名都写全),列宽预算必须按**实际会写出来的字**算, // 联队行的单位名更长(两个单位名都写全),带队名的行也多出几个字,列宽预算必须按
// 否则联队一到屏上就被挤成省略号。 // **实际会写出来的字**算(teamText 已经把队名拼进去了),否则一到屏上就被挤成省略号。
const teamTexts = computed(() => const teamTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))), boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
) )
+39 -10
View File
@@ -1,17 +1,17 @@
import { isJointTeam, jointTeamLabel, normalizeTeamShares } from '@/shared' import { isJointTeam, jointTeamLabel, normalizeSquad, normalizeTeamShares, teamSquadLabel } from '@/shared'
import type { EntryTeamShare, Team, TeamShareView } from '@/shared' import type { EntryTeamShare, Team, TeamShareView } from '@/shared'
/** /**
* 大屏侧的联队渲染(devdocs/19)。 * 大屏侧的联队 / 队伍渲染(devdocs/19)。
* *
* 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有**主单位**的 teamId 与名字, * 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有**主单位**的 teamId 与名字,
* 一条报名挂在几个单位下这件事只存在于报名表(`GET /api/events/{id}/entries` 的 teamShares)。 * 而「一条报名挂在几个单位下」与「这是单位的第几支队」只存在于报名表
* 所以这里做两件事: * (`GET /api/events/{id}/entries` 的 teamShares 与 squad)。所以这里做两件事:
* 1. 把那个接口的读数收敛成 `entryId → 成员单位`(名字取自份额,logo 取自快照的单位表—— * 1. 把那个接口的读数收敛成 `entryId → 成员单位` 与 `entryId → 队名`(名字取自份额,
* entries 接口不下发 logoPath); * logo 取自快照的单位表——entries 接口不下发 logoPath);
* 2. 把「一行该怎么念、放几个 logo」抽成纯函数,页面只负责把结果画出来。 * 2. 把「一行该怎么念、放几个 logo」抽成纯函数,页面只负责把结果画出来。
* *
* 单单位(普通报名)时这些函数一律退回调用方原来的表达式,屏上逐像素不变。 * 单单位且不填队名(普通报名)时这些函数一律退回调用方原来的表达式,屏上逐像素不变。
*/ */
/** 大屏侧的一条份额:展示字段之外补上 logo(从快照单位表里取)。 */ /** 大屏侧的一条份额:展示字段之外补上 logo(从快照单位表里取)。 */
@@ -61,13 +61,40 @@ export function entryShareIndex(
return out return out
} }
/** 解析 entries 接口里一项报名的 squad;缺字段 / 类型不对时是空串(= 不区分队伍)。 */
export function readEntrySquad(source: unknown): string {
if (typeof source !== 'object' || source === null) return ''
return normalizeSquad((source as { squad?: unknown }).squad)
}
/**
* 一批报名 → `entryId → 队名`,**只收填了队名的**(空串不进表)。
*
* 与 entryShareIndex 同一个理由:取不到就是「按原来那样渲染」,于是老数据与单队单位的成绩行
* 与大屏改造前完全一致(连一个多余的字符串拼接都不做)。
* 队名与成员单位来自同一次 `GET /api/events/{id}/entries`,所以大屏不为它多发一个请求。
*/
export function entrySquadIndex(entries: readonly unknown[]): Map<number, string> {
const out = new Map<number, string>()
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) continue
const entryId = (entry as { id?: unknown }).id
if (typeof entryId !== 'number' || entryId <= 0) continue
const squad = readEntrySquad(entry)
if (squad === '') continue
out.set(entryId, squad)
}
return out
}
/** 一行成绩(分项成绩页 / 实时赛况页)在单位列里的文案。 */ /** 一行成绩(分项成绩页 / 实时赛况页)在单位列里的文案。 */
export function rowTeamText( export function rowTeamText(
row: { shortName: string | null; teamName: string }, row: { shortName: string | null; teamName: string },
members: readonly TeamShareView[] | null, members: readonly TeamShareView[] | null,
squad: string | null = null,
): string { ): string {
if (members === null) return row.shortName ?? row.teamName const base = members === null ? (row.shortName ?? row.teamName) : jointTeamLabel(members)
return jointTeamLabel(members) return teamSquadLabel(base, squad)
} }
/** /**
@@ -75,13 +102,15 @@ export function rowTeamText(
* *
* 团体行是联队时写「A+B联队」——底栏只有一行字,写不下两个 logo, * 团体行是联队时写「A+B联队」——底栏只有一行字,写不下两个 logo,
* 但两个单位的名字必须都在,否则现场会以为只有一个单位参赛。 * 但两个单位的名字必须都在,否则现场会以为只有一个单位参赛。
* 队名跟着一起写:同一个单位的两个队都进了名次时,两行只差队名,少了它就分不出哪一行是谁。
*/ */
export function tickerRowText( export function tickerRowText(
row: { athleteName: string | null; shortName: string | null; teamName: string }, row: { athleteName: string | null; shortName: string | null; teamName: string },
members: readonly TeamShareView[] | null, members: readonly TeamShareView[] | null,
squad: string | null = null,
): string { ): string {
if (row.athleteName !== null && row.athleteName.trim() !== '') return row.athleteName if (row.athleteName !== null && row.athleteName.trim() !== '') return row.athleteName
return rowTeamText(row, members) return rowTeamText(row, members, squad)
} }
/** /**
+79 -5
View File
@@ -1,22 +1,44 @@
import { formatResultText } from '@/shared' import { formatResultText } from '@/shared'
import type { EventResultRow, ResultStatus } from '@/shared' import type { EventResultGroup, EventResultRow, ResultStatus } from '@/shared'
/** /**
* 成绩行的展示口径:分项成绩页与实时赛况页共用,避免同一行数据在两页显示成两种样子。 * 成绩行的展示口径:分项成绩页、实时赛况页、底栏轮播与积分明细覆盖层共用,
* 避免同一行数据在几处显示成两种样子(尤其「一格破折号、一格空白」)。
*/ */
const STATUS_LABELS: Record<ResultStatus, string> = { /** 分项成绩页一栏(一个小项 + 一个组别)显示几个名次(06 第 5 节:每个项目显示前 6 名)。 */
ok: '', export const RESULT_ROWS_PER_COLUMN = 6
/**
* 成绩列拿不到内容时写的占位,与名次列(`row.rank ?? '—'`)同一个字符:
* 两列一处是破折号、一处是空白,现场只会当成那一格没渲染出来。
*/
export const RESULT_PLACEHOLDER = '—'
/** 异常状态在成绩列里的写法。`ok` 不在表里:正常成绩要按数值 + 单位格式化,不是一句文案。 */
const STATUS_LABELS: Record<Exclude<ResultStatus, 'ok'>, string> = {
dq: 'DQ', dq: 'DQ',
dns: 'DNS', dns: 'DNS',
dnf: 'DNF', dnf: 'DNF',
withdrawn: '退赛', withdrawn: '退赛',
} }
/**
* 服务端给的成绩原文 → 能写进格子的字。
*
* 类型上它写的是 `string`,运行时并不保证:**手录名次的项目**(台球、篮球那类,只录名次、
* 没有成绩值)两种值都拿不到,旧快照还可能补成 null。判空不碰字符串方法(不 `trim`),
* 拿不到内容就给占位——空着的那一格在大屏上看着像渲染坏了。
*/
export function resultTextOrPlaceholder(text: string | null | undefined): string {
if (typeof text !== 'string' || text === '') return RESULT_PLACEHOLDER
return text
}
/** 成绩列:异常状态优先,其次按数值 + 单位格式化,最后退回服务端给的原文。 */ /** 成绩列:异常状态优先,其次按数值 + 单位格式化,最后退回服务端给的原文。 */
export function displayResult(row: EventResultRow): string { export function displayResult(row: EventResultRow): string {
if (row.status !== 'ok') return STATUS_LABELS[row.status] if (row.status !== 'ok') return STATUS_LABELS[row.status]
if (row.resultValue === null) return row.resultText if (row.resultValue === null) return resultTextOrPlaceholder(row.resultText)
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none') return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
} }
@@ -26,3 +48,55 @@ export function displayName(row: EventResultRow): string {
if (row.memberNames !== null) return row.memberNames.join(' / ') if (row.memberNames !== null) return row.memberNames.join(' / ')
return row.shortName ?? row.teamName return row.shortName ?? row.teamName
} }
/** 分项成绩页的一栏:一个小项 + 一个组别,外加这一栏要画的名次行。 */
export interface EventResultColumn {
/** 组标识,与页面的 `:key` 同口径。 */
key: string
group: EventResultGroup
rows: EventResultRow[]
}
/**
* 一栏要画的名次行:先按名次收口,再取前 N 名。
*
* 不能直接 `group.rows.slice(0, N)`:服务端下发的行**通常是**名次序,但「平铺成绩行」老契约、
* 本地缓存、示例数据都不保证——那时前 N 条就不是前 N 名。现场第 3 栏看到的就是这个形状:
* 第 1 名不在先到的那 6 行里就整行不显示、剩下的名次看着是跳的(3、6、9、14、15、16)。
* 名次只能由名次自己说了算(实时赛况页的 liveRows 同一套口径)。
*
* ⚠️ 服务端**根本没下发**的行补不回来:那是快照 20 行窗口(`realtime/snapshot.go` 的
* `latestResultsLimit`)截断的问题,不在这层;这一层只保证「拿到的行按名次画」。
*
* 并列名次保持原有先后(`sort` 在 ES2019 起是稳定的):服务端按发布时间给出的并列行
* 不许在这里被换位置,否则前两栏的观感就变了。无名次(延迟结算)的行排最后。
*/
export function eventResultRows(
group: EventResultGroup | null,
limit: number = RESULT_ROWS_PER_COLUMN,
): EventResultRow[] {
if (group === null) return []
return [...group.rows].sort(byRank).slice(0, limit)
}
function byRank(a: EventResultRow, b: EventResultRow): number {
if (a.rank === null && b.rank === null) return 0
if (a.rank === null) return 1
if (b.rank === null) return -1
return a.rank - b.rank
}
/**
* 页面要画的栏:最新的 N 个「小项 + 组别」各一栏。
*/
export function eventResultColumns(
groups: readonly EventResultGroup[],
count: number,
limit: number = RESULT_ROWS_PER_COLUMN,
): EventResultColumn[] {
return groups.slice(0, count).map((group) => ({
key: `${group.eventId}-${group.categoryId}`,
group,
rows: eventResultRows(group, limit),
}))
}
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { parse } from 'vue/compiler-sfc' import { parse } from 'vue/compiler-sfc'
import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag, type BreakdownItem } from '@/shared' import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag, type BreakdownItem } from '@/shared'
import { resultTextOrPlaceholder } from '../results'
/** /**
* 大屏积分明细覆盖层的「运动员」列。 * 大屏积分明细覆盖层的「运动员」列。
@@ -95,7 +96,8 @@ test('项目 / 名次 / 成绩 / 积分四列原样保留(是加列不是改
const cells = cellsOf(BODY_ROW) const cells = cellsOf(BODY_ROW)
assert.match(cells[0]?.inner ?? '', /item\.eventName \?\? '人工调整'/) assert.match(cells[0]?.inner ?? '', /item\.eventName \?\? '人工调整'/)
assert.equal(cells[2]?.inner, "{{ item.rank ?? '—' }}") assert.equal(cells[2]?.inner, "{{ item.rank ?? '—' }}")
assert.equal(cells[3]?.inner, "{{ item.resultText ?? '—' }}") // 成绩格与分项成绩页 / 实时赛况页同一个口径:`??` 只挡 null,手录名次的项目给空串就画成空白。
assert.equal(cells[3]?.inner, '{{ resultTextOrPlaceholder(item.resultText) }}')
assert.equal(cells[4]?.inner, '{{ item.points }}') assert.equal(cells[4]?.inner, '{{ item.points }}')
}) })
@@ -105,7 +107,7 @@ test('个人流水那一行大屏上看得见是谁:运动员格是姓名,
row.eventName ?? '人工调整', row.eventName ?? '人工调整',
breakdownAthleteName(row, '第一分公司'), breakdownAthleteName(row, '第一分公司'),
row.rank ?? '—', row.rank ?? '—',
row.resultText ?? '—', resultTextOrPlaceholder(row.resultText),
row.points, row.points,
] ]
assert.deepEqual(rendered, ['100米跑', '张伟', 1, '11.58', 10]) assert.deepEqual(rendered, ['100米跑', '张伟', 1, '11.58', 10])
@@ -0,0 +1,162 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { compileTemplate, parse } from 'vue/compiler-sfc'
import type { EventResultGroup, EventResultRow } from '@/shared'
import { RESULT_ROWS_PER_COLUMN, eventResultColumns, eventResultRows } from '../results'
// 现场缺陷:分项成绩页原来把「先到达的前 6 行」当成「前 6 名」画(`group.rows.slice(0, 6)`),
// 数据一旦不是名次序(平铺契约 / 本地缓存 / 服务端只回了一段窗口内的行),
// 第 1 名就不在画出来的那 6 行里、名次看着是跳的(现场报的第 3 栏是 3、6、9、14、15、16)。
// 这里把「一栏画哪些行」收口成纯函数(与实时赛况页的 liveRows 同一套口径),逐条钉住。
function row(entryId: number, rank: number | null): EventResultRow {
return {
entryId,
rank,
teamId: entryId % 5,
teamCode: `T${entryId % 5}`,
teamName: `单位${entryId % 5}`,
shortName: null,
logoPath: null,
athleteId: entryId,
athleteName: `运动员${entryId}`,
memberNames: null,
resultText: '11.58',
resultValue: 11.58,
unit: 's',
isTie: false,
status: 'ok',
}
}
/** 一个「小项 + 组别」组,`ranks` 就是**行到达的先后**(不是名次序)。 */
function group(eventId: number, ranks: readonly (number | null)[], categoryId = 1): EventResultGroup {
return {
eventId,
eventCode: `E${eventId}`,
eventName: `项目${eventId}`,
categoryId,
categoryName: '青年组',
publishedAt: '2026-09-14T10:00:00+08:00',
rows: ranks.map((rank, index) => row(eventId * 1000 + index + 1, rank)),
}
}
function ranksOf(rows: readonly EventResultRow[]): (number | null)[] {
return rows.map((item) => item.rank)
}
function entryIds(rows: readonly EventResultRow[]): number[] {
return rows.map((item) => item.entryId)
}
test('行按到达顺序给过来时,名次仍从第 1 名开始且连续(现场报的 3、6、9、14、15、16)', () => {
// 100 米跑:16 行都在,但到达顺序是「先到的那几行名次靠后」——正是现场第 3 栏看到的样子。
const race = group(1, [3, 6, 9, 14, 15, 16, 1, 2, 4, 5, 7, 8, 10, 11, 12, 13])
assert.deepEqual(
ranksOf(eventResultRows(race)),
[1, 2, 3, 4, 5, 6],
'画出来的必须是前 6 名,而不是先到的 6 行',
)
// 羽毛球单打:第 1 名排在第 7 位才到达(原来整行都不显示,看着「从第 2 名开始」)。
const badminton = group(2, [2, 3, 4, 5, 6, 7, 1])
assert.deepEqual(ranksOf(eventResultRows(badminton)), [1, 2, 3, 4, 5, 6])
})
test('同一批数据放进第 1 / 2 / 3 栏,各栏拿到的行与名次完全一致、且都从第 1 名开始', () => {
const target = group(7, [3, 6, 9, 14, 15, 16, 1, 2, 4, 5, 7, 8, 10, 11, 12, 13])
const other = group(8, [2, 1])
// 同一个组分别落在第 1 / 2 / 3 栏:位置不该影响它画出来的行。
const first = eventResultColumns([target], 1)[0]?.rows ?? []
const second = eventResultColumns([other, target], 2)[1]?.rows ?? []
const third = eventResultColumns([other, other, target], 3)[2]?.rows ?? []
assert.deepEqual(ranksOf(first), [1, 2, 3, 4, 5, 6])
assert.deepEqual(entryIds(second), entryIds(first), '第 2 栏拿到的行必须与第 1 栏一致')
assert.deepEqual(entryIds(third), entryIds(first), '第 3 栏拿到的行必须与第 1 栏一致')
// 三栏各画各的组:同一批数据在三栏里都能从第 1 名开始。
const columns = eventResultColumns([target, target, target], 3)
for (const column of columns) {
assert.deepEqual(ranksOf(column.rows), [1, 2, 3, 4, 5, 6])
assert.deepEqual(entryIds(column.rows), entryIds(first))
}
})
test('栏数 1 / 2 / 3 / 4 都不漏行、不串栏', () => {
const groups = [group(1, [2, 1]), group(2, [1, 2, 3]), group(3, [5, 1, 4, 2, 3]), group(4, [1])]
for (const count of [1, 2, 3, 4]) {
const columns = eventResultColumns(groups, count)
assert.equal(columns.length, count, `要 ${count} 栏`)
columns.forEach((column, index) => {
const source = groups[index]
assert.equal(column.key, `${source?.eventId}-${source?.categoryId}`, '栏与小项按顺序一一对应')
// 每栏都是自己那一组的行:一个 entryId 都不许串到别的栏去。
const own = new Set((source?.rows ?? []).map((item) => item.entryId))
for (const item of column.rows) assert.ok(own.has(item.entryId), '栏里的行必须来自本组的行')
assert.equal(column.rows[0]?.rank ?? null, 1, '每栏都从第 1 名开始(该组有第 1 名时)')
})
}
})
test('服务端已按名次排好的数据(现网口径)逐行不变', () => {
// 现网快照已按名次升序下发,这条钉住「前两栏现在的表现一个字都不能变」:
// 选出来的行必须与改造前的 `group.rows.slice(0, 6)` 完全一致。
const sorted = group(1, [1, 2, 3, 4, 5, 6, 7, 8])
assert.deepEqual(entryIds(eventResultRows(sorted)), entryIds(sorted.rows.slice(0, 6)))
assert.deepEqual(
ranksOf(eventResultRows(sorted)),
ranksOf(sorted.rows.slice(0, 6)),
)
// 并列名次(服务端按发布时间给出先后)不许被重排:屏幕上是「谁先出现谁在上面」。
const tied = group(2, [1, 2, 2, 4, 5, 6, 7])
assert.deepEqual(entryIds(eventResultRows(tied)), entryIds(tied.rows.slice(0, 6)))
})
test('无名次的行排在最后,够 6 行时名次行不占位置', () => {
// 延迟结算的组名次是 NULL,服务端已把它们排在最后;换序到达时同样要落到底部。
const mixed = group(1, [null, 3, 1, null, 2])
assert.deepEqual(ranksOf(eventResultRows(mixed)), [1, 2, 3, null, null])
const full = group(2, [null, 1, 2, 3, 4, 5, 6])
assert.deepEqual(ranksOf(eventResultRows(full)), [1, 2, 3, 4, 5, 6])
assert.equal(RESULT_ROWS_PER_COLUMN, 6)
})
test('录取人数不足 6 行时不补行,空组给空数组', () => {
const short = group(1, [1, 2, 3])
assert.deepEqual(ranksOf(eventResultRows(short)), [1, 2, 3])
assert.deepEqual(eventResultRows(null), [])
assert.deepEqual(eventResultRows(group(2, [])), [])
})
test('分项成绩页按「一栏」渲染:用 eventResultColumns 取名次行,不再按到达顺序切前 6 行', () => {
const source = readFileSync(
new URL('../components/pages/EventResultsPage.vue', import.meta.url),
'utf8',
)
const { descriptor, errors } = parse(source, { filename: 'EventResultsPage.vue' })
assert.equal(errors.length, 0, 'EventResultsPage.vue 应能被解析')
const template = descriptor.template
assert.ok(template != null, 'EventResultsPage.vue 缺少 <template>')
assert.deepEqual(
compileTemplate({ source: template.content, filename: 'EventResultsPage.vue', id: 'EventResultsPage.vue' }).errors,
[],
'模板应能编译',
)
assert.match(
source,
/eventResultColumns\(latestResults\.value, options\.value\.resultsCount\)/,
'栏要由 eventResultColumns 收口(含每栏的名次行)',
)
assert.match(source, /v-for="row in column\.rows"/, '名次行必须来自收口后的 column.rows')
assert.doesNotMatch(
source,
/group\.rows\.slice\(0, 6\)/,
'不许再按到达顺序切前 6 行——第 1 名不在前 6 行里就会整行不显示',
)
})
+34
View File
@@ -206,6 +206,40 @@ test('短单位名不受这条改动影响:字号与不带单位名时一模
} }
}) })
// 队伍(迁移 0025):大屏拿到的是拼好的「单位名 + 队名」(display/jointTeams.ts 的 rowTeamText),
// 宽度预算必须把队名一起算进去——队名正是用来区分同一个单位的两个队的,被截成省略号就白填了。
test('队名一起进单位列的宽度预算:列宽跟着内容长,队名不会被省略号截掉', () => {
const font = 56
const plain = '第一分公司'
for (const layout of LAYOUTS) {
const base = liveTeamColumnPx([plain], layout, font)
for (const name of ['第一分公司一队', '第一分公司青年队']) {
const column = liveTeamColumnPx([name], layout, font)
assert.ok(column >= base, `${layout}:队名不该让单位列变窄(${column} < ${base})`)
const ink = textEm(name) * 0.78 * font
assert.ok(ink <= column, `${layout}:「${name}」墨迹 ${ink}px > 列宽 ${column}px,仍会省略`)
}
}
})
test('队名让名字更长时只收字号,不会把名字挤成省略号', () => {
const results = ['1:06.33']
const plain = ['第一分公司']
const squads = ['第一分公司一队', '第一分公司二队']
for (const layout of LAYOUTS) {
const withSquad = fitLiveRowFontPx(6, layout, results, squads)
assert.ok(withSquad <= fitLiveRowFontPx(6, layout, results, plain), `${layout}:长名字只该收字号`)
// 收完字号之后,最长的那个队名仍然要装得进它分到的列宽。
for (const name of squads) {
const ink = textEm(name) * 0.78 * withSquad
assert.ok(
ink <= liveTeamColumnPx(squads, layout, withSquad),
`${layout}:${withSquad}px 字号下「${name}」仍被截`,
)
}
}
})
test('单位列宽的下限与预算:短名字观感不变,极端长度不再撑破一行', () => { test('单位列宽的下限与预算:短名字观感不变,极端长度不再撑破一行', () => {
for (const layout of LAYOUTS) { for (const layout of LAYOUTS) {
const geo = liveRowGeometry(layout) const geo = liveRowGeometry(layout)
+65 -5
View File
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs'
import type { Team } from '@/shared' import type { Team } from '@/shared'
import { import {
entryShareIndex, entryShareIndex,
entrySquadIndex,
readEntrySquad,
readEntryTeamShares, readEntryTeamShares,
rowLogoCount, rowLogoCount,
rowTeamLogos, rowTeamLogos,
@@ -12,11 +14,13 @@ import {
} from '../jointTeams' } from '../jointTeams'
/** /**
* 大屏的联队渲染(devdocs/19)。要钉住的是: * 大屏的联队与队伍渲染(devdocs/19、迁移 0025)。要钉住的是:
* 1. **单单位(普通报名)时逐字/逐图不变**:文案还是 `shortName ?? teamName`,logo 还是刚那一张, * 1. **单单位(普通报名)时逐字/逐图不变**:文案还是 `shortName ?? teamName`,logo 还是刚那一张,
* 联队成员表里根本没有这条报名的位置; * 联队成员表与队伍表里根本没有这条报名的位置;
* 2. 联队时写「A+B联队」(两个单位的名字都写全),单位列两个 logo 都画; * 2. 联队时写「A+B联队」(两个单位的名字都写全),单位列两个 logo 都画;
* 3. 成员单位的 logo 只能从快照的单位表里取(entries 接口不下发 logoPath), * 3. 填了队名时写「第一分公司二队」(不加括号),没填时一个字不变——一个单位两个队
* 各自占一个名次,两行必须分得开;
* 4. 成员单位的 logo 只能从快照的单位表里取(entries 接口不下发 logoPath),
* 取不到时那个位置是 null,TeamLogo 不渲染,不会留空洞。 * 取不到时那个位置是 null,TeamLogo 不渲染,不会留空洞。
*/ */
@@ -89,10 +93,16 @@ test('普通报名(单单位 / 老服务端没有 teamShares)不进表 = 屏
assert.equal(index.get(11), undefined) assert.equal(index.get(11), undefined)
}) })
test('单位列文案:单单位与改造前逐字一致,联队写「A+B联队」', () => { test('单位列文案:单单位与改造前逐字一致,联队写「A+B联队」,填了队名写「第一分公司二队」', () => {
const row = { shortName: '一司', teamName: '第一分公司' } const row = { shortName: '一司', teamName: '第一分公司' }
assert.equal(rowTeamText(row, null), '一司') assert.equal(rowTeamText(row, null), '一司')
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null), '第一分公司') assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null), '第一分公司')
// 老服务端不下发 squad(null)与空串都走同一条路:一个字符都不多。
assert.equal(rowTeamText(row, null, ''), '一司')
assert.equal(rowTeamText(row, null, null), '一司')
// 队名直接拼在单位名后面,不加括号(大屏单位列宽紧张)。
assert.equal(rowTeamText(row, null, '二队'), '一司二队')
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null, '青年队'), '第一分公司青年队')
const members = entryShareIndex( const members = entryShareIndex(
[ [
{ {
@@ -110,9 +120,32 @@ test('单位列文案:单单位与改造前逐字一致,联队写「A+B联
assert.ok(members !== null && members !== undefined) assert.ok(members !== null && members !== undefined)
// 联队写全称:两个单位名一个不少(简称在别的单位的人眼里认不出来)。 // 联队写全称:两个单位名一个不少(简称在别的单位的人眼里认不出来)。
assert.equal(rowTeamText(row, members), '第一分公司+第二分公司联队') assert.equal(rowTeamText(row, members), '第一分公司+第二分公司联队')
assert.equal(rowTeamText(row, members, '二队'), '第一分公司+第二分公司联队二队')
}) })
test('底栏一行:有运动员写运动员;团体行的联队写「A+B联队」', () => { test('队名索引:只收填了队名的报名,空串 / 缺字段 / 脏数据一律不进表', () => {
const index = entrySquadIndex([
{ id: 11, squad: '一队' },
{ id: 12, squad: '' },
{ id: 13 },
{ id: 14, squad: ' 二队 ' },
{ id: 15, squad: 2 },
{ id: 0, squad: '一队' },
null,
])
assert.equal(index.get(11), '一队')
assert.equal(index.get(14), '二队', '两侧空白去掉后再上屏')
assert.equal(index.size, 2, '不填队名的报名不在表里 = 按原来那样渲染')
assert.equal(index.has(0), false, '没有合法 id 的报名项丢掉')
assert.deepEqual(entrySquadIndex([]), new Map())
assert.equal(readEntrySquad({ squad: '一队' }), '一队')
assert.equal(readEntrySquad({ squad: null }), '')
assert.equal(readEntrySquad({}), '')
assert.equal(readEntrySquad(null), '')
})
test('底栏一行:有运动员写运动员;团体行的联队写「A+B联队」,带队伍时队名也在', () => {
const members = entryShareIndex( const members = entryShareIndex(
[ [
{ {
@@ -140,6 +173,16 @@ test('底栏一行:有运动员写运动员;团体行的联队写「A+B联
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null), tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null),
'一司', '一司',
) )
// 底栏一行只差队名时,队名就是唯一的分辨依据(同一个单位的两个队都进了名次)。
assert.equal(
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null, '一队'),
'一司一队',
)
assert.equal(
tickerRowText({ athleteName: '', shortName: '一司', teamName: '第一分公司' }, null, '二队'),
'一司二队',
'athleteName 为空串(团体行)时同样带上队名',
)
}) })
test('单位列的 logo:普通报名只有行的那一张,联队把两个单位都放上', () => { test('单位列的 logo:普通报名只有行的那一张,联队把两个单位都放上', () => {
@@ -190,6 +233,23 @@ test('大屏为屏上的小项读报名表:读失败静默(屏上仍是主
const source = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8') const source = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
assert.match(source, /api\.events\.entries\(eventId\)/, '成员单位只能来自报名表') assert.match(source, /api\.events\.entries\(eventId\)/, '成员单位只能来自报名表')
assert.match(source, /entryShareIndex\(entries, teams\.value\)/, 'logo 要从快照单位表里补') assert.match(source, /entryShareIndex\(entries, teams\.value\)/, 'logo 要从快照单位表里补')
assert.match(source, /entrySquadIndex\(entries\)/, '队名与成员单位同一次请求,不为它多发一个请求')
assert.match(source, /catch \{/, '拿不到报名表时按单单位渲染,不能弹错或清屏') assert.match(source, /catch \{/, '拿不到报名表时按单单位渲染,不能弹错或清屏')
assert.match(source, /jointTeamOf/, '页面通过 jointTeamOf 取成员单位') assert.match(source, /jointTeamOf/, '页面通过 jointTeamOf 取成员单位')
assert.match(source, /squadOf/, '页面通过 squadOf 取队名')
})
// 三处渲染点都要把队名接上:少接一处,那一页上同一个单位的两个队就分不开了。
test('分项成绩 / 实时赛况 / 底栏三处都把队名传进单位列文案', () => {
const call = /rowTeamText\(row, jointTeamOf\(row\.entryId\), squadOf\(row\.entryId\)\)/
const pages: [string, RegExp][] = [
['../components/pages/EventResultsPage.vue', call],
['../components/pages/LivePage.vue', call],
['../components/TickerBar.vue', /tickerRowText\(row, jointTeamOf\(row\.entryId\), squadOf\(row\.entryId\)\)/],
]
for (const [file, pattern] of pages) {
const source = readFileSync(new URL(file, import.meta.url), 'utf8')
assert.match(source, pattern, `${file} 没把队名接进单位列`)
assert.match(source, /squadOf/, `${file} 要从 useScoreboard 取 squadOf`)
}
}) })
+7 -3
View File
@@ -332,10 +332,14 @@ test('单位列宽同样由内容算出来,长单位名不再被写死列宽
// 单栏与拆两块:单位列写成 minmax(内容宽, 1fr),短名字时与运动员列一样宽,观感不变。 // 单栏与拆两块:单位列写成 minmax(内容宽, 1fr),短名字时与运动员列一样宽,观感不变。
assert.match(source, /minmax\(var\(--team-width, 300px\), 1fr\)/) assert.match(source, /minmax\(var\(--team-width, 300px\), 1fr\)/)
assert.match(source, /minmax\(var\(--team-width, 150px\), 1fr\)/) assert.match(source, /minmax\(var\(--team-width, 150px\), 1fr\)/)
// 页面必须把单位名(联队时是「A+B联队」)与 logo 一起喂给字号计算, // 页面必须把单位名(联队时是「A+B联队」,填了队名时是「第一分公司二队」)与 logo 一起喂给
// 否则又变成「算完字号再被单位名挤破」;logo 的份数也要喂(联队一行两个 logo)。 // 字号计算,否则又变成「算完字号再被单位名挤破」;logo 的份数也要喂(联队一行两个 logo)。
assert.match(source, /liveTeamColumnPx/) assert.match(source, /liveTeamColumnPx/)
assert.match(source, /rowTeamText\(row, jointTeamOf\(row\.entryId\)\)/) assert.match(
source,
/rowTeamText\(row, jointTeamOf\(row\.entryId\), squadOf\(row\.entryId\)\)/,
'队名要一起进单位列文案:宽度预算按实际写出来的字算',
)
assert.match(source, /rowLogoCount\(row, jointTeamOf\(row\.entryId\)\)/) assert.match(source, /rowLogoCount\(row, jointTeamOf\(row\.entryId\)\)/)
assert.match(source, /'--team-width': `\$\{liveTeamColumnPx/) assert.match(source, /'--team-width': `\$\{liveTeamColumnPx/)
assert.match( assert.match(
+119
View File
@@ -0,0 +1,119 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { EventResultRow } from '@/shared'
import { parseEventResultGroups } from '../guards'
import { RESULT_PLACEHOLDER, displayResult, eventResultColumns, resultTextOrPlaceholder } from '../results'
// 现场缺陷:分项成绩页每一栏的行里只有名次、没有成绩。原因是这些项目「手动录名次」
// (台球、篮球那类,只录名次、没有成绩值):服务端 resultValue 给 null、resultText 给空串,
// 原来 displayResult 就把空串原样画了出去——大屏上那一格空着,看着像渲染坏了。
// 这里把「成绩列写什么字」收口成一条口径:拿不到内容时与名次列 `row.rank ?? '—'` 同一个占位字符。
function row(over: Partial<EventResultRow> = {}): EventResultRow {
return {
entryId: 1,
rank: 1,
teamId: 1,
teamCode: 'T1',
teamName: '第一分公司',
shortName: null,
logoPath: null,
athleteId: 1,
athleteName: '张伟',
memberNames: null,
resultText: '11.58',
resultValue: 11.58,
unit: 's',
isTie: false,
status: 'ok',
...over,
}
}
test('手录名次的项目没有成绩值:成绩列给占位,不是空串(现场看到的那一格空白)', () => {
// 台球:只录了名次,resultValue 与 resultText 都拿不到内容。
const billiards = row({ resultValue: null, resultText: '' })
assert.notEqual(displayResult(billiards), '', '空串会让大屏那一格看起来像渲染坏了')
assert.equal(displayResult(billiards), '—', '占位字符要与名次列一致')
})
test('服务端下发的手录名次行走完整条链路(解析 → 分栏 → 成绩列)也写占位', () => {
// 服务端形状:台球只录名次,result_value 与 result_text 都写空。
const snapshot = [
{
eventId: 21,
eventCode: 'BILLIARD',
eventName: '台球男子组',
categoryId: 3,
categoryName: '青年组',
publishedAt: '2026-09-14T10:00:00+08:00',
rows: [
{
entryId: 501,
rank: 1,
teamId: 2,
teamCode: 'T2',
teamName: '第一分公司',
athleteName: '张伟',
resultText: '',
resultValue: null,
unit: '',
status: 'ok',
},
],
},
]
const columns = eventResultColumns(parseEventResultGroups(snapshot), 1)
const drawn = columns[0]?.rows.map((item) => displayResult(item)) ?? []
assert.deepEqual(drawn, ['—'], '手录名次的项目画出来的是占位,不是空串')
})
test('有成绩值时逐字不变(数值 + 单位的原有格式)', () => {
assert.equal(displayResult(row({ resultValue: 11.58, unit: 's' })), '11.58')
assert.equal(displayResult(row({ resultValue: 6.12, unit: 'm' })), '6.12米')
assert.equal(displayResult(row({ resultValue: 10, unit: 'pts' })), '10分')
assert.equal(displayResult(row({ resultValue: 3, unit: '次' })), '3次')
assert.equal(displayResult(row({ resultValue: 12.5, unit: '' })), '12.5')
// 没有比较值但有原文(老契约):原文照旧,不被占位顶掉。
assert.equal(displayResult(row({ resultValue: null, resultText: '4:10.88', unit: 's' })), '4:10.88')
})
test('非 ok 状态仍然是状态文案,不许回归', () => {
assert.equal(displayResult(row({ status: 'dq' })), 'DQ')
assert.equal(displayResult(row({ status: 'dns' })), 'DNS')
assert.equal(displayResult(row({ status: 'dnf' })), 'DNF')
assert.equal(displayResult(row({ status: 'withdrawn' })), '退赛')
// 异常状态优先于数值:有成绩也写状态文案。
assert.equal(displayResult(row({ status: 'dq', resultValue: 11.58 })), 'DQ')
})
test('底栏「最新完成项目」的成绩格走同一口径,不再直接绑 resultText', () => {
const source = readFileSync(new URL('../components/TickerBar.vue', import.meta.url), 'utf8')
assert.match(source, /<span class="chip-stat">\{\{ displayResult\(row\) \}\}<\/span>/)
assert.doesNotMatch(source, /\{\{ row\.resultText \}\}/)
})
test('积分明细的成绩格:空串也退成占位,不出现空白格', () => {
const source = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8')
assert.doesNotMatch(source, /item\.resultText \?\? '—'/, '`??` 只挡 null,空串照样画成空白')
assert.match(source, /\{\{ resultTextOrPlaceholder\(item\.resultText\) \}\}/)
})
test('占位字符与名次列写的必须是同一个字,且原文照旧透传', () => {
// 不写死字面量:从名次列现取,改了那边这条就会红,两列不会再各写各的。
const page = readFileSync(
new URL('../components/pages/EventResultsPage.vue', import.meta.url),
'utf8',
)
const rankCell = /<span class="rk">\{\{ row\.rank \?\? '(.)' \}\}<\/span>/.exec(page)
assert.ok(rankCell !== null, '分项成绩页的名次列应写 `row.rank ?? 占位`')
assert.equal(RESULT_PLACEHOLDER, rankCell[1])
assert.equal(resultTextOrPlaceholder('4:10.88'), '4:10.88')
assert.equal(resultTextOrPlaceholder(''), RESULT_PLACEHOLDER)
assert.equal(resultTextOrPlaceholder(null), RESULT_PLACEHOLDER)
assert.equal(resultTextOrPlaceholder(undefined), RESULT_PLACEHOLDER)
// 类型上写的是 string、运行时混进非字符串时也不许把 null 画进格子。
assert.equal(resultTextOrPlaceholder(42 as unknown as string), RESULT_PLACEHOLDER)
})
+36 -11
View File
@@ -21,7 +21,7 @@ import {
parseTeamBreakdown, parseTeamBreakdown,
} from './guards' } from './guards'
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards' import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
import { entryShareIndex } from './jointTeams' import { entryShareIndex, entrySquadIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams' import type { DisplayTeamShare } from './jointTeams'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce' import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { applyDisplayFonts } from './fonts' import { applyDisplayFonts } from './fonts'
@@ -258,7 +258,7 @@ function reportLiveFailure(wasStale: boolean, reason: string): void {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 联队成员(一条报名挂在多个单位下,devdocs/19) // 联队成员与队伍(一条报名挂在多个单位下 / 是单位的第几支队,devdocs/19)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
@@ -270,6 +270,12 @@ function reportLiveFailure(wasStale: boolean, reason: string): void {
*/ */
const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map()) const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map())
/**
* `entryId → 队名`(只收填了队名的)。与 entryShares 同一次请求、同一份 TTL,
* 所以「一个单位两个队」不需要大屏多发一个请求;取不到就是空串 = 名字与改造前逐字相同。
*/
const entrySquads = ref<Map<number, string>>(new Map())
/** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */ /** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */
const sharesFetchedAt = new Map<number, number>() const sharesFetchedAt = new Map<number, number>()
/** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */ /** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */
@@ -291,18 +297,31 @@ async function loadEventShares(eventId: number): Promise<void> {
try { try {
const entries = await api.events.entries(eventId) const entries = await api.events.entries(eventId)
const index = entryShareIndex(entries, teams.value) const index = entryShareIndex(entries, teams.value)
const next = new Map(entryShares.value) const squads = entrySquadIndex(entries)
for (const id of shareEntryIds.get(eventId) ?? []) next.delete(id) const nextShares = new Map(entryShares.value)
const ids: number[] = [] const nextSquads = new Map(entrySquads.value)
for (const [entryId, members] of index) { // 重读要先删掉上次读回来的那些报名:改成单单位 / 清掉队名后,旧值不能留在屏上。
next.set(entryId, members) const previousIds = shareEntryIds.get(eventId) ?? []
ids.push(entryId) for (const id of previousIds) {
nextShares.delete(id)
nextSquads.delete(id)
} }
shareEntryIds.set(eventId, ids) const ids = new Set<number>()
entryShares.value = next for (const [entryId, members] of index) {
nextShares.set(entryId, members)
ids.add(entryId)
}
for (const [entryId, squad] of squads) {
nextSquads.set(entryId, squad)
// 只有队名的报名同样要记住 id,下次重读才删得掉。
ids.add(entryId)
}
shareEntryIds.set(eventId, [...ids])
entryShares.value = nextShares
entrySquads.value = nextSquads
sharesFetchedAt.set(eventId, Date.now()) sharesFetchedAt.set(eventId, Date.now())
} catch { } catch {
// 静默:没有联队信息时屏上仍是主单位,与升级前一致。 // 静默:没有联队 / 队伍信息时屏上仍是主单位,与升级前一致。
} finally { } finally {
sharesInFlight.delete(eventId) sharesInFlight.delete(eventId)
} }
@@ -332,6 +351,11 @@ function jointTeamOf(entryId: number): DisplayTeamShare[] | null {
return entryShares.value.get(entryId) ?? null return entryShares.value.get(entryId) ?? null
} }
/** 某个报名项的队名;没填 / 没读到都是 null,调用方渲染出与升级前逐字相同的单位名。 */
function squadOf(entryId: number): string | null {
return entrySquads.value.get(entryId) ?? null
}
watch(sharesKey, () => { watch(sharesKey, () => {
void ensureEntryShares(sharesEventIds.value) void ensureEntryShares(sharesEventIds.value)
}) })
@@ -733,6 +757,7 @@ export function useScoreboard() {
events, events,
teams, teams,
jointTeamOf, jointTeamOf,
squadOf,
liveFeed, liveFeed,
liveStale, liveStale,
page, page,
+1
View File
@@ -38,6 +38,7 @@ export {
teamSharePayload, teamSharePayload,
} from './jointTeams' } from './jointTeams'
export type { TeamShareView } from './jointTeams' export type { TeamShareView } from './jointTeams'
export { normalizeSquad, teamSquadLabel } from './squad'
export { export {
breakdownAthleteName, breakdownAthleteName,
breakdownCategoryName, breakdownCategoryName,
+30
View File
@@ -0,0 +1,30 @@
/**
* 「队伍」(squad)的读写口径,前端只有这一份实现。
*
* 队伍是报名时填的**自由文本**(一队 / 二队 / 青年队 都能写):同一个单位可以在同一组别派多支队,
* 一个单位两个队各占一个名次是正常事实,各队的名次与积分各记各的、最后都算到单位总分上。
*
* 空串 = **不区分队伍** —— 老数据与单队单位的默认值,此时界面上一字不变。
* 这里只做展示与归一,不改名次也不改分:队伍只是让「同一个单位的两个队」在屏上分得开。
*/
/**
* 读侧收口:非字符串(老服务端不下发这个字段 / 脏数据)一律当空串,两侧空白去掉。
*
* 与写侧同口径——服务端存的是 trim 过的文本,界面拼出来的名字才不会带一个隐形的空格。
*/
export function normalizeSquad(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* 「单位名 + 队名」的展示文案:队名为空时**原样返回单位名**(一个字符都不多)。
*
* 不加括号也不用分隔符:「第一分公司二队」在现场就是按一个单位名念的,
* 而大屏单位列宽是按内容算的(见 display/fitFont.ts),括号与空格只会白占宽度。
* 队名同样算进那份宽度预算,否则名字会被截成省略号。
*/
export function teamSquadLabel(teamName: string, squad: string | null | undefined): string {
const suffix = normalizeSquad(squad)
return suffix === '' ? teamName : `${teamName}${suffix}`
}
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { normalizeSquad, teamSquadLabel } from '../squad'
/**
* 队伍(squad)的展示口径。要钉住的是两件事:
* 1. **队名为空时一个字都不变** —— 老数据与单队单位走的就是这条路,
* 界面(管理端单位列、大屏分项成绩 / 实时赛况 / 底栏)必须与升级前逐字相同;
* 2. 队名非空时直接拼在单位名后面、**不加括号**(大屏单位列宽是按内容算的,
* 括号与空格白占宽度,名字反而更容易被截成省略号)。
*/
test('队名为空(空串 / null / undefined / 只有空白):原样返回单位名', () => {
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
assert.equal(teamSquadLabel('第一分公司', null), '第一分公司')
assert.equal(teamSquadLabel('第一分公司', undefined), '第一分公司')
assert.equal(teamSquadLabel('第一分公司', ' '), '第一分公司', '只有空白的队名等于没填')
assert.equal(teamSquadLabel('', ''), '', '单位名本身为空时也不该凭空多出东西')
})
test('队名非空:直接拼在单位名后面,不加括号也不加分隔符', () => {
assert.equal(teamSquadLabel('第一分公司', '二队'), '第一分公司二队')
assert.equal(teamSquadLabel('第一分公司', '青年队'), '第一分公司青年队')
assert.equal(teamSquadLabel('一司', '一队'), '一司一队')
assert.equal(
teamSquadLabel('第一分公司+第二分公司联队', '二队'),
'第一分公司+第二分公司联队二队',
'联队也有队伍:两个队都进名次时,少这几个字就看不出哪一行是哪个队',
)
})
test('队名两侧空白去掉后再拼:服务端存的是去空白后的文本,界面上不该多出一个隐形空格', () => {
assert.equal(teamSquadLabel('第一分公司', ' 二队 '), '第一分公司二队')
assert.equal(normalizeSquad(' 二队 '), '二队')
assert.equal(normalizeSquad('二 队'), '二 队', '队名内部的空格是用户自己写的,保留')
})
test('读侧归一:老服务端不下发这个字段(undefined)/ 脏数据(数字、null、对象)一律当空串', () => {
assert.equal(normalizeSquad(undefined), '')
assert.equal(normalizeSquad(null), '')
assert.equal(normalizeSquad(2), '', '类型不对就按没填处理,不让 NaN / 数字混进单位名')
assert.equal(normalizeSquad({ squad: '一队' }), '')
assert.equal(normalizeSquad('一队'), '一队')
})
+8
View File
@@ -246,6 +246,14 @@ export interface Entry {
seed: string | null seed: string | null
/** 录入 / 展示用的批次号(null = 未分批);批次不参与算分,排名与积分仍按「小项 + 组别」算。 */ /** 录入 / 展示用的批次号(null = 未分批);批次不参与算分,排名与积分仍按「小项 + 组别」算。 */
heat: number | null heat: number | null
/**
* 队伍:同一个单位在同一组别派出的第几支队,**自由文本**(一队 / 二队 / 青年队 都能写)。
*
* 空串 = 不区分队伍 —— 这是老数据与单队单位的默认值,界面与操作与升级前完全一致。
* 只对纯团体报名(athleteId 为 null)有意义:一个单位两个队就是两条报名,名次与积分各记各的。
* 老服务端不下发这个字段,读侧一律按空串归一(见 shared/squad.ts 的 normalizeSquad)。
*/
squad?: string
status: EntryStatus status: EntryStatus
remark: string | null remark: string | null
/** 以下为联表展示字段,接口按需带出。 */ /** 以下为联表展示字段,接口按需带出。 */