diff --git a/VERSION b/VERSION
index 7cb4ac2..f47b9ad 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-dev_0.5.4
+dev_0.5.5
diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp
index fab95e9..bc59cce 100644
Binary files a/console/installer/art/1x/brand.bmp and b/console/installer/art/1x/brand.bmp differ
diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp
index 39c9a0b..ba30d4d 100644
Binary files a/console/installer/art/2x/brand.bmp and b/console/installer/art/2x/brand.bmp differ
diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt
index 7d85683..d1d899f 100644
--- a/console/installer/art/version.txt
+++ b/console/installer/art/version.txt
@@ -1 +1 @@
-0.5.4
+0.5.5
diff --git a/console/package.json b/console/package.json
index 89550d5..240a134 100644
--- a/console/package.json
+++ b/console/package.json
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
- "version": "0.5.4",
+ "version": "0.5.5",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
diff --git a/docs/使用手册.md b/docs/使用手册.md
index 730487c..1f0bf3a 100644
--- a/docs/使用手册.md
+++ b/docs/使用手册.md
@@ -102,7 +102,7 @@
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
-**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。
+**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。
| 列 | 操作 |
| --- | --- |
@@ -300,7 +300,8 @@
3. 底部「**添加成员单位…**」下拉里选一个单位再点「添加」——下拉里列的是还没在成员里的单位(**已停用**的单位也可以加,会标注「已停用」;被「彻底删除」的单位不出现);
4. 弹窗下方实时显示 `当前比例:第一分公司 50% · 第二分公司 50%`,两个以上成员时旁边还会给出联队名预览(`第一分公司+第二分公司联队`);
5. **主单位不能去掉**(它的「移除」是灰的):报名挂在它名下,要换主单位只能重新建报名;
-6. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。
+6. **纯团体报名还会多一个「队伍」输入框**(同一个单位派两个队时写队名,见 4.12):**只有纯团体报名显示这个框,个人报名连框都不出现**;留空 = 不区分队伍。它与份额是**两个接口**,改队名**不会重算榜单**(队名只是标签);
+7. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。按钮上的字会按你改了什么变:只改队名时是 **「保存队伍」**、只改份额时是 **「保存成员单位」**、两样都改了是 **「保存成员单位与队伍」**。
**要注意的**
@@ -313,6 +314,34 @@
- ⚠️ **「按队内平均」这类聚合通道的项目,联队不拆份额**:这类通道的名次不对应任何一条报名,分仍记在主单位头上(本轮如此)。要保证联队一起算,请用不聚合的口径(或按 4.9 在主单位上录名次)。
- 一件事只做一次:**同一个小项同一个组别里,两个单位联合参赛就写一条 `A+B` 的报名**。`A+B` 与 `B+A` 是**两条不同的报名**(主单位不同),不要两种写法都填。
+### 4.12 团体项目多队(同一个单位派两个队)
+
+**羽毛球双打那种:一个单位在同一个组别报了两支队**(例如一队、二队),两队可能各占一个名次。系统支持这么报——**不用新建单位、也不用重复报两次**,两队各算各的成绩、名次与积分,最后都汇到同一个单位的总分上(一个单位两个队都进前三时,这个单位拿 **2 枚牌**)。
+
+**只对团体项目**:个人项目(按人报名的那些)不受影响,也没有「队」这回事。
+
+**怎么录(三条路,任选)**
+
+| 方式 | 怎么做 |
+| --- | --- |
+| **导入报名表** | 用**最后一列「队伍」**写队名:一队那行写 `一队`、二队那行写 `二队`(同一个单位、同一个组别写两行)。**只有一支队的单位这一列留空**;不填这一列、表头里没有它,都合法。列名与报错见[报名名单导入规范 · 第三节的「队伍」](./报名名单导入规范.md)——⚠️ **导入表由 12 列变成 13 列,但老 12 列表照常导入**(新列加在最后,一列都不会错位) |
+| **补报名(勾选)** | 在「成绩录入」页工具栏点 **「补报名」**(或「基础数据 → 报名导入」的**团体报名**页签):勾上某个单位后,**那一行右侧会出现「队伍(留空=不区分)」输入框**,填队名即可;**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)` |
+| **改队名** | 队名填错了**不用删掉重建**:到「成绩录入」页**点那一行的「单位」格**,打开「成员单位与份额」弹窗,里面有 **「队伍」输入框**(**只有纯团体报名才显示**,个人报名连框都不出现)。改完点保存——按钮会按你改了什么都写明(`保存队伍` / `保存成员单位` / `保存成员单位与队伍`),**没改动时按钮是灰的** |
+
+**显示成什么样**
+
+- **`单位名 + 队名`,不加括号、不加分隔符**:`第一分公司一队`、`第一分公司二队`;**队名留空时仍然只写 `第一分公司`**(与以前逐字一样);
+- 出现的地方:**成绩录入页 / 成绩审核页的「单位」列、批量操作的确认框、报错里的行名、大屏的分项成绩 / 实时赛况 / 底栏「最新完成项目」,以及导出件的「单位」列**(分项成绩表、全部成绩单页、报名名单、单位奖牌明细总表、单位积分总明细总表);
+- **单位总分榜、单位奖牌榜、个人积分榜上不写队名**(它们的单位名来自单位汇总,分与奖牌本来就归到单位)。
+
+**要注意的**
+
+- **同一个单位、同一个组别、同一个队名只能有一条**:要派两支队就得写**两个不同的队名**。两个队名写一样(或都留空)时,第二行会被**逐行拦下**并说清是哪个单位、哪个队(见导入规范的第六节);
+- **队伍只对团体行生效**:个人行(有姓名的那几行)**填了队名也不会写进库里**,导入结果里会给一条提示说明这一格被忽略了;
+- ⚠️ **改完队名之后,记得把报名表里的队名也改掉**:导入是按「小项 + 组别 + 单位 + 队名」认人的,以后再导一份**还写着旧队名**的表,会**新增出旧队名那一条报名**(不会静默合并);
+- **改队名不影响成绩与榜单**:队名只是给现场看的一个标签,不参与名次、积分、奖牌的计算,所以改它**不会重算榜单**,也**不会给别处的大屏 / 录入页推刷新**——别的窗口要等下一次刷新才看到新队名(你自己那一次保存的页面上会立刻变);
+- 队伍**不参与「团体名次」按单位录入**的那一类项目(篮球那种每个单位只有一支队):名次仍然是按单位录的,与队名无关。
+
---
## 5. 成绩审核
@@ -723,12 +752,13 @@
1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项;
2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号与号码布。
-**团体报名**(一个单位一条报名)
+**团体报名**(默认一个单位一条报名;同一个单位要派两支队时见 4.12)
1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员;
-2. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补;
-3. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单;
-4. 点「加入报名」,一次为所有勾选的单位各建一条报名。
+2. **同一个单位要派两支队时**(羽毛球双打那种):勾上这个单位后,那一行右侧会出现 **「队伍(留空=不区分)」** 输入框,填上队名(例如 `一队`);**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)`——**要再报第二支队,就再填一个新的队名提交一次**(同一个队名重复提交只是更新队员名单,不会新增)。详见 4.12;
+3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补;
+4. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单;
+5. 点「加入报名」,一次为所有勾选的单位各建一条报名(**同一个单位 + 同一个队名**只留一条)。
- 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。
**表格导入**
@@ -740,8 +770,9 @@
- 「**分工作表 · 中文表头 / 分工作表 · 英文表头**」=**分工作表模板**(`.xlsx`):按当前赛事已配好的**小项 × 组别各建一张工作表**,表名是「**小项名称@组别名称**」(例如 `100 米跑@男子组 30岁以下`),范围由表名承担,数据区只填人和单位。小项组别多时用它更不容易抄串;
- 两种模板的表头与列顺序完全一样,只有「范围从哪来」不同(详细规范见 [报名名单导入规范 · 第八节](./报名名单导入规范.md));
- **两个单位联合参赛(联队)**:把「单位编码」那一格写成 `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);
+ - `squad`(队伍)是**最后一列**、**新加的(2026 年 9 月)**:同一个单位在同一个组别派两支队时(羽毛球双打的一队 / 二队)用它区分,写队名即可(中文表头里叫「队伍」,也认「队名」「队伍名」);**只有一支队的单位整列留空**、整列不写、表头里没有它,三种都合法。**老 12 列表照常导入**(新列加在最后,一列都不会错位),详见 4.12 与[报名名单导入规范 · 第三节「队伍」](./报名名单导入规范.md);
- 用**分工作表模板**时,`event_code`、`category` 两列也可以整列不要或整列留空(范围由工作表名决定);一旦填了就必须与工作表名一致,不一致的那一行会报错并跳过;
- 工作表名必须是「小项名称@组别名称」;工作簿里只要有**一张**表名带 `@`,整份文件就按分工作表模板处理,别的表名不合规会**整份拒绝并点名是哪张表**(不让数据无声消失);
- 小项名与组别名里**不允许出现 `@`**(它是表名里的分隔符):新建 / 编辑小项与组别时界面会当场拦下并说明原因;**已经带 `@` 的历史名字不影响报名与成绩**,但下载分工作表模板时会被点名要求先改名;
@@ -811,6 +842,7 @@
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导);
+- **队名(同一个单位派两个队时写的那个,见 4.12)写进「单位」列**:分项成绩表 / 全部 / 全部(单页)/ 报名名单 / 单位奖牌明细总表 / 单位积分总明细总表里,写的是 **`第一分公司二队`** 这种「单位名 + 队名」(队名留空时仍然只写 `第一分公司`)。⚠️ **导出件里没有单独的「队名」列**——所以**不要拿导出的报名名单直接改完再导回去**(那会撞「同一个单位同一个队」的重复报名、报错拦下);要改队名或改名单,用**导入模板**那条路,或到录入页点「单位」格改队名;
- **联队(一条报名挂多个单位)在这几份导出件里的写法**(见 4.11):
- **「分项成绩表」「全部」「全部(单页)」的「单位」列**:写成联队全名 `第一分公司+第二分公司联队`(成员单位名写全,与导入表的 `F1+F2` 同构);**「备注」列**写清这条分给了谁、各多少,例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`——「积分」列写的是**联队拿到的总分**(上例 10 分),每家的那一份看备注;
- **「报名名单」**:「单位编码」列回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例);
@@ -1053,6 +1085,8 @@
> **联队(两个单位合并参赛)在大屏上怎么写**(见 4.11):「分项成绩」与「实时赛况」的**单位列写联队全名**——`第一分公司+第二分公司联队`,并且**把两个单位的 logo 都放上**(单位列宽会为该行实际要画的 logo 数留位置,第二个 logo 不会把名字挤成省略号);底栏「最新完成项目」那一行同样写联队全名。**单位奖牌榜、总积分榜本来就是逐单位一行,服务端已按份额拆好**(例:A、B 各 1 枚金、各 7.5 分),屏上不需要另做处理。**没有联队的赛事,大屏画面与升级前逐像素一致**。
+> **队名(同一个单位派两个队时写的那个,见 4.12)同样写在大屏的单位名后面**:`第一分公司二队`——分项成绩、实时赛况、底栏「最新完成项目」三处都一样(同一个单位的两个队都进了名次时,两行只差队名,不写就分不出哪一行是谁);**单位奖牌榜、总积分榜上不写队名**。队名留空时画面与以前逐像素一致。
+
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。
diff --git a/docs/升级说明.md b/docs/升级说明.md
index d6b3860..ca79bb5 100644
--- a/docs/升级说明.md
+++ b/docs/升级说明.md
@@ -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)不能在导入表里写**,只能在弹窗里改。
- **谁能改**:份额编辑与录成绩同档,**录入员及以上**;访客只读时输入框置灰并写明原因。
- **改完当场生效**:保存立即重算该小项该组别并推送大屏。
diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md
index d8072a7..ac6fa0e 100644
--- a/docs/报名名单导入规范.md
+++ b/docs/报名名单导入规范.md
@@ -11,14 +11,17 @@
## 一、先看懂三件事
1. **用什么软件做表**:Excel 或 WPS 都行,做完存成 `.xlsx` 或 `.csv` 都可以(推荐 `.csv`,系统兼容性最好,不用管编码)。
-2. **表格长什么样**:**第一行是表头**(固定的 12 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。
+2. **表格长什么样**:**第一行是表头**(固定的 13 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。
3. **编码不能自己编**:表里的「小项编码」「单位编码」「组别名称」必须是系统里已经配好的,**照抄系统里的写法**。系统里没有的编码,这一行会被跳过并报错。
---
## 二、表头(第一行的列名)
-一共 12 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。
+一共 13 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。
+
+> **第 13 列「队伍」是新加的(2026 年 9 月)**:**同一个单位在同一个组别派两支队**(羽毛球双打的一队 / 二队那种)时才需要填它;**绝大多数表格整列留空就行**,填法见第三节的「队伍」。
+> **老表格不用改**:表头里没有这一列、整列不写、格子留空,三种情况都合法,系统按「不区分队伍」处理——**原来的 12 列一列都不会错位**(新的一列加在**最后**)。
下载模板时按手上表格的样子选:
@@ -43,6 +46,9 @@
| `heat` | 批次(也认「批次号」) | 批次,可缺省 |
| `status` | 状态(也认「报名状态」) | 报名状态 |
| `member_names` | 队员名单 | 团体项目的队员名单 |
+| `squad` | 队伍(也认「队名」「队伍名」) | **队名,可缺省**;同一个单位在同一组别派两支队时用它区分(留空 = 不区分队伍) |
+
+> **第 13 列 `squad`(队伍)永远排在最后**:它是 2026 年 9 月新增的,加在表尾是为了让**手里那些原来 12 列的老表格一列都不会错位**(表格少的正是最后这一列)。整列不写、整列留空、表头没有它,三种情况都合法。
> **同一列只能写一次**:一份表里同时出现 `event_code` 和「项目编码」(或者干脆把同一列写了两遍),会被整份拦下并提示「报名表表头重复」,删掉多余的那一列再导入即可——系统不会替你猜该信哪一列,免得静默丢掉另一列的数据。
>
@@ -64,6 +70,7 @@
| `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
| `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
| `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` |
+| `squad`(队伍) | 可选 | **队名**(自由写,例如 `一队`);**留空 = 不区分队伍**。**同一个单位在同一个组别派两支队就靠它区分**,见第三节 | `一队` |
> 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。
@@ -183,6 +190,23 @@ F1+F2
- 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。
- 系统界面里会把这条名单显示成「张三、李四、王五」。
+### `squad` 队伍 —— 可选(表的最后一列)
+
+**这一列只在「同一个单位在同一个组别派了不止一支队」时才需要填**,比如羽毛球双打:一个单位报了一队、二队,两队可能各占一个名次。
+
+```
+一队
+二队
+青年队
+```
+
+- **队名随便写**(`一队` / `二队` / `青年队` / `A 组` 都行),就是给现场看的一个标签;
+- **留空 = 不区分队伍**:一个单位在这个组别只有一支队(绝大多数情况)时,**整列留空、整列不写、表头里没有这一列**,三种都合法,与升级前的行为完全一样;
+- **只对团体项目有效**:这一列只认「姓名那一格留空」的团体行。**个人行(填了姓名的行)如果也填了队名,这一格不会写进库里**,导入结果里会给你一条提示:`第 N 行填了队伍「一队」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里`;
+- **同一个单位、同一个组别、同一个队名只能有一条**(要派两支队就写两个不同的队名)。撞上了会逐行拦下并说清是哪个单位、哪个队(见第六节);
+- **队名只影响显示与查重,不影响成绩与算分**:两个队的成绩、名次、积分各算各的,最后都汇总到同一个单位的总分上;显示上写成「单位名 + 队名」,例如 `第一分公司二队`(队名留空时仍然只写 `第一分公司`)。**单位总分榜 / 单位奖牌榜 / 个人积分榜上不写队名**(分与牌本来就归到单位);
+- 队名**改起来不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ 改完记得**把报名表里的队名也改成一样**——以后再导一份还写着旧队名的表,会因为认不出来而**新增出旧队名那一条**。
+
### 系统怎么认人(证件号 → 号码布 → 姓名 + 单位)
有姓名的行,系统按下面的顺序认人。**证件号是唯一标识,号码布是它的替补,最后才靠「姓名 + 单位」兜底回填**:
@@ -196,7 +220,7 @@ F1+F2
---
-## 四、四个完整例子
+## 四、五个完整例子
### 例 1:个人项目(七单位 × 若干人)
@@ -246,11 +270,30 @@ F1+F2
- 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛));
- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `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` 都是从系统里抄的编码,没有空格
- [ ] `category` 与系统里的组别名一字不差
@@ -262,6 +305,7 @@ F1+F2
- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
- [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节)
+- [ ] **「队伍」列**(最后一列)只在**同一个单位派了不止一支队**时才填:两队写了**两个不一样的队名**(都写一样或都留空会被拦下);只有一支队的单位留空即可;**个人行(有姓名的行)没有填队名**(填了也不会写进库里,只给一条提示)
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
- [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节)
@@ -284,6 +328,9 @@ F1+F2
| 第 N 行:单位编码 F1+ 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B) | **加号有一边是空的**(`F1+`、`+F2`、`F1++F2` 都是这一条)。多半是填一半或多了个加号 | 补上缺的那个编码,或删掉多余的加号;联队至少两个单位 |
| 第 N 行:单位编码 F1+F1 里同一个单位出现了两次:联队的每个单位只能写一次 | 同一个单位在联队里写了两遍(`F1+F1`) | 一个单位只写一次;两个单位联合参赛就是 `F1+F2` |
| 第 N 行:单位编码 … 里写了 6 个单位:一个联队最多 5 个单位 | 加号太多、成员超过 5 个 | **一个联队最多 5 个单位**;现场最多见过三个单位合并,超过 5 个多半是这一格填错了东西(例如整格都是加号) |
+| 第 N 行:单位 A 在本组别已经有一条队伍「一队」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队) | 同一个单位、同一个组别、**同一个队名**写了两遍(两支队要写两个不同的队名) | 把其中一行的「队伍」列改成另一个队名(`一队` / `二队`);如果本来就是同一支队、只是想把队员名单补全,改完重导**不会新增**(同一单位同一队名是更新) |
+| 第 N 行:单位 A 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请在队伍列分别写清(例如 一队 / 二队) | **两行的队伍列都留空**(等于都说「这个单位在这个组别只有一支队」),或者库里以前报过一条不分队的、这行又留空 | 要派两支队就在两行的「队伍」列分别写 `一队` / `二队`;确实只有一支队,就把多余的那一行删掉 |
+| 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 |
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
@@ -344,7 +391,7 @@ F1+F2
### 8.2 数据怎么填
-- 每张工作表**第 1 行就是表头**(与单表模板同样的 12 列、中英两套都认),**从第 2 行开始填人**;
+- 每张工作表**第 1 行就是表头**(与单表模板同样的 13 列、中英两套都认),**从第 2 行开始填人**;
- **「项目编码」「组别」两列可以整列留空**——表名已经说明了是哪个小项、哪个组别;
一旦填了,就**必须与表名一致**,不一致的那一行会报错并跳过(系统不猜、也不会挑一个用);
- 其余列与单表模板完全一样(第三节逐列说明照样适用);
@@ -395,6 +442,7 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿
> 8. 填完先对着第五节的清单自查一遍,再发给收表人;
> 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**;
> 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 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。
diff --git a/server/internal/export/entries.go b/server/internal/export/entries.go
index a47208e..02c55ca 100644
--- a/server/internal/export/entries.go
+++ b/server/internal/export/entries.go
@@ -131,7 +131,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
query := `
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.id_card, ''), COALESCE(en.bib, ''), en.heat,
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
age sql.NullInt64
athleteName string
+ squad string
entryID int64
)
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 {
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.Remark = appendNote(row.Remark, joint.shareRatioNote())
}
+ // 队名接在单位名后面(口径:单位名+队名,不加括号),空队名时一个字都不加:
+ // 老数据与单队单位的这份名单必须与升级前逐行一致。单位编码不动 —— 队名不是编码的一部分,
+ // 编码列照旧是可以直接拿去重导的写法。
+ row.TeamName = withSquad(row.TeamName, squad)
out = append(out, row)
}
if err := rows.Err(); err != nil {
diff --git a/server/internal/export/load.go b/server/internal/export/load.go
index c88fa71..5001599 100644
--- a/server/internal/export/load.go
+++ b/server/internal/export/load.go
@@ -542,7 +542,8 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
// 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。
query := `
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((SELECT SUM(l.points) FROM score_ledger l
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
rank, isTie int
memberJSON, athleteName string
+ squad string
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
@@ -601,7 +603,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
required, published int
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,
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
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.Remark = appendNote(row.Remark, joint.shareNote())
}
+ // 队名接在单位名后面(口径:单位名+队名,不加括号)。放在联队之后:联队名本身就是单位名,
+ // 「A+B联队二队」读起来才是「这个联队的第二支队」。空队名时这一行不动,导出件与升级前逐字一致。
+ row.TeamName = withSquad(row.TeamName, squad)
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
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(r.unit, ''),
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
LEFT JOIN events e ON e.id = l.event_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 resultText, unit string
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)
}
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
diff --git a/server/internal/export/medals.go b/server/internal/export/medals.go
index 703458c..9337d94 100644
--- a/server/internal/export/medals.go
+++ b/server/internal/export/medals.go
@@ -36,6 +36,11 @@ type MedalDetailRow struct {
// 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。
jointNote string
+ // squad 是这块奖牌对应报名项的队名(entries.squad),只有总表的「单位」列用它:
+ // 一个单位两个队各拿一块牌时,总表上要认得出来哪块是哪个队的。分表没有「单位」列,
+ // 表名本身就是单位(它是一整份单位级清单,不按队拆)。
+ squad string
+
// 排序键,只在本包内用,不导出。
sportSort int
eventSort int
@@ -129,7 +134,9 @@ func medalMasterRows(sheets []MedalDetailSheet) [][]any {
for _, s := range sheets {
unit := displayName(s.Team.Name, s.Team.ShortName)
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
@@ -213,9 +220,15 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
}
orderTeamsByStandings(teams, order)
+ // 队名按报名项读一次给全部奖牌行复用(逐行去查库是白跑)。
+ squads, err := loadEntrySquads(db)
+ if err != nil {
+ return MedalDetailWorkbook{}, err
+ }
+
sheets := make([]MedalDetailSheet, 0, len(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 {
continue
}
@@ -409,7 +422,8 @@ func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error
// 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]
rows := make([]MedalDetailRow, 0, len(list))
for _, h := range list {
@@ -430,6 +444,7 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
eventID: c.eventID,
categoryID: c.categoryID,
resultID: h.resultID,
+ squad: squads[h.entryID],
}
// 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。
// 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。
diff --git a/server/internal/export/squad.go b/server/internal/export/squad.go
new file mode 100644
index 0000000..cb5caf8
--- /dev/null
+++ b/server/internal/export/squad.go
@@ -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
+}
diff --git a/server/internal/export/squad_dump_test.go b/server/internal/export/squad_dump_test.go
new file mode 100644
index 0000000..0b07e8b
--- /dev/null
+++ b/server/internal/export/squad_dump_test.go
@@ -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()
+}
diff --git a/server/internal/export/squad_test.go b/server/internal/export/squad_test.go
new file mode 100644
index 0000000..52cd30f
--- /dev/null
+++ b/server/internal/export/squad_test.go
@@ -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)
+ }
+}
diff --git a/server/internal/export/team.go b/server/internal/export/team.go
index 4fa1ca9..0e9d6c9 100644
--- a/server/internal/export/team.go
+++ b/server/internal/export/team.go
@@ -23,6 +23,10 @@ type TeamDetailRow struct {
Remark string
// individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。
individual bool
+
+ // squad 是这条流水对应报名项的队名(entries.squad),只有总明细的「单位」列用它:
+ // 单位分表的表名就是单位(一份单位级清单),没有单位列可写,所以那里不展示队名。
+ squad string
}
// TeamDetailWorkbook 是「单位积分明细」的内容。
diff --git a/server/internal/export/team_all.go b/server/internal/export/team_all.go
index b5414da..581915f 100644
--- a/server/internal/export/team_all.go
+++ b/server/internal/export/team_all.go
@@ -94,8 +94,15 @@ func allTeamDetailRows(sheets []TeamDetailSheet, weight float64) [][]any {
unit := displayName(s.Team.Name, s.Team.ShortName)
rows, sub := teamDetailDataRows(s.Rows, weight)
total += sub
- for _, row := range rows {
- out = append(out, append([]any{unit}, row...))
+ for i, row := range rows {
+ // 队名接在单位名后面(口径:单位名+队名):一行分是哪个队拿的,总表上要认得出来。
+ // 空队名时一个字都不加(老数据与单队单位的导出件与升级前逐行一致)。
+ // dataRows 逐行按原序产出,索引与 s.Rows 一一对应。
+ label := unit
+ if i < len(s.Rows) {
+ label = withSquad(unit, s.Rows[i].squad)
+ }
+ out = append(out, append([]any{label}, row...))
}
}
// 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。
diff --git a/server/internal/httpapi/channel_compare_guard_test.go b/server/internal/httpapi/channel_compare_guard_test.go
new file mode 100644
index 0000000..a1a7b17
--- /dev/null
+++ b/server/internal/httpapi/channel_compare_guard_test.go
@@ -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)
+ }
+}
diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go
index 4854aaa..c4ce668 100644
--- a/server/internal/httpapi/dto.go
+++ b/server/internal/httpapi/dto.go
@@ -112,10 +112,15 @@ type profileDTO struct {
}
type entryDTO struct {
- ID int64 `json:"id"`
- EventID int64 `json:"eventId"`
- CategoryID int64 `json:"categoryId"`
- TeamID int64 `json:"teamId"`
+ ID int64 `json:"id"`
+ EventID int64 `json:"eventId"`
+ CategoryID int64 `json:"categoryId"`
+ TeamID int64 `json:"teamId"`
+ // Squad 是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
+ // 空串 = 不区分队伍:单队单位与升级前的老数据都是它,行为与升级前完全一致(见迁移 0025)。
+ // 只对纯团体报名(athleteId 为空)有意义:带运动员的报名按 (组别, 单位, 运动员) 唯一,
+ // 同一个人在同一组别不会因为 squad 而多报一次。
+ Squad string `json:"squad"`
AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"`
diff --git a/server/internal/httpapi/entries_sheet_template.go b/server/internal/httpapi/entries_sheet_template.go
index 451d240..57e0825 100644
--- a/server/internal/httpapi/entries_sheet_template.go
+++ b/server/internal/httpapi/entries_sheet_template.go
@@ -436,8 +436,8 @@ func entryTemplateHeader(language string) []string {
// 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位,
// 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。
//
-// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、12 列
-// 一字不动),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
+// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、列名与
+// importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
// 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见)
// 与这一列的**输入提示**(点中格子就弹出来)。
const (
@@ -505,6 +505,7 @@ var entryTemplateWidths = map[string]float64{
"heat": 8,
"status": 12,
"member_names": 26,
+ "squad": 12,
}
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 {
column := entryTeamCodeColumn()
if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" {
diff --git a/server/internal/httpapi/entries_sheet_template_test.go b/server/internal/httpapi/entries_sheet_template_test.go
index 1adc82c..705b203 100644
--- a/server/internal/httpapi/entries_sheet_template_test.go
+++ b/server/internal/httpapi/entries_sheet_template_test.go
@@ -187,16 +187,16 @@ func TestEntriesTemplateOneSheetPerEventCategory(t *testing.T) {
}
}
-// sheetImportHeader 是分工作表模板的完整 12 列表头(含批次列),与服务端模板逐列一致。
+// sheetImportHeader 是分工作表模板的完整 13 列表头(含批次列与队伍列),与服务端模板逐列一致。
// 既有测试用的 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 的中文别名、前端模板同源)。
func entryTemplateHeaderForTest(t *testing.T) []string {
t.Helper()
return []string{
"项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄",
- "号码布", "种子", "批次", "状态", "队员名单",
+ "号码布", "种子", "批次", "状态", "队员名单", "队伍",
}
}
@@ -214,7 +214,7 @@ func TestEntriesTemplateEnglishHeader(t *testing.T) {
}
want := []string{
"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) {
t.Fatalf("英文模板表头应为规范列名,得到 %v", rows[0])
diff --git a/server/internal/httpapi/entry_heat_test.go b/server/internal/httpapi/entry_heat_test.go
index 0b21abf..c9b39e8 100644
--- a/server/internal/httpapi/entry_heat_test.go
+++ b/server/internal/httpapi/entry_heat_test.go
@@ -7,7 +7,8 @@ import (
"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"
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
diff --git a/server/internal/httpapi/entry_squad.go b/server/internal/httpapi/entry_squad.go
new file mode 100644
index 0000000..6d229d7
--- /dev/null
+++ b/server/internal/httpapi/entry_squad.go
@@ -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
+ })
+}
diff --git a/server/internal/httpapi/entry_squad_edit_test.go b/server/internal/httpapi/entry_squad_edit_test.go
new file mode 100644
index 0000000..f153857
--- /dev/null
+++ b/server/internal/httpapi/entry_squad_edit_test.go
@@ -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)
+}
diff --git a/server/internal/httpapi/entry_squad_internal_test.go b/server/internal/httpapi/entry_squad_internal_test.go
new file mode 100644
index 0000000..0c940fb
--- /dev/null
+++ b/server/internal/httpapi/entry_squad_internal_test.go
@@ -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
+}
diff --git a/server/internal/httpapi/entry_squad_test.go b/server/internal/httpapi/entry_squad_test.go
new file mode 100644
index 0000000..70899f1
--- /dev/null
+++ b/server/internal/httpapi/entry_squad_test.go
@@ -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
+}
diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go
index 82d68af..9c4a0a9 100644
--- a/server/internal/httpapi/handlers_config.go
+++ b/server/internal/httpapi/handlers_config.go
@@ -1048,6 +1048,9 @@ var importColumns = []importColumn{
{name: "heat", aliases: []string{"批次", "批次号"}, optional: true},
{name: "status", aliases: []string{"状态", "报名状态"}},
{name: "member_names", aliases: []string{"队员名单"}},
+ // squad(队伍)挂在**最后**:新列追加在表尾,现场手里那些少一列的旧表格一列都不会错位。
+ // 空串 = 不区分队伍,与老口径一致;同一个单位在同一个组别派两个队就靠它区分(迁移 0025)。
+ {name: "squad", aliases: []string{"队伍", "队名", "队伍名"}, optional: true},
}
// label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列,
@@ -1228,6 +1231,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
+ // 队伍是自由文本(一队 / 二队 / 青年队),不做任何归一:现场怎么写就怎么存,
+ // 「一队」与「一 队」是两个队(系统没有立场替现场合并)。
+ squad := cell("squad")
if teamCode == "" || (table.scope == nil && eventCode == "") {
result.Skipped++
if table.scope == nil {
@@ -1432,11 +1438,21 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
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{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
TeamShares: teamShares,
+ Squad: squad,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(bib),
diff --git a/server/internal/httpapi/joint_team_template_test.go b/server/internal/httpapi/joint_team_template_test.go
index 060317b..51aae4c 100644
--- a/server/internal/httpapi/joint_team_template_test.go
+++ b/server/internal/httpapi/joint_team_template_test.go
@@ -10,7 +10,7 @@ import (
// 积分平均分、奖牌两边各记一枚。
//
// 说明只能挂在「不会变成数据」的载体上:表头行是导入器认模板的锚点(必须留在第 1 行、
-// 12 列一成不变),工作表名是「小项@组别」的范围锚点(也不能多出说明表),
+// 列名与 importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出说明表),
// 所以这里用**单元格批注**(表头「单位编码」那一格)+ **该列的输入提示**(选中即弹出)。
func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) {
env, _, client := recorderWithSeed(t)
diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go
index f54f0c4..ebaa088 100644
--- a/server/internal/httpapi/payload.go
+++ b/server/internal/httpapi/payload.go
@@ -53,6 +53,14 @@ type entryTeamSharesPayload struct {
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 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go
index b2b99b2..ab185df 100644
--- a/server/internal/httpapi/repo_events.go
+++ b/server/internal/httpapi/repo_events.go
@@ -712,10 +712,29 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
// 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时
// 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。
- if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
+ event, err := eventByIDQ(ctx, tx, eventID)
+ if err != nil {
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 {
return nil, internal(err)
}
@@ -777,8 +796,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
// ---------- entries ----------
+// squad 挂在最后:列的追加不该动到既有列的下标(scanEntry 按位置扫,加在中间会整列错位)。
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) {
var (
@@ -794,7 +814,8 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
categoryName sql.NullString
)
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
}
e.AthleteID = i64Ptr(athleteID)
diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go
index be5b02f..baeb38b 100644
--- a/server/internal/httpapi/repo_import.go
+++ b/server/internal/httpapi/repo_import.go
@@ -646,6 +646,10 @@ func staleScoredEntryError(item staleScopeEntry) error {
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
//
+// 纯团体报名(athlete_id 为空)另加**队伍**这一段:同一个单位在同一个组别可以派多个队参赛
+// (迁移 0025),所以查重键是「组别 + 单位 + 队伍」,队伍为空串就是「不区分队伍」的老口径。
+// 带运动员的报名照旧只认 (组别, 单位, 运动员):同一个人在同一组别不会因为队伍多报一次。
+//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
//
@@ -663,8 +667,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus)
} else {
err = s.ev.QueryRowContext(ctx,
- `SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
- in.CategoryID, in.TeamID).Scan(&existing, &oldStatus)
+ `SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ?`,
+ in.CategoryID, in.TeamID, entrySquad(in)).Scan(&existing, &oldStatus)
}
status := orDefault(in.Status, "registered")
@@ -675,12 +679,12 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
return entryImportOutcome{}, err
}
res, err := s.ev.ExecContext(ctx,
- `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, squad)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 {
- return entryImportOutcome{}, err
+ return entryImportOutcome{}, s.importedEntryConflictError(ctx, in, err)
}
newID, err := res.LastInsertId()
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 重算报名导入动过的 (小项, 组别),并推小项与榜单。
//
// 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径:
diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go
index 2ee8fcc..f6c600b 100644
--- a/server/internal/httpapi/server.go
+++ b/server/internal/httpapi/server.go
@@ -279,6 +279,9 @@ func (s *server) routes(mux *http.ServeMux) {
// 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。
// 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。
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))
diff --git a/server/internal/httpapi/team_manual_ranks_score_test.go b/server/internal/httpapi/team_manual_ranks_score_test.go
new file mode 100644
index 0000000..b396bb2
--- /dev/null
+++ b/server/internal/httpapi/team_manual_ranks_score_test.go
@@ -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
+}
diff --git a/server/migrations/event/0025_entry_squad.sql b/server/migrations/event/0025_entry_squad.sql
new file mode 100644
index 0000000..e849d7f
--- /dev/null
+++ b/server/migrations/event/0025_entry_squad.sql
@@ -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;
diff --git a/server/migrations/event/migration_0025_test.go b/server/migrations/event/migration_0025_test.go
new file mode 100644
index 0000000..df8c054
--- /dev/null
+++ b/server/migrations/event/migration_0025_test.go
@@ -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()
+}
diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts
index fc133b4..1134441 100644
--- a/web/src/admin/api/scoring.ts
+++ b/web/src/admin/api/scoring.ts
@@ -9,6 +9,7 @@ import type {
ResultStatus,
ScoreChannel,
ScoringProfile,
+ TeamRankEntry,
TieMode,
} from '@/shared'
@@ -18,9 +19,29 @@ import type {
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
+ * 「按单位录名次」的通道(scope=team + basis=manual)另有一条候选来源,
+ * 见 collectTeamManualCandidates——它读的是 channel_manual_ranks,不是行上的名次。
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
*/
+/** 通道「名次范围」的界面称谓;管理端下拉与预览报错文案共用这一份,免得同一件事两处措辞不同。 */
+export const CHANNEL_SCOPE_LABELS: Record
- 每个单位生成一条报名(同组别 + 单位只留一条),队员名单按列表顺序写入。
+ 每个单位生成一条报名(同组别 + 单位 + 队伍只留一条),队员名单按列表顺序写入。
已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空,
- 所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位。
+ 所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位,
+ 队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。
队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。
按单位录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
不影响个人名次;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
+ 配了联队的组别,成员单位合并成一行,名次录在主单位上(成员单位不再单独录,避免同一个名次重复计分)。
+ {{ teamRankMergedAwayNames.join('、') }} 已并入联队那一行,不再单独录入;
+ 原来单独录过的名次会在保存后清除(同一个名次不再重复计分)。
+
-
diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue
index fef2494..c3e07b4 100644
--- a/web/src/admin/components/baseData/EntryPicker.vue
+++ b/web/src/admin/components/baseData/EntryPicker.vue
@@ -13,9 +13,11 @@ import {
emptyTeamPick,
registeredAthleteIds,
registeredTeamIds,
+ registeredTeamSquads,
selectableTeamIds,
selectionAllowsSubmit,
setPickedTeams,
+ setTeamPickSquad,
teamSelectionAllowsSubmit,
toggleMemberPick,
toggleTeamPick,
@@ -116,6 +118,8 @@ const filteredAthletes = computed(() => {
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id)))
const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id))
+/** 各单位已报名的队名:徽标上写出来,操作员才知道第二支队该填哪个名字。 */
+const registeredSquads = computed(() => registeredTeamSquads(entries.value, currentCategory.value?.id))
const registeredAthletes = computed(() =>
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
}
+/** 某个单位输入框里的队名原文(没填过 = 空串 = 不区分队伍)。 */
+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 {
return expandedTeams.value.includes(teamId)
}
@@ -560,6 +581,8 @@ async function submitTeam(): Promise
@@ -2437,8 +2644,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
- 成绩输入
名次
状态
- 积分(自动)
+
+ 积分(自动)
+
+ 见下方团体名次
+
备注
操作
@@ -2328,19 +2533,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
{{ row.teamShort || row.teamName }}
+ >{{ teamCellText(row) }}
联队
{{ shareText(member) }}
+
+ 队伍 {{ row.squad }}
-
+
+
+
{{ formatPoints(pointsOf(row)) }}
@@ -2501,21 +2709,39 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@@ -2553,11 +2779,29 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
-
+
+
- 保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的, - 点「关闭」不会发出任何请求。 + 保存成员单位后本组的名次与积分会当场重算,大屏榜单跟着刷新;队名是自由文本, + 改了当场反映到单位列与大屏。没改动的话保存按钮是灰的,点「关闭」不会发出任何请求。