feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
+97
-21
@@ -72,6 +72,8 @@
|
|||||||
4. 组别页签旁边有**批次下拉**(`全部` / `第 1 批` / `第 2 批` / … / `未分批`),默认 `全部`(`未分批` 只在确实还有未分批的行时才出现)。选某一批后表格只显示那一批的人,方便录入员**只在自己这一批里找名字**,不用在一大堆人里翻;
|
4. 组别页签旁边有**批次下拉**(`全部` / `第 1 批` / `第 2 批` / … / `未分批`),默认 `全部`(`未分批` 只在确实还有未分批的行时才出现)。选某一批后表格只显示那一批的人,方便录入员**只在自己这一批里找名字**,不用在一大堆人里翻;
|
||||||
5. 配了「按队内平均」通道的小项(例如「个人+团体」项目的团体通道),工具栏下方会出现 **「平均值口径」单选**:`只算有效成绩`(没有有效成绩的人不进分母)或 `全部报名人员都计入`(没成绩的按 0 计入平均)。点选后立即保存到该小项,不需要另外点保存;这是小项级设置,该小项的所有组别共用。
|
5. 配了「按队内平均」通道的小项(例如「个人+团体」项目的团体通道),工具栏下方会出现 **「平均值口径」单选**:`只算有效成绩`(没有有效成绩的人不进分母)或 `全部报名人员都计入`(没成绩的按 0 计入平均)。点选后立即保存到该小项,不需要另外点保存;这是小项级设置,该小项的所有组别共用。
|
||||||
|
|
||||||
|
**批次录入进度条**:工具栏正下方有一条横跨整行的进度条,显示当前筛选范围的录入进度,例如 `第 2 批 已录 5/8 · 3 条有错`(有错的行既不算「已录」,也会在发布时被防呆拦下,所以要单独报出来);**全部录完时文字变成「已录完 8/8」、进度条变绿**。分母就是当前筛选范围的行数——选了某一批就只数这一批。它和发布防呆、行上的状态徽标用的是**同一套判定**,不会出现「进度条说录完了、发布却说缺人」。
|
||||||
|
|
||||||
**批次是什么**:层级是 **大项 → 小项 → 组别 → 批次**。批次只是「把一条组别里的人分成几组,方便分批录入 / 分批查找」的**录入与展示分组**——**不参与算分**:名次与积分仍然按「小项 + 组别」整体计算,分批与否不会让分数、名次发生任何变化(评审口径不会变成「按批排名」)。批次筛选也只影响这张表格显示哪些行,不影响发布、积分榜与大屏。
|
**批次是什么**:层级是 **大项 → 小项 → 组别 → 批次**。批次只是「把一条组别里的人分成几组,方便分批录入 / 分批查找」的**录入与展示分组**——**不参与算分**:名次与积分仍然按「小项 + 组别」整体计算,分批与否不会让分数、名次发生任何变化(评审口径不会变成「按批排名」)。批次筛选也只影响这张表格显示哪些行,不影响发布、积分榜与大屏。
|
||||||
|
|
||||||
**怎么分批**(三种方式,可混用):
|
**怎么分批**(三种方式,可混用):
|
||||||
@@ -88,7 +90,7 @@
|
|||||||
|
|
||||||
### 4.2 录入成绩
|
### 4.2 录入成绩
|
||||||
|
|
||||||
表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。
|
表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
|
||||||
|
|
||||||
| 列 | 操作 |
|
| 列 | 操作 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
|
|
||||||
1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。
|
1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。
|
||||||
- **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。
|
- **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。
|
||||||
2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜。
|
2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5)。
|
||||||
- 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存;
|
- 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存;
|
||||||
- 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。
|
- 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。
|
||||||
|
|
||||||
@@ -163,13 +165,19 @@
|
|||||||
|
|
||||||
1. 这一批跑完,把已出的成绩填上(其他还没跑的行保持空白即可);
|
1. 这一批跑完,把已出的成绩填上(其他还没跑的行保持空白即可);
|
||||||
2. 点「发布」(该组已有发布过的成绩、且还有没发的已填行时,按钮显示为「**发布本批**」);
|
2. 点「发布」(该组已有发布过的成绩、且还有没发的已填行时,按钮显示为「**发布本批**」);
|
||||||
3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去;
|
3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去(末句在延迟结算下会换成「名次与榜单等该组全部录完后再更新」,见下面);
|
||||||
4. 确认后这批成绩立即上屏,**名次与积分先按已发布的这部分算**,大屏立刻有内容;
|
4. 确认后这批成绩立即上屏;**实时结算(默认)下,名次与积分先按已发布的这部分算**,大屏立刻有内容(延迟结算下先不动,见下);
|
||||||
5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新;
|
5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新;
|
||||||
6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。
|
6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。
|
||||||
|
|
||||||
> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。
|
> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。
|
||||||
|
|
||||||
|
**如果管理员关掉了「分批发布时实时更新榜单」**(见 10.5),行为会变成这样:发布后**成绩照常上屏,但名次、积分与奖牌榜都不动**——要等**该项目该组所有人成绩都录完**(退赛 / 失格的人不算在等待里)才一次性结算并更新榜单。未结算期间:
|
||||||
|
|
||||||
|
- 录入页在进度条下方显示一条提示:`延迟结算:本组 X/Y 人已出成绩,名次与榜单等该组全部录完后一起更新`(本页预览的名次仍是实时的),旁边就是「立即结算」按钮;
|
||||||
|
- 该组在**分项成绩 / 最新成绩里的名次显示 `—`**(成绩照常显示)——这是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面;
|
||||||
|
- 有人永远录不出成绩时(例如已退赛但没改状态),在录入页点 **「立即结算」**,系统按当前已发布成绩先结算一次榜单和奖牌;之后补录完还会再自动结算。
|
||||||
|
|
||||||
### 4.7 撤销发布
|
### 4.7 撤销发布
|
||||||
|
|
||||||
已发布的组别会出现「撤销发布」按钮:
|
已发布的组别会出现「撤销发布」按钮:
|
||||||
@@ -178,6 +186,20 @@
|
|||||||
2. 系统作废该组已产生的积分流水,大屏回退到发布前状态;
|
2. 系统作废该组已产生的积分流水,大屏回退到发布前状态;
|
||||||
3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。
|
3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。
|
||||||
|
|
||||||
|
### 4.8 勾选与批量操作(退赛 / 恢复 / 移出报名)
|
||||||
|
|
||||||
|
表格每行最左边有勾选框,表头那个勾选框是**全选当前筛选范围内的行**——它只勾当前组别、当前批次筛选下看得见的行,被筛掉的行不会被选上。勾选后工具栏里出现「已选 N 行」与三个按钮:
|
||||||
|
|
||||||
|
| 按钮 | 作用 | 可恢复吗 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **批量退赛** | 把选中的正常参赛者标记为**退赛(弃权)**:0 分、不占名次 | **可以**,用「批量恢复」撤回 |
|
||||||
|
| **批量恢复** | 把选中的退赛者改回「正常参赛」 | —— |
|
||||||
|
| **批量移出报名** | 把选中的报名项**物理删除**,连同他的成绩与积分流水一起删掉 | **不可以**,需二次确认 |
|
||||||
|
|
||||||
|
- **批量退赛 / 恢复**会按新名次重算积分并推到大屏(若开了延迟结算,同样等该组录完才结算,见 4.6、10.5)。已经是「取消资格(dq)」「未出场(dns)」的行会被**跳过、不覆盖原判定**(跳过的人会在确认框里逐条列出原因,例如「已被判罚取消资格(dq),跳过」)。
|
||||||
|
- **批量移出报名**的确认框会列出**将被删除的人**(姓名|号码布|单位,超过 20 人只列前 20 并报剩余数量)以及**将重算的小项与组别**——看完再确认,删了不能恢复。
|
||||||
|
- 一次最多处理 **500 条**报名项,超出请分批提交;有未保存的成绩修改时,请先「存草稿」再批量操作;示例数据模式下不能批量操作。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 成绩审核
|
## 5. 成绩审核
|
||||||
@@ -189,7 +211,7 @@
|
|||||||
3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化;
|
3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化;
|
||||||
4. 「发布影响」列出受影响单位与分值变化;
|
4. 「发布影响」列出受影响单位与分值变化;
|
||||||
5. 操作:
|
5. 操作:
|
||||||
- **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜,没录的保持未发布,补录后再发一次即可(分批发布,见 4.6);
|
- **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜(延迟结算下同样等该组录完才更新名次与榜单),没录的保持未发布,补录后再发一次即可(分批发布,见 4.6);
|
||||||
- **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。
|
- **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。
|
||||||
|
|
||||||
> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。
|
> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。
|
||||||
@@ -423,12 +445,20 @@
|
|||||||
3. 保存会整体替换该模板的分值明细,请核对后再保存;
|
3. 保存会整体替换该模板的分值明细,请核对后再保存;
|
||||||
4. 页面下方的「榜单口径(全局)」卡片设置**个人项目得分折算系数**(默认 1,步进 0.1,必须大于 0 且不超过 100):`单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水`。它是**全局**设置(键名 `standings.individual_to_team_weight`,不随某一张模板保存,所有模板与所有计分通道共用;导入规则包时,规则包里的同名配置会覆盖它),只有管理员能改,点「保存榜单口径」后榜单按新系数立即变化。「个人+团体」项目的个人通道已经用「计入单位总分」关掉了折算,这里的系数对它没有影响。
|
4. 页面下方的「榜单口径(全局)」卡片设置**个人项目得分折算系数**(默认 1,步进 0.1,必须大于 0 且不超过 100):`单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水`。它是**全局**设置(键名 `standings.individual_to_team_weight`,不随某一张模板保存,所有模板与所有计分通道共用;导入规则包时,规则包里的同名配置会覆盖它),只有管理员能改,点「保存榜单口径」后榜单按新系数立即变化。「个人+团体」项目的个人通道已经用「计入单位总分」关掉了折算,这里的系数对它没有影响。
|
||||||
|
|
||||||
|
**同一张卡片里的「分批发布时实时更新榜单」开关(默认开)**
|
||||||
|
|
||||||
|
- **开(默认)**:每发布一批成绩就立即重算名次、各单位总分与奖牌榜,即 4.6 描述的行为;
|
||||||
|
- **关** = **延迟结算**:分批发布时**只把成绩上屏,不动积分与名次**;等**该项目该组所有批次都录完**(退赛 / 失格的人不算在等待里)才一次性结算,更新榜单与奖牌。未结算的组在分项成绩 / 最新成绩里的**名次显示 `—`**(成绩照常显示)——这是有意的,否则会出现「有金牌但 0 分」的自相矛盾;
|
||||||
|
- 有人永远录不出成绩时,到**成绩录入页**点 **「立即结算」** 兜底:按当前已发布成绩先结算一次,补录完还会再自动结算;
|
||||||
|
- 勾选 / 取消后点「保存结算方式」生效;只有管理员能改。
|
||||||
|
|
||||||
### 10.6 运动员
|
### 10.6 运动员
|
||||||
|
|
||||||
1. 点「新增运动员」,填写姓名 *、性别、年龄、所属单位 *、工号 / 学号、证件号、号码布等(年龄可留空,填了必须是 0–120 的整数);
|
1. 点「新增运动员」,填写姓名 *、性别、年龄、所属单位 *、工号 / 学号、证件号、号码布等(年龄可留空,填了必须是 0–120 的整数);
|
||||||
2. **证件号(如身份证号)与号码布二者取其一**即可唯一标识一个人,用于区分重名;两个人员不能共用同一个证件号,也不能共用同一个号码布,重复会被拒绝;没有身份证信息的人可以只填号码布。已有历史数据两项可以都为空;
|
2. **号码布是单人的唯一标识**,同名同姓的人靠它区分,两个人的号码布不能重复;**证件号(如身份证号)是可选的档案信息**,报名导入**不按证件号认人**,但系统里**一个证件号也只能属于一个人**,与别人重复同样会被拒绝。没有身份证信息的人只填号码布就行;两项都不填也能保存档案,但这样的人员**不能在「报名导入 → 个人报名」里勾选**(系统认不出是谁)。已有历史数据两项可以都为空;
|
||||||
3. 每行有三个动作:**编辑**、**停用**(停用后按钮变成「启用」)、**彻底删除**;
|
3. 每行有三个动作:**编辑**、**停用**(停用后按钮变成「启用」)、**彻底删除**;
|
||||||
4. 个人项目的报名项会关联到运动员,团体项目可以留空。
|
4. 个人项目的报名项会关联到运动员,团体项目可以留空;
|
||||||
|
5. **多选与批量操作**:列表每行有勾选框,表头是「全选当前筛选结果」(只勾当前筛出来的人)。选中后顶部出现「已选 N 人」与 **批量停用** / **批量彻底删除** 两个按钮,整批只提交一个请求,不用一个个点;**批量彻底删除**会先预览将删掉谁、重算哪些小项,确认后才执行,规则与单个「彻底删除」完全一致(见下)。
|
||||||
|
|
||||||
**停用与彻底删除的区别**
|
**停用与彻底删除的区别**
|
||||||
|
|
||||||
@@ -467,7 +497,7 @@
|
|||||||
**个人报名**(一人一条报名)
|
**个人报名**(一人一条报名)
|
||||||
|
|
||||||
1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项;
|
1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项;
|
||||||
2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号(优先)或号码布。
|
2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号与号码布。
|
||||||
|
|
||||||
**团体报名**(一个单位一条报名)
|
**团体报名**(一个单位一条报名)
|
||||||
|
|
||||||
@@ -483,26 +513,29 @@
|
|||||||
|
|
||||||
1. 点「下载 CSV 模板」获取表头样例;
|
1. 点「下载 CSV 模板」获取表头样例;
|
||||||
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. 表头共 12 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)为可选列,缺失不影响导入,其余列必须包含;
|
||||||
3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;`age` 可留空,填了必须是 0–120 的整数;同一份文件里同一个证件号或同一个号码布重复出现时,重复的行会报错;
|
3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;`age` 可留空,填了必须是 0–120 的整数;**同一份文件里同一个号码布重复出现会报错并跳过**(号码布是唯一标识);同一个证件号重复出现**不再报错**,但证件号撞上系统里**另一个人**时会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过该行;
|
||||||
4. **`status` 列很宽容**:大小写不敏感(`DNS`、`Registered`、`registered` 都认),也认中文别名——
|
4. **`status` 列很宽容**:大小写不敏感(`DNS`、`Registered`、`registered` 都认),也认中文别名——
|
||||||
- `正常` / `正常参赛` / `参赛` → 正常参赛;
|
- `正常` / `正常参赛` / `参赛` → 正常参赛;
|
||||||
- `退赛` / `弃赛` / `请假` → 退赛;
|
- `退赛` / `弃赛` / `请假` → 退赛;
|
||||||
- `取消资格` / `犯规` → 取消资格;
|
- `取消资格` / `犯规` → 取消资格;
|
||||||
- `未出场` / `未到` / `缺席` → 未出场。
|
- `未出场` / `未到` / `缺席` → 未出场。
|
||||||
留空即「正常参赛」。真正认不出的写法会给出中文行级错误、该行跳过,例如「第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)」;
|
留空即「正常参赛」。真正认不出的写法会给出中文行级错误、该行跳过,例如「第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)」;
|
||||||
5. 人员匹配顺序:
|
5. 人员匹配顺序(**号码布是单人的唯一标识,证件号不参与认人**,只作为档案信息写入 / 更新):
|
||||||
1. 先按 `id_card` 匹配已有人员;
|
1. 先按 `bib`(号码布)匹配:命中即认作同一人;姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上时进「冲突」列表,等你逐条决定(见下);
|
||||||
2. 没匹配到,再按 `bib`(号码布)匹配;
|
2. 号码布为空、或按号码布没找到,再按「姓名 + 单位」找:
|
||||||
3. 还没匹配到,按「姓名 + 单位」匹配且该人员证件号为空,视为同一人,把表里给的证件号 / 号码布 / 年龄补进去(不算冲突);如果该人员已有号码布且与表里不同,则算冲突;
|
- 恰好一人、且他还没有号码布 → 认作同一人,表里给的号码布 / 证件号 / 年龄会**回填**进他的档案(不算冲突);
|
||||||
4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来;
|
- 恰好一人、且他已有号码布(与表里不同)→ 按「**同名的另一个人**」新建档案,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒核对,**不算错误、不拦截**;
|
||||||
6. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布;
|
- 多人 → 分不清是谁,进「冲突」列表;
|
||||||
|
3. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来;
|
||||||
|
4. 证件号不参与认人,但**一个证件号只能属于一个人**:表里给的证件号若已被别人占用,该行会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过,不会把证件号抢过来;
|
||||||
|
6. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号与号码布;
|
||||||
7. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
7. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
||||||
- **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别;
|
- **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别;
|
||||||
8. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
8. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
||||||
|
|
||||||
**人员信息冲突的处理**
|
**人员信息冲突的处理**
|
||||||
|
|
||||||
导入时如果某个证件号或号码布在系统里已经存在,但表里的姓名、性别、年龄、单位或号码布与已有人员不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法:
|
导入时如果按号码布(或「姓名 + 单位」唯一命中)找到的人在系统里已有记录,但表里的姓名、性别、年龄、单位或号码布与他的档案不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法:
|
||||||
|
|
||||||
1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段(含年龄与号码布);
|
1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段(含年龄与号码布);
|
||||||
2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」:
|
2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」:
|
||||||
@@ -512,13 +545,17 @@
|
|||||||
|
|
||||||
> 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。
|
> 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。
|
||||||
|
|
||||||
|
**「同名的另一个人」提醒(不是冲突)**
|
||||||
|
|
||||||
|
如果表里某人的号码布与库里「同名 + 同单位」的人不同,系统按**同名的另一个人**给他**新建档案**,并在导入结果里单独列出一块:`以下 N 人按同名的另一个人新建了档案(号码布不同)`,逐条显示 `姓名(单位):原有号码布 XXX → 新号码布 XXX`。这些行**已经导入成功、不会拦截**,只是提醒你核对号码布有没有写错;若确认是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来、并删掉多出来的那条档案即可。
|
||||||
|
|
||||||
种子等字段无法在勾选界面填写,仍需通过表格导入;号码布取自人员档案,勾选时会自动写入报名。团体报名登记的队员名单只存姓名,不会带动运动员档案。
|
种子等字段无法在勾选界面填写,仍需通过表格导入;号码布取自人员档案,勾选时会自动写入报名。团体报名登记的队员名单只存姓名,不会带动运动员档案。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 导出
|
## 11. 导出
|
||||||
|
|
||||||
页面分三个页签。
|
页面分四个页签:**结果导出** / **基础数据** / **赛事项目包** / **规则包**。
|
||||||
|
|
||||||
### 11.1 结果导出(xlsx)
|
### 11.1 结果导出(xlsx)
|
||||||
|
|
||||||
@@ -529,10 +566,12 @@
|
|||||||
| 全部(单页) | 所有单位、所有项目、所有成绩**平铺在一张表**里(一个工作簿只有一张表),适合筛选与二次统计 | `赛事成绩总表(单页).xlsx` |
|
| 全部(单页) | 所有单位、所有项目、所有成绩**平铺在一张表**里(一个工作簿只有一张表),适合筛选与二次统计 | `赛事成绩总表(单页).xlsx` |
|
||||||
| 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` |
|
| 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` |
|
||||||
| 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` |
|
| 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` |
|
||||||
|
| 报名名单 | **本页下方的「报名名单」卡片**:按小项 / 组别 / 单位 / 批次筛选后导出报名数据(不填 = 全部)。13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、**号码布**、**批次**、性别、年龄、状态、备注;号码布按文本导出,前导零不会丢 | `报名名单.xlsx` |
|
||||||
| 团体奖牌明细 | **在「积分榜 → 团体奖牌榜」页操作**:逐枚奖牌列出单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队,每个有奖牌的单位一张分表(末尾小计) | `团体奖牌明细.xlsx` |
|
| 团体奖牌明细 | **在「积分榜 → 团体奖牌榜」页操作**:逐枚奖牌列出单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队,每个有奖牌的单位一张分表(末尾小计) | `团体奖牌明细.xlsx` |
|
||||||
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
|
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
|
||||||
|
|
||||||
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;
|
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导);
|
||||||
|
- **录了但还没发布的成绩也会占一行**(「分项成绩表」「全部」「全部(单页)」都是):这类行的成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」,人不会从表上消失;**完全没有录成绩的报名行**(连草稿都没有)保持原样——各列留空、状态显示「未录入」;
|
||||||
- **「全部」与「全部(单页)」的区别**:两者内容同源,覆盖的都是全部小项的全部报名 / 成绩;「全部」把每个小项放成一张分表(多 sheet 工作簿),「全部(单页)」把所有行平铺进同一张表,每行前面补「大项 / 小项 / 组别」三列以便脱离表名也能看懂。单页表的列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注;
|
- **「全部」与「全部(单页)」的区别**:两者内容同源,覆盖的都是全部小项的全部报名 / 成绩;「全部」把每个小项放成一张分表(多 sheet 工作簿),「全部(单页)」把所有行平铺进同一张表,每行前面补「大项 / 小项 / 组别」三列以便脱离表名也能看懂。单页表的列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注;
|
||||||
- **「团体奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响;
|
- **「团体奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响;
|
||||||
- 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。
|
- 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。
|
||||||
@@ -573,6 +612,28 @@
|
|||||||
|
|
||||||
> 规则导入会立即改变积分榜与大屏,且**不能一键撤销**,纠错需重新导入旧规则包。
|
> 规则导入会立即改变积分榜与大屏,且**不能一键撤销**,纠错需重新导入旧规则包。
|
||||||
|
|
||||||
|
### 11.4 基础数据(单位 / 大项 / 小项 / 组别)
|
||||||
|
|
||||||
|
「基础数据」页签有**导出**与**导入**两张卡片,用于批量维护基础数据(单位 / 大项 / 小项 / 组别)。
|
||||||
|
|
||||||
|
**导出基础数据包**:点「导出 xlsx」导出一个工作簿,含**四张工作表:单位 / 大项 / 小项 / 组别**(组别表带所属小项编码)。列名是中文表头;导出的文件可以直接原样导回来,识别到的差异为 0。
|
||||||
|
|
||||||
|
**导入基础数据包**(**仅管理员**):
|
||||||
|
|
||||||
|
1. 选择 `.xlsx` 文件(只认 xlsx:四张表装不进一个 CSV);
|
||||||
|
2. 点「**预览差异**」:先看到 `新增 N 条 / 更新 M 条 / 无变化 K 条 / 错误 K 条` 与**逐行变化**(哪个字段从什么变成什么),这一步**不写库**;
|
||||||
|
3. 确认无误后点「**确认写入**」才真正写库;**有错的行会被跳过,合法的行照常写入**(与报名导入一致);行级错误是中文并带**真实 Excel 行号**,例如 `单位 第 3 行:单位编码必填`。
|
||||||
|
|
||||||
|
几条现场要记住的规则:
|
||||||
|
|
||||||
|
- **按编码匹配**:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;**存在就更新、不存在就新建**;
|
||||||
|
- **绝不删除**:文件里没有的行(表里没有、或整张工作表没放)**不会被删掉**,也不会被停用;
|
||||||
|
- **列不在表里 = 不动该字段**;**列在表里但格子留空 = 按该列的默认口径处理**:简称 / 颜色 / 场地 / 备注等文本列与年龄上下限会**清空**,权重留空按 1、启用 / 计分 / 计破纪录等布尔列留空按「是」,**排序留空则保持原来的值**。改数据前先想清楚这一点;
|
||||||
|
- **表头行可以不在第一行**:从系统导出的包表头在第 4 行(上面是标题与副标题),手工整理的表表头在第 1 行,两种都能识别(识别范围是前 20 行);
|
||||||
|
- **枚举列要填系统里的英文规范值**(表头是中文,容易踩):类型 `individual` / `team` / `match` / `individual_team`,录入方式 `auto` / `manual`,成绩类型 `time` / `distance` / `count` / `score` / `none`,比较方式 `asc` / `desc` / `none`,小项状态 `pending` / `ongoing` / `finished` / `published`,平均口径 `valid` / `all`;布尔列(启用 / 计分 / 计破纪录)填 **是 / 否**;组别表的「性别」填 **男 / 女**(留空 = 不限);
|
||||||
|
- **最稳的用法**:先「导出基础数据包」,在导出件上直接改,再导回来——表头与取值天然对得上;手工整理的表格请照导出件的表头写;
|
||||||
|
- 被「彻底删除」的单位不在导出件里;导入**不会**把它翻出来(已有的隐藏记录只更新字段,仍是隐藏状态)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 变更历史
|
## 12. 变更历史
|
||||||
@@ -602,7 +663,7 @@
|
|||||||
|
|
||||||
## 14. 系统设置
|
## 14. 系统设置
|
||||||
|
|
||||||
页面分三个页签,均需要管理员权限。
|
页面分四个页签:**用户管理** / **登录有效期** / **备份** / **赛事信息**。前三个需要管理员权限,非管理员只看到「赛事信息」(其余页签不显示)。
|
||||||
|
|
||||||
### 14.1 用户管理
|
### 14.1 用户管理
|
||||||
|
|
||||||
@@ -663,6 +724,21 @@
|
|||||||
|
|
||||||
> 修改大屏标题、主题色、字体等会立即影响所有在线大屏,请避开颁奖或播报时段。
|
> 修改大屏标题、主题色、字体等会立即影响所有在线大屏,请避开颁奖或播报时段。
|
||||||
|
|
||||||
|
### 14.4 登录有效期
|
||||||
|
|
||||||
|
「登录有效期」页签(**仅管理员**)设置**这台机器上所有人的登录能待多久**。它是机器级配置(存在系统库 `system.db` 里),不随赛事项目包走。
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 登录有效期(小时) | 可填 **1–720** 的整数(最长 30 天),默认 **24 小时**;原先固定 12 小时的做法已取消 |
|
||||||
|
| 当前会话将于 X 过期 | 页面顶部与右上角用户菜单里都会显示当前登录的到期时间 |
|
||||||
|
|
||||||
|
- **滑动续期**:只要在有效期内继续操作(两次操作的间隔不超过设定时长),到期时间会自动往后顺延——**不会因为「登录满 N 小时」被踢**。因此比赛期间可以从早用到晚不重新登录,例如把有效期设成 **72 小时**(三天);
|
||||||
|
- **改有效期只对之后的新登录生效**:已经登录的会话保持各自的到期时间,不会被当场踢下线;
|
||||||
|
- 页面开着不动时,管理端每 10 分钟会自动续期一次,所以只要页面还开着就不会掉线;
|
||||||
|
- ⚠️ **不是「永不掉线」**:如果超过**设定的有效期**(默认 24 小时)一直没有操作(例如浏览器关了、机器关机过夜),再打开时仍会要求重新登录。
|
||||||
|
- 忘记密码时用 `scoreboard.exe passwd` 重置(见常见问题「忘记密码怎么办」)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. 修改密码
|
## 15. 修改密码
|
||||||
@@ -772,10 +848,10 @@
|
|||||||
结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在数据目录下的 `exports\` 里生成临时包(导出完成后自动删除)。数据目录默认是 `%LOCALAPPDATA%\ScoreboardConsole\data`,详见部署指南第 3.4 节。
|
结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在数据目录下的 `exports\` 里生成临时包(导出完成后自动删除)。数据目录默认是 `%LOCALAPPDATA%\ScoreboardConsole\data`,详见部署指南第 3.4 节。
|
||||||
|
|
||||||
**Q:登录多久会过期?**
|
**Q:登录多久会过期?**
|
||||||
会话有效期 12 小时。过期后自动回到只读访客视图,重新登录即可。
|
默认 **24 小时**,可由管理员在「系统设置 → 登录有效期」里改成 **1–720 小时**(最长 30 天);比赛期间建议设成 72 小时。会话是**滑动续期**的:有效期内继续操作会自动顺延到期时间,不会因为「登录满 N 小时」被踢;但超过设定的有效期一直没操作(比如浏览器关了、机器关机过夜),就需要重新登录。当前到期时间在右上角用户菜单与「系统设置 → 登录有效期」里都能看到。
|
||||||
|
|
||||||
**Q:为什么登录失败次数多了会被暂时拒绝?**
|
**Q:为什么登录失败次数多了会被暂时拒绝?**
|
||||||
登录有频率限制(每个 IP 每分钟 5 次),写操作也有频率限制(每个用户每分钟 30 次)。稍等一分钟再试即可。
|
登录有频率限制(每个 IP 每分钟 5 次),写操作也有频率限制(每个用户每分钟 120 次)。稍等一分钟再试即可。
|
||||||
|
|
||||||
**Q:为什么有些页面 / 按钮看不到或不可用?**
|
**Q:为什么有些页面 / 按钮看不到或不可用?**
|
||||||
权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。
|
权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。
|
||||||
+60
-12
@@ -18,7 +18,7 @@
|
|||||||
访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。
|
访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。
|
||||||
|
|
||||||
**Q4:为什么登录后还是只读?**
|
**Q4:为什么登录后还是只读?**
|
||||||
可能未登录成功、会话已过期(有效期 12 小时),或页面处于示例数据模式。看右上角是否显示账号名与角色。
|
可能未登录成功、会话已过期(登录有效期默认 24 小时、可配 1–720 小时,见 Q89),或页面处于示例数据模式。看右上角是否显示账号名与角色。
|
||||||
|
|
||||||
**Q5:可以建「访客账号」吗?**
|
**Q5:可以建「访客账号」吗?**
|
||||||
不可以。访客是未登录时的默认身份,不需要也不允许建账号;可落库的角色只有管理员和录入员。
|
不可以。访客是未登录时的默认身份,不需要也不允许建账号;可落库的角色只有管理员和录入员。
|
||||||
@@ -219,13 +219,13 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
## 导出与导入
|
## 导出与导入
|
||||||
|
|
||||||
**Q43:能导出哪些文件?**
|
**Q43:能导出哪些文件?**
|
||||||
结果导出页有五类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、团体奖牌榜卡片头的**导出奖牌明细**(`团体奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)与规则包(YAML)。
|
「导出 → 结果导出」页有六类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`),以及可筛选的**报名名单**(`报名名单.xlsx`,见 Q87)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、团体奖牌榜卡片头的**导出奖牌明细**(`团体奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)、**基础数据包**(xlsx,见 Q88)与规则包(YAML)。
|
||||||
|
|
||||||
**Q44:导出文件保存在哪?**
|
**Q44:导出文件保存在哪?**
|
||||||
浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。
|
浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。
|
||||||
|
|
||||||
**Q45:谁能导入规则包 / 赛事项目包?**
|
**Q45:谁能导入规则包 / 赛事项目包 / 基础数据包?**
|
||||||
只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。
|
三者都只有管理员能导入。规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认;**基础数据包**按编码「存在就更新、不存在就新建」,**绝不删除**(见 Q88)。
|
||||||
|
|
||||||
**Q46:导入赛事包后为什么看不到数据?**
|
**Q46:导入赛事包后为什么看不到数据?**
|
||||||
因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。
|
因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。
|
||||||
@@ -237,20 +237,27 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
**Q47:导入规则包后积分变了,能撤销吗?**
|
**Q47:导入规则包后积分变了,能撤销吗?**
|
||||||
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
|
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
|
||||||
|
|
||||||
**Q48:导入时提示证件号重复怎么办?**
|
**Q48:报名表里的证件号重复了怎么办?**
|
||||||
证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。
|
先分清两种「重复」,处理方式完全不同:
|
||||||
|
|
||||||
- **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。
|
- **同一份表里同一个证件号出现多次**:**不再报错**,照常导入(同一个人报多项时,证件号本来就会重复)。证件号只是**档案信息,不参与认人**。
|
||||||
- **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
- **证件号已经登记在系统里另一个人名下**(姓名、号码布都对不上):这一行会报 `第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并**跳过该行**。系统里一个证件号只能属于一个人(避免把两个人的身份弄混)。处理办法:先核对证件号是不是抄错了——不是这个人的,把这一格留空即可(只填号码布也能导入);确实是他的,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传。
|
||||||
|
- **在「运动员」里新增 / 编辑时提示证件号重复**:同上,说明该证件号已属于另一个人。是同一个人的话直接用已有记录;证件号填错就改对。已有历史数据可以没有证件号。
|
||||||
|
|
||||||
|
详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
||||||
|
|
||||||
**Q49:人员没有身份证,只有号码布,能报名吗?**
|
**Q49:人员没有身份证,只有号码布,能报名吗?**
|
||||||
可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。
|
可以,这正是**号码布作为唯一标识**的用法:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。反过来,证件号只是**可选的档案信息**——只填证件号、没有号码布也能导入,但系统是**按号码布认人**的(号码布为空时按「姓名 + 单位」认),所以能编号码布就编上,重名重姓时才分得清。
|
||||||
|
|
||||||
**Q50:号码布重复了怎么办?**
|
**Q50:号码布重复了怎么办?**
|
||||||
号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。
|
号码布是**单人的唯一标识**,系统不允许两个人员共用同一个号码布。
|
||||||
|
|
||||||
- **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。
|
- **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。
|
||||||
- **表格导入时出现重复**:同一份文件里同一个号码布重复出现,重复的行会报错并跳过,改好后重新上传;号码布在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
- **同一份报名表里号码布重复出现**:重复的行会报错并跳过,改好后重新上传。
|
||||||
|
- **号码布在系统里已有、但表里这个人跟他对不上**(姓名 / 性别 / 年龄 / 单位不同):会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。
|
||||||
|
- **表里某人的号码布与库里「同名 + 同单位」的人不同**:系统按「**同名的另一个人**」给他**新建档案**(不报错),并在导入结果里单独列出 `姓名(单位):原有号码布 XXX → 新号码布 XXX` 提醒核对。多半是号码布写错了:到「基础数据 → 运动员」把档案里的号改回来、删掉多出来的那条档案即可。
|
||||||
|
|
||||||
|
详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
||||||
|
|
||||||
**Q51:导入 CSV 时中文变成乱码,或者提示「报名表缺少列 event_code」怎么办?**
|
**Q51:导入 CSV 时中文变成乱码,或者提示「报名表缺少列 event_code」怎么办?**
|
||||||
**不用转码,直接传原文件就行。** 服务端会自己识别编码:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16(PowerShell `Export-Csv` 的默认编码)都能正常导入。
|
**不用转码,直接传原文件就行。** 服务端会自己识别编码:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16(PowerShell `Export-Csv` 的默认编码)都能正常导入。
|
||||||
@@ -272,7 +279,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
> 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。
|
> 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。
|
||||||
|
|
||||||
**Q54:为什么登录 / 写操作偶尔被拒绝?**
|
**Q54:为什么登录 / 写操作偶尔被拒绝?**
|
||||||
系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。
|
系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 **120 次**。稍等一分钟再试。
|
||||||
|
|
||||||
**Q55:数据目录在哪?**
|
**Q55:数据目录在哪?**
|
||||||
默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传资源 `uploads\`,图片与自定义字体都在里面,字体在 `uploads\fonts\`)、`backups\`、`exports\`。
|
默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传资源 `uploads\`,图片与自定义字体都在里面,字体在 `uploads\fonts\`)、`backups\`、`exports\`。
|
||||||
@@ -516,3 +523,44 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
- 对话框不会超出屏幕,按钮与分页的点击区域也放大了,触屏点得中。
|
- 对话框不会超出屏幕,按钮与分页的点击区域也放大了,触屏点得中。
|
||||||
|
|
||||||
录入、发布、看榜这些日常操作手机上都能做;赛事配置这类条目较多的操作仍建议在电脑上完成。如果某台手机上仍显示不全,请把**机型、浏览器与屏幕宽度**(或截图)告诉维护人员——目前适配覆盖到 390px 宽的窄屏,桌面端布局不受影响。
|
录入、发布、看榜这些日常操作手机上都能做;赛事配置这类条目较多的操作仍建议在电脑上完成。如果某台手机上仍显示不全,请把**机型、浏览器与屏幕宽度**(或截图)告诉维护人员——目前适配覆盖到 390px 宽的窄屏,桌面端布局不受影响。
|
||||||
|
|
||||||
|
**Q86:号码布和证件号,哪个才是系统认人的依据?**
|
||||||
|
**号码布。** 报名导入时的匹配顺序是「号码布 → 姓名 + 单位」;**证件号不参与认人**,只作为可选的档案信息写入 / 更新。不过系统里**一个证件号仍然只能属于一个人**,撞上别人的证件号会明确报错并指出占用者(见 Q48)。完整匹配规则见[报名名单导入规范](./报名名单导入规范.md)。
|
||||||
|
|
||||||
|
**Q87:怎么把报名名单导出来?**
|
||||||
|
到「导出 → 结果导出」页,往下找**「报名名单」**卡片:先按**小项 / 组别 / 单位 / 批次**筛选(都可以留空 = 不筛,即导全部),再点「导出报名名单」,得到 `报名名单.xlsx`。表里有 13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、号码布、批次、性别、年龄、状态(中文:正常参赛 / 已退赛 / 取消资格 / 未出场)、备注;号码布按文本导出,前导零不会丢。访客也能导出(只读)。
|
||||||
|
|
||||||
|
**Q88:基础数据(单位 / 大项 / 小项 / 组别)能批量导入导出吗?**
|
||||||
|
能。到「导出 → 基础数据」页:
|
||||||
|
|
||||||
|
- **导出**:一个工作簿四张工作表(单位 / 大项 / 小项 / 组别,组别表带所属小项编码);
|
||||||
|
- **导入**(**仅管理员**,只认 `.xlsx`):按编码匹配,**存在就更新、不存在就新建,绝不删除**(表里没有的行不会被删、也不会被停用)。先点「预览差异」看「新增 / 更新 / 无变化 / 错误」与逐行变化,确认后再点「**确认写入**」;**有错的行会跳过、合法的行照常写入**,行级错误是中文并带真实 Excel 行号(如 `单位 第 3 行:单位编码必填`)。
|
||||||
|
|
||||||
|
要点:**列不在表里 = 不动该字段**;**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。
|
||||||
|
|
||||||
|
**Q89:登录会不会用着用着被踢下线?「登录有效期」怎么设?**
|
||||||
|
默认有效期 **24 小时**,管理员可在「系统设置 → 登录有效期」里改成 **1–720 小时**(最长 30 天);比赛期间建议设成 **72 小时**。会话是**滑动续期**的:只要在有效期内继续操作(页面开着也会定期自动续期),到期时间就往后顺延——**不会因为「登录满 N 小时」被踢**。当前到期时间在右上角用户菜单与系统设置页都写着「当前会话将于 X 过期」。
|
||||||
|
|
||||||
|
⚠️ 但它不是「永不掉线」:超过设定的有效期一直没操作(浏览器关掉、机器关机过夜),再打开仍要重新登录。改设置**只对之后的新登录生效**,已经登录的会话不会被当场踢下线。
|
||||||
|
|
||||||
|
**Q90:发布后成绩上屏了,为什么积分榜没变、名次还是「—」?**
|
||||||
|
多半是管理员把「榜单口径」里的**「分批发布时实时更新榜单」关掉了**(默认是开的),也就是**延迟结算**:分批发布时只把成绩上屏,**积分与名次等该项目该组所有批次都录完才一次性结算**(退赛 / 失格的人不算在等待里)。未结算组的**名次显示 `—`**(成绩照常显示)是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面。
|
||||||
|
|
||||||
|
想让榜单立刻跟上:等该组录完会自动结算;确实有人永远录不出成绩时,在**成绩录入页**点 **「立即结算」**,按当前已发布成绩先结算一次。想改回实时结算:管理员到「基础数据 → 积分模板 → 榜单口径」,把「分批发布时实时更新榜单」勾上并点「保存结算方式」(见使用手册 10.5)。
|
||||||
|
|
||||||
|
**Q91:能不能一次把多个人退赛 / 恢复 / 移出报名?**
|
||||||
|
能,在「成绩录入」页操作:表格每行最左边勾选(表头是全选,**只作用于当前筛选范围**),选中后工具栏出现 **批量退赛** / **批量恢复** / **批量移出报名**。
|
||||||
|
|
||||||
|
- **批量退赛**:标记弃权(0 分、不占名次),**随时可以批量恢复**;已经是「取消资格 / 未出场」的行会被跳过、不覆盖原判定;
|
||||||
|
- **批量移出报名**:**物理删除**报名项连同他的成绩与积分流水,**不可恢复**,确认框会列出将被删的人与将重算的小项 / 组别;
|
||||||
|
- 一次最多 500 条;有未保存的成绩修改时请先「存草稿」再操作。
|
||||||
|
|
||||||
|
运动员列表也有类似的多选与**批量停用 / 批量彻底删除**(见使用手册 10.6)。
|
||||||
|
|
||||||
|
**Q92:新建单位 / 大项 / 小项时没填「启用」「计分」这类开关,会不会被静默关掉?**
|
||||||
|
不会。**漏传这些开关时会取默认值,而不是按「关」处理**:
|
||||||
|
|
||||||
|
- **单位 / 大项**:漏传「启用」= **默认启用**(以前会静默建成「停用」,在界面上等于不存在);**更新**时漏传则沿用原来的状态,不会顺手把它停掉;
|
||||||
|
- **小项**:漏传「计分」「计破纪录」= **两个都默认打开**(以前会被静默建成「只排名不计分、不能破纪录」);更新时漏传同样沿用原值。
|
||||||
|
|
||||||
|
用**基础数据包**导入时同理:「启用 / 计分 / 计破纪录」列留空 = 「是」,这些列**不在表里 = 不改动**该字段(见 Q88)。
|
||||||
+27
-11
@@ -25,10 +25,10 @@
|
|||||||
| `category` | **必须** | 组别**名称**(中文,要跟系统里一字不差) | `男子30岁以下` |
|
| `category` | **必须** | 组别**名称**(中文,要跟系统里一字不差) | `男子30岁以下` |
|
||||||
| `team_code` | **必须** | 参赛单位编码,抄系统里配好的 | `F1` |
|
| `team_code` | **必须** | 参赛单位编码,抄系统里配好的 | `F1` |
|
||||||
| `athlete_name` | **必须** | 运动员姓名。**团体项目这一列留空** | `张伟` |
|
| `athlete_name` | **必须** | 运动员姓名。**团体项目这一列留空** | `张伟` |
|
||||||
| `id_card` | 可选 | 证件号(身份证号) | `110101199001011234` |
|
| `id_card` | 可选 | 证件号(身份证号)。**只是档案信息,不参与认人**;一个证件号只能属于一个人 | `110101199001011234` |
|
||||||
| `gender` | 可选 | 性别,只写「男」或「女」 | `男` |
|
| `gender` | 可选 | 性别,只写「男」或「女」 | `男` |
|
||||||
| `age` | 可选 | 年龄,0–120 的整数 | `26` |
|
| `age` | 可选 | 年龄,0–120 的整数 | `26` |
|
||||||
| `bib` | 可选 | 号码布 | `101` |
|
| `bib` | 可选 | 号码布,**单人的唯一标识**(同名同姓的人靠它区分) | `101` |
|
||||||
| `seed` | 可选 | 道次 / 种子,随便写或留空 | `种子1` |
|
| `seed` | 可选 | 道次 / 种子,随便写或留空 | `种子1` |
|
||||||
| `heat` | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
|
| `heat` | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
|
||||||
| `status` | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
|
| `status` | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
|
||||||
@@ -60,11 +60,14 @@
|
|||||||
- **团体项目**:**这一列留空**,表示"这是某个单位的一条团体报名",队员写在 `member_names` 里。
|
- **团体项目**:**这一列留空**,表示"这是某个单位的一条团体报名",队员写在 `member_names` 里。
|
||||||
- 姓名为空的行,系统只建一条单位报名,不会去建运动员档案。
|
- 姓名为空的行,系统只建一条单位报名,不会去建运动员档案。
|
||||||
|
|
||||||
### `id_card` 证件号 / `bib` 号码布 —— 二选一,至少填一个
|
### `id_card` 证件号 / `bib` 号码布 —— 有姓名就至少填一个
|
||||||
只要有姓名,**证件号与号码布至少要填一个**,否则无法区分同名同姓的两个人,这一行会被跳过,提示「填写了 athlete_name 就必须填 id_card 或 bib」。
|
只要有姓名,**证件号与号码布至少要填一个**,否则无法确认是哪一个人,这一行会被跳过,提示「填写了 athlete_name 就必须填 id_card 或 bib」。
|
||||||
|
|
||||||
- 优先认证件号;没有证件号就认号码布(比如没有身份证的、外聘人员)。
|
- **号码布是单人的唯一标识,也是系统认人的依据**:先按号码布找人,号码布为空或没找到时,再按「姓名 + 单位」找(完整顺序见本节的「系统怎么认人」)。
|
||||||
- **同一份表里,同一个证件号或同一个号码布只能出现一次**,重复的会被跳过并报错。
|
- **证件号是可选的档案信息,不参与认人**:它只写进 / 更新人员档案,用来登记身份;没有身份证、外聘人员只填号码布就行。
|
||||||
|
- **但一个证件号在系统里只能属于一个人**:如果表里给的证件号已经登记在**另一个人**名下(姓名、号码布都对不上),这一行会报 `证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并跳过——证件号不会被抢过来,那个人也不会被改成表里的人。
|
||||||
|
- **同一个证件号在同一份表里重复出现不再报错**(同一个人报多项时本来就会重复),照常导入。
|
||||||
|
- **号码布不能在一份表里重复出现**:重号说明要么是填错、要么是把号码布给了两个人,重复的行会被跳过并报错。
|
||||||
- 号码布会同时写进人员档案和报名记录。
|
- 号码布会同时写进人员档案和报名记录。
|
||||||
|
|
||||||
### `gender` 性别
|
### `gender` 性别
|
||||||
@@ -114,6 +117,16 @@
|
|||||||
- 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。
|
- 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。
|
||||||
- 系统界面里会把这条名单显示成「张三、李四、王五」。
|
- 系统界面里会把这条名单显示成「张三、李四、王五」。
|
||||||
|
|
||||||
|
### 系统怎么认人(号码布 → 姓名 + 单位)
|
||||||
|
有姓名的行,系统按下面的顺序认人。**号码布是唯一标识,证件号只当档案信息,不参与这一步**:
|
||||||
|
|
||||||
|
| 情况 | 系统怎么处理 |
|
||||||
|
|---|---|
|
||||||
|
| 表里的号码布在系统里已有 | 认作**同一个人**;若表里的姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上,会进「冲突」列表等你逐条决定(保留哪一侧),该行本轮不写入 |
|
||||||
|
| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:<br>· 找不到 → 新建人员档案;<br>· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;<br>· 恰好一人、且他**已有号码布** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);<br>· 恰好一人、但他已有号码布**且与表里不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;<br>· 多人 → 分不清是谁,进「冲突」列表等逐条决定 |
|
||||||
|
|
||||||
|
> 「同名的另一个人」多半是号码布写错了。收到这条提醒时,先核对新号码布是不是本人:是别人(真的同名),确认无误即可;是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来,并把多出来的那条新档案删掉。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、三个完整例子
|
## 四、三个完整例子
|
||||||
@@ -157,7 +170,8 @@
|
|||||||
- [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格
|
- [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格
|
||||||
- [ ] `category` 与系统里的组别名一字不差
|
- [ ] `category` 与系统里的组别名一字不差
|
||||||
- [ ] 个人行都有姓名,且都有证件号或号码布
|
- [ ] 个人行都有姓名,且都有证件号或号码布
|
||||||
- [ ] 一份表里没有重复的证件号、没有重复的号码布
|
- [ ] 一份表里**没有重复的号码布**(号码布是唯一标识,重号会报错)
|
||||||
|
- [ ] 证件号可以重复(同一个人报多项很正常),但每个证件号在系统里只属于一个人,别和别人的档案重号
|
||||||
- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空
|
- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空
|
||||||
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
|
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
|
||||||
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
|
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
|
||||||
@@ -179,10 +193,11 @@
|
|||||||
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
|
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
|
||||||
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
|
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
|
||||||
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项唯一标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
|
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项唯一标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
|
||||||
| 第 N 行:证件号 / 号码布 … 在报名表内重复出现 | 同一份表里同一个人出现了两次 | 删掉重复行(同一个人报多个项目时,`event_code` 必须不同) |
|
| 第 N 行:号码布 XX 在报名表内重复出现 | 同一份表里同一个号码布出现了两次 | 删掉重复行(同一个人报多个项目时,`event_code` 必须不同;同一个号码布也不能给两个人) |
|
||||||
|
| 第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z) | 表里给的证件号已经登记在**另一个人**名下。证件号不参与认人,但系统里一个证件号只能属于一个人 | 先核对证件号是不是抄错了:**不是这个人**的,把这一格留空即可(只填号码布也能导入);**是他的**,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传这一步列出的行 |
|
||||||
| 第 N 行:status「xxx」无法识别 | `status` 填了系统不认的词(4 个标准词以外的英文,或第三节没列的中文) | 改成 `registered` / `scratched` / `dq` / `dns`(大小写都行)或第三节列的中文别名,也可以留空(= 正常参赛);改完重新上传 |
|
| 第 N 行:status「xxx」无法识别 | `status` 填了系统不认的词(4 个标准词以外的英文,或第三节没列的中文) | 改成 `registered` / `scratched` / `dq` / `dns`(大小写都行)或第三节列的中文别名,也可以留空(= 正常参赛);改完重新上传 |
|
||||||
| 第 N 行:heat 需要是 1-999 的整数(留空表示未分批) | `heat` 填了非整数、0、负数或超过 999 的值(如 `第1批`、`一组`、`1000`) | 改成 1–999 的整数,或者整格留空(= 未分批);批次只影响录入分组,不参与算分 |
|
| 第 N 行:heat 需要是 1-999 的整数(留空表示未分批) | `heat` 填了非整数、0、负数或超过 999 的值(如 `第1批`、`一组`、`1000`) | 改成 1–999 的整数,或者整格留空(= 未分批);批次只影响录入分组,不参与算分 |
|
||||||
| 提示「冲突」,人员没有写入 | 这个人在系统里已有记录,但姓名 / 性别 / 年龄 / 单位 / 号码布跟表里**对不上** | 在导入结果里逐条选「保留已有数据」或「使用新数据」,选完再提交 |
|
| 提示「冲突」,人员没有写入 | 按号码布(或「姓名 + 单位」唯一命中)的人在系统里已有记录,但姓名 / 性别 / 年龄 / 单位 / 号码布跟表里**对不上** | 在导入结果里逐条选「保留已有数据」或「使用新数据」,选完再提交 |
|
||||||
| 文件传不上去 | 超过 16MB | 拆分上传,或改用 xlsx |
|
| 文件传不上去 | 超过 16MB | 拆分上传,或改用 xlsx |
|
||||||
| 只导入了第一张表 | xlsx 只读第一个工作表 | 把要导入的数据放在第一个工作表里 |
|
| 只导入了第一张表 | xlsx 只读第一个工作表 | 把要导入的数据放在第一个工作表里 |
|
||||||
|
|
||||||
@@ -194,10 +209,11 @@
|
|||||||
- **新增**:这次新建的报名;
|
- **新增**:这次新建的报名;
|
||||||
- **更新**:这个人本来就有这个项目的报名,用表里的信息覆盖了(**不会产生重复报名**);
|
- **更新**:这个人本来就有这个项目的报名,用表里的信息覆盖了(**不会产生重复报名**);
|
||||||
- **跳过**:没能导入的行,会带着**行号**列出来,改好那几行重新上传即可。
|
- **跳过**:没能导入的行,会带着**行号**列出来,改好那几行重新上传即可。
|
||||||
|
- 另有一份**「按同名的另一个人新建了档案(号码布不同)」**清单,单独列出姓名 / 单位 / 原号码布 / 新号码布。它不是错误:这些行**已经导入成功**,只是提醒你核对号码布有没有写错(见第三节的「系统怎么认人」)。
|
||||||
2. **重复上传同一份表是安全的**,不会把名单变成两份。改完再传一遍就行。
|
2. **重复上传同一份表是安全的**,不会把名单变成两份。改完再传一遍就行。
|
||||||
- ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布、**批次**都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态或批次,**重传前请在表里把 `status`、`heat` 写清楚**。
|
- ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布、**批次**都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态或批次,**重传前请在表里把 `status`、`heat` 写清楚**。
|
||||||
3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。
|
3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。
|
||||||
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个号码布(或同一「姓名 + 单位」),系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
||||||
5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。
|
5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。
|
||||||
6. **别一口气连传几十次**:写操作限 **120 次/分钟/用户**,超了会返回「操作过于频繁,请稍后再试」。正常一次导入只算一次;真被限了就等一分钟再传,不要连点。
|
6. **别一口气连传几十次**:写操作限 **120 次/分钟/用户**,超了会返回「操作过于频繁,请稍后再试」。正常一次导入只算一次;真被限了就等一分钟再传,不要连点。
|
||||||
|
|
||||||
@@ -208,7 +224,7 @@
|
|||||||
> 1. 用系统里下载的模板,**第一行不要动**;
|
> 1. 用系统里下载的模板,**第一行不要动**;
|
||||||
> 2. **一个人一行**;一个人报几项就写几行;
|
> 2. **一个人一行**;一个人报几项就写几行;
|
||||||
> 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编;
|
> 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编;
|
||||||
> 4. 每人要有**证件号或号码布**,至少一个;
|
> 4. 每人要有**证件号或号码布**,至少一个;**号码布是系统认人的唯一标识**(优先填、别重号),证件号只是可选档案信息、也不能和别人重号;
|
||||||
> 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开;
|
> 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开;
|
||||||
> 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号;
|
> 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号;
|
||||||
> 7. 表里**不要合并单元格**、不要加标题行、不要插图;
|
> 7. 表里**不要合并单元格**、不要加标题行、不要插图;
|
||||||
|
|||||||
+17
-1
@@ -469,7 +469,7 @@ http://<主机IP>:19540/display
|
|||||||
- `uploads/`:**全部上传资源**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo,以及**上传的字体**(放在 `uploads/fonts/` 下,见使用手册 14.3)。导入时会连文件一起落到新项目目录的 `uploads/`,切换过去后图片直接显示、字体直接可用,不用重新上传;
|
- `uploads/`:**全部上传资源**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo,以及**上传的字体**(放在 `uploads/fonts/` 下,见使用手册 14.3)。导入时会连文件一起落到新项目目录的 `uploads/`,切换过去后图片直接显示、字体直接可用,不用重新上传;
|
||||||
- `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。
|
- `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。
|
||||||
|
|
||||||
导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**全部(单页)**(所有成绩平铺在一张表)、**分项成绩表**(按小项)、**单位积分明细**(按单位)各自可单独导出打印;「积分榜」页三个榜单的卡片头还各有明细导出(**导出总明细** / **导出奖牌明细** / **导出个人明细**,见使用手册 8.3)。
|
导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**全部(单页)**(所有成绩平铺在一张表)、**分项成绩表**(按小项)、**单位积分明细**(按单位)各自可单独导出打印,另有可筛选(小项 / 组别 / 单位 / 批次)的**报名名单**;「积分榜」页三个榜单的卡片头还各有明细导出(**导出总明细** / **导出奖牌明细** / **导出个人明细**,见使用手册 8.3)。基础数据(单位 / 大项 / 小项 / 组别)另有单独的基础数据包可导出 / 导入,见 8.7。
|
||||||
|
|
||||||
导入与切换:
|
导入与切换:
|
||||||
|
|
||||||
@@ -498,6 +498,22 @@ http://<主机IP>:19540/display
|
|||||||
|
|
||||||
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标/单位 logo **与自定义字体**在 `event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),不拷这个目录会丢这些文件,大屏上表现为图片不显示、字体回落成默认字体。
|
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标/单位 logo **与自定义字体**在 `event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),不拷这个目录会丢这些文件,大屏上表现为图片不显示、字体回落成默认字体。
|
||||||
|
|
||||||
|
### 8.7 基础数据包导出 / 导入(单位 / 大项 / 小项 / 组别)
|
||||||
|
|
||||||
|
赛前批量维护基础数据,或换一台机器时把基础数据整体搬过去,用「导出 → 基础数据」页签:
|
||||||
|
|
||||||
|
| 操作 | 位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 导出 | 「导出 → 基础数据 → 导出 xlsx」 | 一个工作簿四张工作表:单位 / 大项 / 小项 / 组别(组别表带所属小项编码) |
|
||||||
|
| 导入 | 「导出 → 基础数据 → 选择 .xlsx → 预览差异 → 确认写入」 | **仅管理员**;只认 `.xlsx`(四张表装不进一个 CSV) |
|
||||||
|
|
||||||
|
- **按编码匹配,存在就更新、不存在就新建;绝不删除**:文件里没有的行不会被删,也不会被停用;
|
||||||
|
- **两步走**:先「预览差异」(新增 / 更新 / 无变化 / 错误 + 逐行变化,这一步不写库),确认后再点「**确认写入**」;**有错的行会跳过,合法的行照常写入**,行级错误带**真实 Excel 行号**(如 `单位 第 3 行:单位编码必填`);
|
||||||
|
- **列不在表里 = 不动该字段**;**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列按「是」,**排序留空保持原值**;
|
||||||
|
- 表头行在第 4 行(系统导出件,上面是标题与副标题)或第 1 行(手工整理的表)都能识别;枚举列要填英文规范值(如 `individual` / `time` / `published`),布尔列填「是 / 否」;
|
||||||
|
- 被「彻底删除」的单位不在导出件里,导入也**不会**把它翻出来(仍是隐藏状态);
|
||||||
|
- **这个包只含基础数据**,不含成绩、报名与规则;整场赛事的搬迁仍用赛事项目包(见 8.5)。赛前导出一份留底,现场改乱了可以导回对照。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 故障排查
|
## 9. 故障排查
|
||||||
|
|||||||
+177
-43
@@ -12,7 +12,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -23,8 +25,33 @@ const (
|
|||||||
CookieSession = "scoreboard_session"
|
CookieSession = "scoreboard_session"
|
||||||
CookieCSRF = "csrf_token"
|
CookieCSRF = "csrf_token"
|
||||||
|
|
||||||
// DefaultSessionTTL 是会话有效期:12h。
|
// DefaultSessionTTL 是登录有效期的默认值:24h。
|
||||||
DefaultSessionTTL = 12 * time.Hour
|
// 原来是 12h:现场前一晚 20:00 登录,次日早上 8:00 整被踢出登录,
|
||||||
|
// 而那正是比赛进行中的时间——这正是本项需求要修的。
|
||||||
|
DefaultSessionTTL = 24 * time.Hour
|
||||||
|
// DefaultSessionHours 与 DefaultSessionTTL 同值,供以「小时」为单位的配置读写共用。
|
||||||
|
DefaultSessionHours = 24
|
||||||
|
// MinSessionHours / MaxSessionHours 是面板可配的登录有效期范围:1 小时 ~ 720 小时(30 天)。
|
||||||
|
MinSessionHours = 1
|
||||||
|
MaxSessionHours = 720
|
||||||
|
// SessionHoursKey 是系统库 settings 里存登录有效期的键(机器级配置,与赛事级 settings 不共用)。
|
||||||
|
SessionHoursKey = "security.session_hours"
|
||||||
|
// SessionHoursSourceKey 记录 SessionHoursKey 是谁写的:面板写过的值一定是操作员的明确选择,
|
||||||
|
// 不能被下面的历史占位值规则改写。
|
||||||
|
SessionHoursSourceKey = "security.session_hours_source"
|
||||||
|
// SessionHoursSourcePanel 是面板写入时登记的来源标记。
|
||||||
|
SessionHoursSourcePanel = "panel"
|
||||||
|
// legacySessionHoursSeed 是 0005 迁移种下的历史占位值。
|
||||||
|
// 在本项改动之前,没有任何代码读过 security.session_hours,所以库里还留着 12
|
||||||
|
// 只可能是「没人配过」;它按「未配置」处理,用新的默认 24,否则新装的库会永远停在 12。
|
||||||
|
legacySessionHoursSeed = "12"
|
||||||
|
// renewThreshold 是滑动续期的写入节流窗口:剩余寿命不足「有效期 - 1 分钟」时才回写 expires_at。
|
||||||
|
// 每个请求都续期会让 sessions 变成热点写表(现场几十个客户端轮询),
|
||||||
|
// 而少顺延这 1 分钟对「会不会掉线」没有任何影响。
|
||||||
|
renewThreshold = time.Minute
|
||||||
|
// touchInterval 是 last_seen_at 的回写节流窗口:没有续期时,活动时间最快每分钟写一次。
|
||||||
|
// 一次页面加载会打若干个请求,逐个回写纯属放大写压力;last_seen_at 只用于排障,分钟级足够。
|
||||||
|
touchInterval = time.Minute
|
||||||
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
|
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
|
||||||
DefaultBcryptCost = 12
|
DefaultBcryptCost = 12
|
||||||
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。
|
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。
|
||||||
@@ -73,7 +100,10 @@ type Session struct {
|
|||||||
|
|
||||||
// Options 是认证服务的可调参数;零值即文档默认。
|
// Options 是认证服务的可调参数;零值即文档默认。
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
// SessionTTL 显式指定登录有效期(测试 / 嵌入用)。优先级最低的那一档默认值:
|
||||||
|
// 只要系统库 settings 里有有效的 security.session_hours(面板写的就是它),就以面板为准;
|
||||||
|
// 这里只在系统库里没有配置时兜底(例如测试用的空库)。
|
||||||
SessionTTL time.Duration
|
SessionTTL time.Duration
|
||||||
BcryptCost int
|
BcryptCost int
|
||||||
LoginPerMinute int
|
LoginPerMinute int
|
||||||
@@ -88,12 +118,14 @@ type Options struct {
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
sessionTTL time.Duration
|
|
||||||
bcryptCost int
|
bcryptCost int
|
||||||
secureCookies bool
|
secureCookies bool
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
loginLimiter *Limiter
|
// ttl 是当前生效的登录有效期;面板可以随时改(SetSessionTTL),
|
||||||
writeLimiter *Limiter
|
// 所以每个请求都会读它,必须用原子量而不是普通字段。
|
||||||
|
ttl atomic.Int64
|
||||||
|
loginLimiter *Limiter
|
||||||
|
writeLimiter *Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 构建认证服务;db 为系统库(data/system.db)。
|
// New 构建认证服务;db 为系统库(data/system.db)。
|
||||||
@@ -102,9 +134,14 @@ func New(db *sql.DB, opts Options) *Service {
|
|||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
ttl := opts.SessionTTL
|
// 优先级:面板(系统库设置)> 启动参数 / 配置文件 > 内置默认。
|
||||||
if ttl <= 0 {
|
// 面板排在最前是为了不出现「改了面板没反应」:只要系统库里有一个有效值,
|
||||||
ttl = DefaultSessionTTL
|
// 它就是这个进程的有效期,别处的默认值一律让位。
|
||||||
|
ttl, source := DefaultSessionTTL, "内置默认"
|
||||||
|
if dbTTL, ok := sessionTTLFromSettings(db); ok {
|
||||||
|
ttl, source = dbTTL, "系统设置"
|
||||||
|
} else if opts.SessionTTL > 0 {
|
||||||
|
ttl, source = opts.SessionTTL, "启动参数"
|
||||||
}
|
}
|
||||||
cost := opts.BcryptCost
|
cost := opts.BcryptCost
|
||||||
if cost <= 0 {
|
if cost <= 0 {
|
||||||
@@ -122,20 +159,86 @@ func New(db *sql.DB, opts Options) *Service {
|
|||||||
if writeLimit == 0 {
|
if writeLimit == 0 {
|
||||||
writeLimit = DefaultWritePerMinute
|
writeLimit = DefaultWritePerMinute
|
||||||
}
|
}
|
||||||
return &Service{
|
logger.Info("登录有效期", "hours", ttl.Hours(), "source", source,
|
||||||
|
"滑动续期", true, "节流窗口", renewThreshold.String())
|
||||||
|
s := &Service{
|
||||||
db: db,
|
db: db,
|
||||||
log: logger,
|
log: logger,
|
||||||
sessionTTL: ttl,
|
|
||||||
bcryptCost: cost,
|
bcryptCost: cost,
|
||||||
secureCookies: opts.Secure,
|
secureCookies: opts.Secure,
|
||||||
now: now,
|
now: now,
|
||||||
loginLimiter: NewLimiter(loginLimit, time.Minute, now),
|
loginLimiter: NewLimiter(loginLimit, time.Minute, now),
|
||||||
writeLimiter: NewLimiter(writeLimit, time.Minute, now),
|
writeLimiter: NewLimiter(writeLimit, time.Minute, now),
|
||||||
}
|
}
|
||||||
|
s.ttl.Store(int64(ttl))
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionTTL 返回会话有效期。
|
// SessionTTL 返回当前生效的登录有效期。
|
||||||
func (s *Service) SessionTTL() time.Duration { return s.sessionTTL }
|
func (s *Service) SessionTTL() time.Duration { return time.Duration(s.ttl.Load()) }
|
||||||
|
|
||||||
|
// SetSessionTTL 更新登录有效期。只对之后创建的会话与之后的续期生效:
|
||||||
|
// 已存在会话的 expires_at 一律不动——改设置不该把正在录入的人踢下线。
|
||||||
|
func (s *Service) SetSessionTTL(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ttl.Store(int64(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSessionHours 解析登录有效期(小时);非法或超出 1~720 一律判为无效。
|
||||||
|
func ParseSessionHours(raw string) (int, bool) {
|
||||||
|
hours, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil || hours < MinSessionHours || hours > MaxSessionHours {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hours, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveSessionHours 算出实际生效的小时数。
|
||||||
|
// 规则:面板写过的值原样生效(含操作员明确选的 12);迁移种下的历史占位值 12
|
||||||
|
// 因为从来没有代码读过,按「未配置」处理,回落到新默认 24;缺键或非法同样是默认值。
|
||||||
|
func EffectiveSessionHours(values map[string]string) int {
|
||||||
|
raw := strings.TrimSpace(values[SessionHoursKey])
|
||||||
|
if hours, ok := ParseSessionHours(raw); ok {
|
||||||
|
if strings.TrimSpace(values[SessionHoursSourceKey]) != "" || raw != legacySessionHoursSeed {
|
||||||
|
return hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DefaultSessionHours
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionTTLFromSettings 从系统库 settings 读登录有效期;没有有效配置(缺键或非法)时 ok=false,
|
||||||
|
// 由调用方回落到启动参数 / 内置默认。数据库不可用不当作 fatal:认证要能带着默认值起来。
|
||||||
|
func sessionTTLFromSettings(db *sql.DB) (time.Duration, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
values := make(map[string]string, 2)
|
||||||
|
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN (?, ?)`,
|
||||||
|
SessionHoursKey, SessionHoursSourceKey)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
slog.Warn("解析登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if _, ok := values[SessionHoursKey]; !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return time.Duration(EffectiveSessionHours(values)) * time.Hour, true
|
||||||
|
}
|
||||||
|
|
||||||
// AllowLogin 按 IP 限流登录。
|
// AllowLogin 按 IP 限流登录。
|
||||||
func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) }
|
func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) }
|
||||||
@@ -301,20 +404,22 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
|
|||||||
hash := hashToken(token)
|
hash := hashToken(token)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
u User
|
u User
|
||||||
sess Session
|
sess Session
|
||||||
expiresAt string
|
expiresAt string
|
||||||
role string
|
lastSeenRaw string
|
||||||
|
role string
|
||||||
)
|
)
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
`SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''),
|
`SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''),
|
||||||
|
COALESCE(s.last_seen_at, ''),
|
||||||
u.id, u.username, u.display_name, u.role, u.active, u.must_change_password,
|
u.id, u.username, u.display_name, u.role, u.active, u.must_change_password,
|
||||||
COALESCE(u.last_login_at, '')
|
COALESCE(u.last_login_at, '')
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`,
|
WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`,
|
||||||
hash, s.now().UTC().Format(time.DateTime)).
|
hash, s.now().UTC().Format(time.DateTime)).
|
||||||
Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent,
|
Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent, &lastSeenRaw,
|
||||||
&u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
|
&u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
@@ -323,6 +428,7 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
|
|||||||
return nil, nil, fmt.Errorf("查询会话: %w", err)
|
return nil, nil, fmt.Errorf("查询会话: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := s.now().UTC()
|
||||||
exp, err := parseDBTime(expiresAt)
|
exp, err := parseDBTime(expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -330,8 +436,26 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
|
|||||||
sess.ExpiresAt = exp
|
sess.ExpiresAt = exp
|
||||||
u.Role = ParseRole(role)
|
u.Role = ParseRole(role)
|
||||||
|
|
||||||
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
|
// 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期,
|
||||||
s.now().UTC().Format(time.DateTime), sess.ID)
|
// 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。
|
||||||
|
//
|
||||||
|
// 写入节流:只有剩余寿命已不足「有效期 - renewThreshold(1 分钟)」时才回写 expires_at。
|
||||||
|
// 否则每点一次按钮、每次轮询都要写一遍 sessions,把单连接 sqlite 变成热点写表;
|
||||||
|
// 少顺延这 1 分钟不会让任何人提前掉线(前一次续期至少还剩一个有效期)。
|
||||||
|
if ttl := s.SessionTTL(); ttl > renewThreshold && exp.Sub(now) < ttl-renewThreshold {
|
||||||
|
renewed := now.Add(ttl)
|
||||||
|
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
|
||||||
|
now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID)
|
||||||
|
sess.ExpiresAt = renewed
|
||||||
|
return &u, &sess, nil
|
||||||
|
}
|
||||||
|
// 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。
|
||||||
|
// 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。
|
||||||
|
lastSeen, err := parseDBTime(lastSeenRaw)
|
||||||
|
if err != nil || now.Sub(lastSeen) >= touchInterval {
|
||||||
|
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
|
||||||
|
now.Format(time.DateTime), sess.ID)
|
||||||
|
}
|
||||||
return &u, &sess, nil
|
return &u, &sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +486,7 @@ func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
expiresAt := s.now().UTC().Add(s.sessionTTL)
|
expiresAt := s.now().UTC().Add(s.SessionTTL())
|
||||||
_, err = s.db.ExecContext(ctx,
|
_, err = s.db.ExecContext(ctx,
|
||||||
`INSERT INTO sessions (id, user_id, token_hash, expires_at, ip, user_agent, last_seen_at)
|
`INSERT INTO sessions (id, user_id, token_hash, expires_at, ip, user_agent, last_seen_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -377,54 +501,64 @@ func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent
|
|||||||
|
|
||||||
// SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。
|
// SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。
|
||||||
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
|
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
|
||||||
|
setSessionCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshSessionCookies 按会话到期时间重发会话与 CSRF Cookie。
|
||||||
|
//
|
||||||
|
// 滑动续期之后必须重发:Cookie 的 Max-Age 是浏览器端的硬期限,
|
||||||
|
// 不跟着续期走的话,浏览器会在「登录时刻 + 有效期」处把 Cookie 丢掉,
|
||||||
|
// 服务端续得再久也留不住人。CSRF 沿用原值(不轮换),避免和正在飞行的写请求打架。
|
||||||
|
// 到期时间已过时不重发——调用方不该给一个已经失效的会话续 Cookie。
|
||||||
|
func (s *Service) RefreshSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string, expiresAt time.Time) {
|
||||||
|
life := expiresAt.Sub(s.now())
|
||||||
|
if life <= 0 || sessionToken == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seconds := int(life.Seconds())
|
||||||
|
setSessionCookie(w, s.secureCookies, sessionToken, seconds)
|
||||||
|
if csrfToken != "" {
|
||||||
|
setCSRFCookie(w, s.secureCookies, csrfToken, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookie(w http.ResponseWriter, secure bool, token string, maxAge int) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: CookieSession,
|
Name: CookieSession,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(s.sessionTTL.Seconds()),
|
MaxAge: maxAge,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: s.secureCookies,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSessionCookie 删除会话 Cookie。
|
// ClearSessionCookie 删除会话 Cookie。
|
||||||
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
|
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
setSessionCookie(w, s.secureCookies, "", -1)
|
||||||
Name: CookieSession,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: s.secureCookies,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。
|
// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。
|
||||||
func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) {
|
func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) {
|
||||||
|
setCSRFCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCSRFCookie(w http.ResponseWriter, secure bool, token string, maxAge int) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: CookieCSRF,
|
Name: CookieCSRF,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(s.sessionTTL.Seconds()),
|
MaxAge: maxAge,
|
||||||
HttpOnly: false,
|
HttpOnly: false,
|
||||||
Secure: s.secureCookies,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCSRFCookie 删除 CSRF Cookie。
|
// ClearCSRFCookie 删除 CSRF Cookie。
|
||||||
func (s *Service) ClearCSRFCookie(w http.ResponseWriter) {
|
func (s *Service) ClearCSRFCookie(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
setCSRFCookie(w, s.secureCookies, "", -1)
|
||||||
Name: CookieCSRF,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: false,
|
|
||||||
Secure: s.secureCookies,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCSRFToken 生成新的 CSRF Token。
|
// NewCSRFToken 生成新的 CSRF Token。
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 登录有效期的解析与新默认值:迁移种下的历史占位值 12 从未被任何代码读过,
|
||||||
|
// 按「未配置」处理,否则新装的库会永远停在 12(现场前一晚登录、次日比赛当天掉线)。
|
||||||
|
func TestEffectiveSessionHours(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
values map[string]string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"历史占位值 12(无面板标记)按未配置处理", map[string]string{SessionHoursKey: "12"}, 24},
|
||||||
|
{"面板明确选过的 12 原样生效", map[string]string{SessionHoursKey: "12", SessionHoursSourceKey: "panel"}, 12},
|
||||||
|
{"面板写的 72 生效", map[string]string{SessionHoursKey: "72", SessionHoursSourceKey: "panel"}, 72},
|
||||||
|
{"未配置回默认 24", map[string]string{}, 24},
|
||||||
|
{"上界 720", map[string]string{SessionHoursKey: "720"}, 720},
|
||||||
|
{"下界 1", map[string]string{SessionHoursKey: "1"}, 1},
|
||||||
|
{"0 非法回默认", map[string]string{SessionHoursKey: "0"}, 24},
|
||||||
|
{"721 非法回默认", map[string]string{SessionHoursKey: "721"}, 24},
|
||||||
|
{"非数字非法回默认", map[string]string{SessionHoursKey: "十二"}, 24},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := EffectiveSessionHours(tc.values); got != tc.want {
|
||||||
|
t.Fatalf("EffectiveSessionHours(%v) = %d,期望 %d", tc.values, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSessionHours(t *testing.T) {
|
||||||
|
for raw, want := range map[string]int{"1": 1, "24": 24, "720": 720, " 48 ": 48} {
|
||||||
|
got, ok := ParseSessionHours(raw)
|
||||||
|
if !ok || got != want {
|
||||||
|
t.Fatalf("ParseSessionHours(%q) = %d,%v,期望 %d,true", raw, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range []string{"", "0", "-1", "721", "24.5", "abc"} {
|
||||||
|
if _, ok := ParseSessionHours(raw); ok {
|
||||||
|
t.Fatalf("ParseSessionHours(%q) 应判为非法", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统库里的登录有效期要真正被认成会话寿命;显式传入的 Options.SessionTTL 优先(测试 / 嵌入用)。
|
||||||
|
func TestSessionTTLFromSystemSettings(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
seed map[string]string
|
||||||
|
optsTTL time.Duration
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"历史占位值 12 视为未配置 → 24h", map[string]string{SessionHoursKey: "12"}, 0, 24 * time.Hour},
|
||||||
|
{"面板写的 1 → 1h", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 0, time.Hour},
|
||||||
|
{"面板里的值优先于启动参数", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 90 * time.Minute, time.Hour},
|
||||||
|
{"系统库没有配置时用启动参数兜底", nil, 90 * time.Minute, 90 * time.Minute},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, tc.seed)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: tc.optsTTL})
|
||||||
|
if got := svc.SessionTTL(); got != tc.want {
|
||||||
|
t.Fatalf("SessionTTL() = %s,期望 %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滑动续期:剩余寿命少于「有效期 - 节流窗口」时才回写,且回写后到期时间顺延到 now+有效期。
|
||||||
|
func TestLookupSlidesExpiryWithThrottle(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, nil)
|
||||||
|
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
|
||||||
|
Now: func() time.Time { return now }})
|
||||||
|
userID := insertUser(t, db, "admin")
|
||||||
|
token := insertSession(t, db, userID, now.Add(24*time.Hour))
|
||||||
|
|
||||||
|
// 刚创建的会话:剩余 = 有效期,不写 expires_at(首次请求不该产生额外写)。
|
||||||
|
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("查询会话: %v", err)
|
||||||
|
} else if !sess.ExpiresAt.Equal(now.Add(24 * time.Hour)) {
|
||||||
|
t.Fatalf("新鲜会话的到期时间不该变,得到 %s", sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
if got := dbExpiry(t, db); got != now.Add(24*time.Hour).Format(time.DateTime) {
|
||||||
|
t.Fatalf("新鲜会话不应回写 expires_at,库里是 %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩余 23h58m(不足 24h-1m):下一次请求应把到期时间顺延到 now+24h。
|
||||||
|
before := now.Add(24*time.Hour - 2*time.Minute)
|
||||||
|
setExpiry(t, db, before)
|
||||||
|
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("查询会话: %v", err)
|
||||||
|
} else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||||
|
t.Fatalf("应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
if got, want := dbExpiry(t, db), now.Add(24*time.Hour).Format(time.DateTime); got != want {
|
||||||
|
t.Fatalf("库里到期时间应为 %s,得到 %s", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节流:紧接着的多次请求既不写 expires_at,也不写 last_seen_at——
|
||||||
|
// 现场一次页面加载会打七八个请求,逐个写库纯属放大写压力。
|
||||||
|
renewed := dbExpiry(t, db)
|
||||||
|
seen := dbLastSeen(t, db)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
now = now.Add(time.Second)
|
||||||
|
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("第 %d 次查询会话: %v", i, err)
|
||||||
|
}
|
||||||
|
if got := dbExpiry(t, db); got != renewed {
|
||||||
|
t.Fatalf("节流窗口内不该回写 expires_at:%s → %s", renewed, got)
|
||||||
|
}
|
||||||
|
if got := dbLastSeen(t, db); got != seen {
|
||||||
|
t.Fatalf("节流窗口内不该回写 last_seen_at:%s → %s", seen, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 越过节流窗口(1 分钟)后再次顺延:活跃会话大约每分钟才写一次库。
|
||||||
|
now = now.Add(5 * time.Minute)
|
||||||
|
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("查询会话: %v", err)
|
||||||
|
} else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||||
|
t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want {
|
||||||
|
t.Fatalf("续期时活动时间应一起回写为 %s,得到 %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活动时间的节流不会影响续期:会话剩余寿命超过当前有效期(例如有效期被调短)时,
|
||||||
|
// 只回写 last_seen_at,expires_at 保持原值。
|
||||||
|
func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, nil)
|
||||||
|
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
|
||||||
|
Now: func() time.Time { return now }})
|
||||||
|
userID := insertUser(t, db, "admin")
|
||||||
|
token := insertSession(t, db, userID, now.Add(20*time.Hour))
|
||||||
|
setLastSeen(t, db, now.Add(-time.Hour))
|
||||||
|
stored := dbExpiry(t, db)
|
||||||
|
|
||||||
|
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("查询会话: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want {
|
||||||
|
t.Fatalf("活动时间应回写为 %s,得到 %s", want, got)
|
||||||
|
}
|
||||||
|
if got := dbExpiry(t, db); got != stored {
|
||||||
|
t.Fatalf("已登录会话的到期时间不该被改设置的续期逻辑改写:%s → %s", stored, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。
|
||||||
|
func TestLookupRenewsAfterThrottleWindow(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, nil)
|
||||||
|
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
|
||||||
|
Now: func() time.Time { return now }})
|
||||||
|
userID := insertUser(t, db, "admin")
|
||||||
|
token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute))
|
||||||
|
|
||||||
|
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("查询会话: %v", err)
|
||||||
|
}
|
||||||
|
now = now.Add(2 * time.Minute)
|
||||||
|
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("查询会话: %v", err)
|
||||||
|
} else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||||
|
t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 到期校验不能被续期改坏:已过期的会话仍然查不到(调用方据此回 401)。
|
||||||
|
func TestLookupRejectsExpiredSession(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, nil)
|
||||||
|
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour,
|
||||||
|
Now: func() time.Time { return now }})
|
||||||
|
userID := insertUser(t, db, "admin")
|
||||||
|
token := insertSession(t, db, userID, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound {
|
||||||
|
t.Fatalf("过期会话应返回 ErrSessionNotFound,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改有效期不动已存在的会话:只换进程内的 TTL,库里的 expires_at 原样保留。
|
||||||
|
func TestSetSessionTTLKeepsExistingExpiry(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, nil)
|
||||||
|
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
|
||||||
|
Now: func() time.Time { return now }})
|
||||||
|
userID := insertUser(t, db, "admin")
|
||||||
|
token := insertSession(t, db, userID, now.Add(time.Hour))
|
||||||
|
stored := dbExpiry(t, db)
|
||||||
|
|
||||||
|
svc.SetSessionTTL(72 * time.Hour)
|
||||||
|
|
||||||
|
if got := svc.SessionTTL(); got != 72*time.Hour {
|
||||||
|
t.Fatalf("SessionTTL() = %s,期望 72h", got)
|
||||||
|
}
|
||||||
|
if got := dbExpiry(t, db); got != stored {
|
||||||
|
t.Fatalf("改有效期不该动已存在会话的 expires_at:%s → %s", stored, got)
|
||||||
|
}
|
||||||
|
// 旧会话下一次被使用时按新的、更长的有效期顺延(是延长,不是踢下线)。
|
||||||
|
_, sess, err := svc.Lookup(context.Background(), token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("旧会话不该被踢下线: %v", err)
|
||||||
|
}
|
||||||
|
if want := now.Add(72 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||||
|
t.Fatalf("旧会话应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新会话按改后的有效期创建。
|
||||||
|
func TestCreateSessionUsesCurrentTTL(t *testing.T) {
|
||||||
|
db := newSettingsDB(t, nil)
|
||||||
|
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour,
|
||||||
|
Now: func() time.Time { return now }})
|
||||||
|
userID := insertUser(t, db, "admin")
|
||||||
|
|
||||||
|
svc.SetSessionTTL(720 * time.Hour)
|
||||||
|
if _, sess, err := svc.createSession(context.Background(), userID, "127.0.0.1", "test"); err != nil {
|
||||||
|
t.Fatalf("创建会话: %v", err)
|
||||||
|
} else if want := now.Add(720 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||||
|
t.Fatalf("新会话应在 %s 到期,得到 %s", want, sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 测试脚手架 ----------
|
||||||
|
|
||||||
|
func quietLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSchema = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'recorder',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_login_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);`
|
||||||
|
|
||||||
|
func newSettingsDB(t *testing.T, seed map[string]string) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "system.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开测试库: %v", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if _, err := db.Exec(testSchema); err != nil {
|
||||||
|
t.Fatalf("建表: %v", err)
|
||||||
|
}
|
||||||
|
for k, v := range seed {
|
||||||
|
if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)`, k, v); err != nil {
|
||||||
|
t.Fatalf("写入设置 %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertUser(t *testing.T, db *sql.DB, username string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(`INSERT INTO users (username, role) VALUES (?, 'admin')`, username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建用户: %v", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSession(t *testing.T, db *sql.DB, userID int64, expiresAt time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
token := "token-" + expiresAt.Format(time.RFC3339Nano)
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
"sid", userID, hashToken(token), expiresAt.Format(time.DateTime), expiresAt.Format(time.DateTime)); err != nil {
|
||||||
|
t.Fatalf("建会话: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExpiry(t *testing.T, db *sql.DB, expiresAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Exec(`UPDATE sessions SET expires_at = ?`, expiresAt.Format(time.DateTime)); err != nil {
|
||||||
|
t.Fatalf("改到期时间: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLastSeen(t *testing.T, db *sql.DB, at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Exec(`UPDATE sessions SET last_seen_at = ?`, at.Format(time.DateTime)); err != nil {
|
||||||
|
t.Fatalf("改活动时间: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbExpiry(t *testing.T, db *sql.DB) string {
|
||||||
|
t.Helper()
|
||||||
|
var v string
|
||||||
|
if err := db.QueryRow(`SELECT expires_at FROM sessions`).Scan(&v); err != nil {
|
||||||
|
t.Fatalf("读 expires_at: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbLastSeen(t *testing.T, db *sql.DB) string {
|
||||||
|
t.Helper()
|
||||||
|
var v string
|
||||||
|
if err := db.QueryRow(`SELECT last_seen_at FROM sessions`).Scan(&v); err != nil {
|
||||||
|
t.Fatalf("读 last_seen_at: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ const (
|
|||||||
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
||||||
SettingKeyLiveEvents = "display.live_events"
|
SettingKeyLiveEvents = "display.live_events"
|
||||||
SettingKeyLiveSource = "display.live_source"
|
SettingKeyLiveSource = "display.live_source"
|
||||||
|
// SettingKeyLiveOnPublish 决定发布成绩后是否立即结算:写积分流水并刷新各单位总分与奖牌榜。
|
||||||
|
// 关闭后按「分批延迟结算」处理:每次发布只落成绩,等该 (小项, 组别) 所有批次都录完才结算一次。
|
||||||
|
// 缺键 / 空值一律等于开启,保证历史赛事的现场行为不变。
|
||||||
|
SettingKeyLiveOnPublish = "standings.live_on_publish"
|
||||||
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
||||||
MaxLiveEvents = 2
|
MaxLiveEvents = 2
|
||||||
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
||||||
@@ -116,6 +120,8 @@ type EventSettingsStandings struct {
|
|||||||
RankingMode string `json:"rankingMode"`
|
RankingMode string `json:"rankingMode"`
|
||||||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||||||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||||||
|
// LiveOnPublish 为 false 表示分批延迟结算:发布只落成绩,等该 (小项, 组别) 全部录完才更新榜单。
|
||||||
|
LiveOnPublish bool `json:"liveOnPublish"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
|
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
|
||||||
@@ -132,10 +138,27 @@ func (s EventSettings) WithoutAdminFields() EventSettings {
|
|||||||
RankingMode: s.Standings.RankingMode,
|
RankingMode: s.Standings.RankingMode,
|
||||||
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
|
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
|
||||||
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
|
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
|
||||||
|
// 结算方式不是系统机密,而是录入员必须知道的现场行为:
|
||||||
|
// 关掉后「发布这批成绩」不会立刻改榜单,录入页要据此提示「等全组录完」。
|
||||||
|
LiveOnPublish: s.Standings.LiveOnPublish,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionSettings 是 GET/PUT /api/system/session 的线上结构:登录有效期(小时)。
|
||||||
|
//
|
||||||
|
// 它是**系统级**配置,存在系统库 system.db 的 settings 里,与赛事级的 EventSettings
|
||||||
|
// (event.db,随赛事包走)不共用——会话寿命是这台机器的属性,不该因为切换赛事而变。
|
||||||
|
type SessionSettings struct {
|
||||||
|
SessionHours int `json:"sessionHours"`
|
||||||
|
// DefaultHours / MinHours / MaxHours 由服务端下发,前端不再各写一份范围提示。
|
||||||
|
DefaultHours int `json:"defaultSessionHours"`
|
||||||
|
MinHours int `json:"minHours"`
|
||||||
|
MaxHours int `json:"maxHours"`
|
||||||
|
// ExpiresAt 是当前会话的到期时间(RFC3339 UTC);未登录为 null。
|
||||||
|
ExpiresAt *string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
|
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
|
||||||
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
|
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
|
||||||
type RuleSwitches struct {
|
type RuleSwitches struct {
|
||||||
@@ -188,6 +211,8 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
|||||||
out.Standings.ShowIndividualBoard = boolDefault(
|
out.Standings.ShowIndividualBoard = boolDefault(
|
||||||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
||||||
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
|
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
|
||||||
|
// 缺键 = 实时结算:老赛事升级后行为必须与升级前完全一致。
|
||||||
|
out.Standings.LiveOnPublish = boolDefault(values[SettingKeyLiveOnPublish], true)
|
||||||
|
|
||||||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 基础数据包的四个工作表名。导入按这几个中文名认表:缺哪张就整张跳过,
|
||||||
|
// 一张都没有才报错——「没写的表」代表不改,绝不代表删除。
|
||||||
|
const (
|
||||||
|
BaseDataSheetTeams = "单位"
|
||||||
|
BaseDataSheetSports = "大项"
|
||||||
|
BaseDataSheetEvents = "小项"
|
||||||
|
BaseDataSheetCategories = "组别"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BaseDataHeader 是一列表头与字段的对应关系。
|
||||||
|
// Header 是导出写进单元格的中文表头,Field 是导入按它取值的规范字段名。
|
||||||
|
// 导出与导入共用这一份定义:表头文案只有一处,往返(导出→原样导入)天然对得上。
|
||||||
|
type BaseDataHeader struct {
|
||||||
|
Header string
|
||||||
|
Field string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseDataSheetSpec 是一张基础数据表的导入契约:表名 + 表头集合。
|
||||||
|
type BaseDataSheetSpec struct {
|
||||||
|
Sheet string
|
||||||
|
Headers []BaseDataHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseDataColumn 在表头之外带上版式(列宽 / 对齐),仅供导出用。
|
||||||
|
type baseDataColumn struct {
|
||||||
|
BaseDataHeader
|
||||||
|
width float64
|
||||||
|
kind cellKind
|
||||||
|
}
|
||||||
|
|
||||||
|
type baseDataSheet struct {
|
||||||
|
sheet string
|
||||||
|
columns []baseDataColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseDataSheets 是基础数据包的全部内容定义,顺序即导入顺序:
|
||||||
|
// 单位 → 大项 → 小项 → 组别,后者依赖前者的编码(组别依赖小项,小项依赖大项)。
|
||||||
|
var baseDataSheets = []baseDataSheet{
|
||||||
|
{
|
||||||
|
sheet: BaseDataSheetTeams,
|
||||||
|
columns: []baseDataColumn{
|
||||||
|
{BaseDataHeader{"单位编码", "code", true}, 14, kindCenter},
|
||||||
|
{BaseDataHeader{"单位名称", "name", true}, 24, kindText},
|
||||||
|
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
|
||||||
|
{BaseDataHeader{"颜色", "color", false}, 12, kindCenter},
|
||||||
|
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||||
|
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheet: BaseDataSheetSports,
|
||||||
|
columns: []baseDataColumn{
|
||||||
|
{BaseDataHeader{"大项编码", "code", true}, 14, kindCenter},
|
||||||
|
{BaseDataHeader{"大项名称", "name", true}, 24, kindText},
|
||||||
|
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
|
||||||
|
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||||
|
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheet: BaseDataSheetEvents,
|
||||||
|
columns: []baseDataColumn{
|
||||||
|
{BaseDataHeader{"大项编码", "sportCode", true}, 14, kindCenter},
|
||||||
|
{BaseDataHeader{"小项编码", "code", true}, 14, kindCenter},
|
||||||
|
{BaseDataHeader{"小项名称", "name", true}, 22, kindText},
|
||||||
|
{BaseDataHeader{"类型", "eventType", false}, 14, kindCenter},
|
||||||
|
{BaseDataHeader{"结果模式", "resultMode", false}, 10, kindCenter},
|
||||||
|
{BaseDataHeader{"结果类型", "resultType", false}, 10, kindCenter},
|
||||||
|
{BaseDataHeader{"比较", "compare", false}, 8, kindCenter},
|
||||||
|
{BaseDataHeader{"计分", "scored", false}, 8, kindCenter},
|
||||||
|
{BaseDataHeader{"计破纪录", "recordEligible", false}, 10, kindCenter},
|
||||||
|
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
|
||||||
|
{BaseDataHeader{"状态", "status", false}, 10, kindCenter},
|
||||||
|
{BaseDataHeader{"平均口径", "avgMode", false}, 10, kindCenter},
|
||||||
|
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||||
|
{BaseDataHeader{"场地", "venue", false}, 16, kindText},
|
||||||
|
{BaseDataHeader{"备注", "remark", false}, 20, kindText},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheet: BaseDataSheetCategories,
|
||||||
|
columns: []baseDataColumn{
|
||||||
|
// 组别必须带所属小项编码,否则导回来时对不上是哪个体项目的组别。
|
||||||
|
{BaseDataHeader{"小项编码", "eventCode", true}, 14, kindCenter},
|
||||||
|
{BaseDataHeader{"组别名", "name", true}, 16, kindText},
|
||||||
|
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
|
||||||
|
{BaseDataHeader{"性别", "gender", false}, 8, kindCenter},
|
||||||
|
{BaseDataHeader{"年龄下限", "ageMin", false}, 10, kindInt},
|
||||||
|
{BaseDataHeader{"年龄上限", "ageMax", false}, 10, kindInt},
|
||||||
|
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseDataSpecs 返回基础数据包的导入契约(表名与表头),顺序即导入顺序。
|
||||||
|
// 调用方(httpapi)拿它做表头识别与行级校验,不再自己写一份中文表头。
|
||||||
|
func BaseDataSpecs() []BaseDataSheetSpec {
|
||||||
|
out := make([]BaseDataSheetSpec, 0, len(baseDataSheets))
|
||||||
|
for _, s := range baseDataSheets {
|
||||||
|
spec := BaseDataSheetSpec{Sheet: s.sheet, Headers: make([]BaseDataHeader, 0, len(s.columns))}
|
||||||
|
for _, c := range s.columns {
|
||||||
|
spec.Headers = append(spec.Headers, c.BaseDataHeader)
|
||||||
|
}
|
||||||
|
out = append(out, spec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseDataRecord 是一行待写出的基础数据:字段名 → 单元格值。
|
||||||
|
type BaseDataRecord map[string]any
|
||||||
|
|
||||||
|
// BaseDataWorkbook 是基础数据包的内容,一张表一份记录列表。
|
||||||
|
type BaseDataWorkbook struct {
|
||||||
|
Meta Meta
|
||||||
|
Teams []BaseDataRecord
|
||||||
|
Sports []BaseDataRecord
|
||||||
|
Events []BaseDataRecord
|
||||||
|
Categories []BaseDataRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// records 按工作表名取记录,顺序与 baseDataSheets 一致。
|
||||||
|
func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord {
|
||||||
|
switch sheet {
|
||||||
|
case BaseDataSheetTeams:
|
||||||
|
return wb.Teams
|
||||||
|
case BaseDataSheetSports:
|
||||||
|
return wb.Sports
|
||||||
|
case BaseDataSheetEvents:
|
||||||
|
return wb.Events
|
||||||
|
case BaseDataSheetCategories:
|
||||||
|
return wb.Categories
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportBaseDataXLSX 导出基础数据包到 dst。
|
||||||
|
func ExportBaseDataXLSX(db *sql.DB, dst string, opts Options) error {
|
||||||
|
wb, err := LoadBaseDataWorkbook(db, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := baseDataFile(wb)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveWorkbook(f, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。
|
||||||
|
func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error {
|
||||||
|
f, err := baseDataFile(wb)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Write(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseDataFile(wb BaseDataWorkbook) (*excelize.File, error) {
|
||||||
|
return buildWorkbook(BaseDataSheetTeams, func(f *excelize.File, st *styleSet) error {
|
||||||
|
meta := wb.Meta.normalized()
|
||||||
|
namer := &sheetNamer{}
|
||||||
|
for _, s := range baseDataSheets {
|
||||||
|
name := namer.add(s.sheet)
|
||||||
|
if err := addSheet(f, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cols := make([]column, 0, len(s.columns))
|
||||||
|
for _, c := range s.columns {
|
||||||
|
cols = append(cols, column{c.Header, c.width, c.kind})
|
||||||
|
}
|
||||||
|
rows := baseDataRows(s, wb.records(s.sheet))
|
||||||
|
if err := writeSheet(f, st, name, meta, sheetTitle(meta, s.sheet), cols, rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseDataRows 按列定义把记录摊平成行;记录里缺的字段写空,不写 "0" / "false" 这类噪声。
|
||||||
|
func baseDataRows(s baseDataSheet, records []BaseDataRecord) [][]any {
|
||||||
|
out := make([][]any, 0, len(records))
|
||||||
|
for _, rec := range records {
|
||||||
|
row := make([]any, 0, len(s.columns))
|
||||||
|
for _, c := range s.columns {
|
||||||
|
v, ok := rec[c.Field]
|
||||||
|
if !ok || v == nil {
|
||||||
|
v = ""
|
||||||
|
}
|
||||||
|
row = append(row, v)
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolCell 把布尔值写成「是 / 否」:中文表头配中文取值,现场改表时不用查英文枚举。
|
||||||
|
func boolCell(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBaseDataWorkbook 读取全部基础数据(单位 / 大项 / 小项 / 组别)。
|
||||||
|
//
|
||||||
|
// 单位排除「彻底删除」(deleted_at 非空)的行:它们对界面不可见,也不该被重新印进导出件;
|
||||||
|
// 停用(active=0)的单位照常导出,启用列写「否」,导入回来才不会把它误改回启用。
|
||||||
|
func LoadBaseDataWorkbook(db *sql.DB, opts Options) (BaseDataWorkbook, error) {
|
||||||
|
if db == nil {
|
||||||
|
return BaseDataWorkbook{}, ErrNoDatabase
|
||||||
|
}
|
||||||
|
meta, _, err := resolveMeta(db, opts.Meta)
|
||||||
|
if err != nil {
|
||||||
|
return BaseDataWorkbook{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, err := loadBaseDataTeams(db)
|
||||||
|
if err != nil {
|
||||||
|
return BaseDataWorkbook{}, err
|
||||||
|
}
|
||||||
|
sports, err := loadBaseDataSports(db)
|
||||||
|
if err != nil {
|
||||||
|
return BaseDataWorkbook{}, err
|
||||||
|
}
|
||||||
|
events, err := loadBaseDataEvents(db)
|
||||||
|
if err != nil {
|
||||||
|
return BaseDataWorkbook{}, err
|
||||||
|
}
|
||||||
|
categories, err := loadBaseDataCategories(db)
|
||||||
|
if err != nil {
|
||||||
|
return BaseDataWorkbook{}, err
|
||||||
|
}
|
||||||
|
return BaseDataWorkbook{Meta: meta, Teams: teams, Sports: sports, Events: events, Categories: categories}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBaseDataTeams(db *sql.DB) ([]BaseDataRecord, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT code, name, COALESCE(short_name, ''), COALESCE(color, ''), sort_order, active
|
||||||
|
FROM teams WHERE deleted_at IS NULL ORDER BY sort_order, code`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 读取单位: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []BaseDataRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
code, name, shortName, color string
|
||||||
|
sortOrder, active int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&code, &name, &shortName, &color, &sortOrder, &active); err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 扫描单位: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, BaseDataRecord{
|
||||||
|
"code": code, "name": name, "shortName": shortName, "color": color,
|
||||||
|
"sortOrder": sortOrder, "active": boolCell(active != 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBaseDataSports(db *sql.DB) ([]BaseDataRecord, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT code, name, COALESCE(short_name, ''), sort_order, active
|
||||||
|
FROM sports ORDER BY sort_order, code`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 读取大项: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []BaseDataRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
code, name, shortName string
|
||||||
|
sortOrder, active int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&code, &name, &shortName, &sortOrder, &active); err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 扫描大项: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, BaseDataRecord{
|
||||||
|
"code": code, "name": name, "shortName": shortName,
|
||||||
|
"sortOrder": sortOrder, "active": boolCell(active != 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBaseDataEvents(db *sql.DB) ([]BaseDataRecord, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type,
|
||||||
|
e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode,
|
||||||
|
e.sort_order, COALESCE(e.venue, ''), COALESCE(e.remark, '')
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN sports s ON s.id = e.sport_id
|
||||||
|
ORDER BY COALESCE(s.sort_order, 0), e.sort_order, e.code`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 读取小项: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []BaseDataRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
sportCode, code, name string
|
||||||
|
eventType, resultMode, resultType, cmpare string
|
||||||
|
status, avgMode, venue, remark string
|
||||||
|
scored, recordEligible, sortOrder int
|
||||||
|
multiplier float64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&sportCode, &code, &name, &eventType, &resultMode, &resultType,
|
||||||
|
&cmpare, &scored, &recordEligible, &multiplier, &status, &avgMode,
|
||||||
|
&sortOrder, &venue, &remark); err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 扫描小项: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, BaseDataRecord{
|
||||||
|
"sportCode": sportCode, "code": code, "name": name,
|
||||||
|
"eventType": eventType, "resultMode": resultMode, "resultType": resultType,
|
||||||
|
"compare": cmpare, "scored": boolCell(scored != 0), "recordEligible": boolCell(recordEligible != 0),
|
||||||
|
"multiplier": multiplier, "status": status, "avgMode": avgMode,
|
||||||
|
"sortOrder": sortOrder, "venue": venue, "remark": remark,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBaseDataCategories(db *sql.DB) ([]BaseDataRecord, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT COALESCE(e.code, ''), c.name, c.multiplier, COALESCE(c.gender, ''),
|
||||||
|
c.age_min, c.age_max, c.sort_order
|
||||||
|
FROM event_categories c
|
||||||
|
LEFT JOIN events e ON e.id = c.event_id
|
||||||
|
ORDER BY COALESCE(e.sort_order, 0), e.code, c.sort_order, c.id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 读取组别: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []BaseDataRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
eventCode, name, gender string
|
||||||
|
multiplier float64
|
||||||
|
ageMin, ageMax sql.NullInt64
|
||||||
|
sortOrder int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&eventCode, &name, &multiplier, &gender, &ageMin, &ageMax, &sortOrder); err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 扫描组别: %w", err)
|
||||||
|
}
|
||||||
|
rec := BaseDataRecord{
|
||||||
|
"eventCode": eventCode, "name": name, "multiplier": multiplier,
|
||||||
|
"gender": gender, "sortOrder": sortOrder,
|
||||||
|
}
|
||||||
|
rec["ageMin"], rec["ageMax"] = "", ""
|
||||||
|
if ageMin.Valid {
|
||||||
|
rec["ageMin"] = int(ageMin.Int64)
|
||||||
|
}
|
||||||
|
if ageMax.Valid {
|
||||||
|
rec["ageMax"] = int(ageMax.Int64)
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 导入侧:读取工作簿 ----------
|
||||||
|
|
||||||
|
// BaseDataRow 是基础数据表里的一行数据。
|
||||||
|
type BaseDataRow struct {
|
||||||
|
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
|
||||||
|
Line int
|
||||||
|
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
|
||||||
|
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
|
||||||
|
Cells map[string]string
|
||||||
|
Has map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseDataTable 是从工作簿里解析出的一张表。
|
||||||
|
type BaseDataTable struct {
|
||||||
|
Sheet string
|
||||||
|
HeaderLine int
|
||||||
|
Rows []BaseDataRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxHeaderScanRows 是表头检测的扫描范围。
|
||||||
|
// 本系统的导出件在表头上方有标题与副标题(表头在第 4 行),手工整理的表通常表头在第 1 行,
|
||||||
|
// 再往上加两三行说明也有可能;扫前 20 行足够,又不会被中间某行数据误判成表头。
|
||||||
|
const maxHeaderScanRows = 20
|
||||||
|
|
||||||
|
// ReadBaseDataPackage 解析基础数据包工作簿,按工作表返回表头位置与数据行。
|
||||||
|
// 只认 baseDataSheets 里的四个中文表名,别的工作表一律忽略;一张都没找到时返回错误。
|
||||||
|
func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) {
|
||||||
|
f, err := excelize.OpenReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 打开基础数据包: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
sheets := f.GetSheetList()
|
||||||
|
present := make(map[string]bool, len(sheets))
|
||||||
|
for _, name := range sheets {
|
||||||
|
present[strings.TrimSpace(name)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := make([]BaseDataTable, 0, len(baseDataSheets))
|
||||||
|
for _, spec := range BaseDataSpecs() {
|
||||||
|
if !present[spec.Sheet] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows, err := f.GetRows(spec.Sheet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err)
|
||||||
|
}
|
||||||
|
table, err := parseBaseDataSheet(spec, rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tables = append(tables, table)
|
||||||
|
}
|
||||||
|
if len(tables) == 0 {
|
||||||
|
return nil, fmt.Errorf("export: 基础数据包缺少可识别的工作表(应为 %s)", baseDataSheetNames())
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseDataSheetNames() string {
|
||||||
|
names := make([]string, 0, len(baseDataSheets))
|
||||||
|
for _, s := range baseDataSheets {
|
||||||
|
names = append(names, s.sheet)
|
||||||
|
}
|
||||||
|
return strings.Join(names, " / ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBaseDataSheet 在表头行定位成功后,把其后的数据行逐行摊成「字段 → 原文」。
|
||||||
|
// 整行为空的行(用户在表尾留的空行)直接跳过,不产生「必填」误报。
|
||||||
|
func parseBaseDataSheet(spec BaseDataSheetSpec, rows [][]string) (BaseDataTable, error) {
|
||||||
|
headerLine, index := findBaseDataHeader(spec, rows)
|
||||||
|
if headerLine < 0 {
|
||||||
|
return BaseDataTable{}, fmt.Errorf("export: 工作表 %s 找不到表头行:应有一行同时包含 %s(本系统导出的包把表头放在标题行下方)",
|
||||||
|
spec.Sheet, strings.Join(requiredHeaders(spec), "、"))
|
||||||
|
}
|
||||||
|
|
||||||
|
table := BaseDataTable{Sheet: spec.Sheet, HeaderLine: headerLine + 1}
|
||||||
|
for i := headerLine + 1; i < len(rows); i++ {
|
||||||
|
row := rows[i]
|
||||||
|
cells := make(map[string]string, len(index))
|
||||||
|
has := make(map[string]bool, len(index))
|
||||||
|
blank := true
|
||||||
|
for _, h := range spec.Headers {
|
||||||
|
col, ok := index[h.Field]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
has[h.Field] = true
|
||||||
|
v := ""
|
||||||
|
if col < len(row) {
|
||||||
|
v = strings.TrimSpace(row[col])
|
||||||
|
}
|
||||||
|
cells[h.Field] = v
|
||||||
|
if v != "" {
|
||||||
|
blank = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
table.Rows = append(table.Rows, BaseDataRow{Line: i + 1, Cells: cells, Has: has})
|
||||||
|
}
|
||||||
|
return table, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBaseDataHeader 找出表头行:返回行下标(0 起)与「字段 → 列下标」。
|
||||||
|
// 判据是该行必须同时出现全部必填表头,避免把标题行或数据行认成表头。
|
||||||
|
func findBaseDataHeader(spec BaseDataSheetSpec, rows [][]string) (int, map[string]int) {
|
||||||
|
limit := len(rows)
|
||||||
|
if limit > maxHeaderScanRows {
|
||||||
|
limit = maxHeaderScanRows
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
byHeader := make(map[string]int, len(rows[i]))
|
||||||
|
for j, cell := range rows[i] {
|
||||||
|
text := strings.TrimSpace(cell)
|
||||||
|
if _, dup := byHeader[text]; !dup {
|
||||||
|
byHeader[text] = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index := make(map[string]int, len(spec.Headers))
|
||||||
|
missing := false
|
||||||
|
for _, h := range spec.Headers {
|
||||||
|
col, ok := byHeader[h.Header]
|
||||||
|
if !ok {
|
||||||
|
if h.Required {
|
||||||
|
missing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index[h.Field] = col
|
||||||
|
}
|
||||||
|
if !missing {
|
||||||
|
return i, index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredHeaders(spec BaseDataSheetSpec) []string {
|
||||||
|
out := make([]string, 0, len(spec.Headers))
|
||||||
|
for _, h := range spec.Headers {
|
||||||
|
if h.Required {
|
||||||
|
out = append(out, h.Header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 导入侧用的小工具 ----------
|
||||||
|
|
||||||
|
// ParseBaseDataBool 解析「是 / 否」型单元格:空值取 fallback,认不出的写法返回 false。
|
||||||
|
func ParseBaseDataBool(raw string, fallback bool) (bool, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "":
|
||||||
|
return fallback, true
|
||||||
|
case "是", "1", "true", "yes", "y", "启用", "on":
|
||||||
|
return true, true
|
||||||
|
case "否", "0", "false", "no", "n", "停用", "off":
|
||||||
|
return false, true
|
||||||
|
default:
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBaseDataFloat 解析数值列;空值取 fallback。
|
||||||
|
func ParseBaseDataFloat(raw string, fallback float64) (float64, bool) {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return fallback, true
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(text, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBaseDataInt 解析整数列;空值返回 nil。返回的第二个值表示是否合法。
|
||||||
|
func ParseBaseDataInt(raw string) (*int, bool) {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &v, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。
|
||||||
|
// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项,
|
||||||
|
// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。
|
||||||
|
func seedBaseData(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
stmts := []string{
|
||||||
|
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
|
||||||
|
VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`,
|
||||||
|
`INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`,
|
||||||
|
`INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`,
|
||||||
|
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`,
|
||||||
|
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||||
|
record_eligible, scored, multiplier, status, sort_order, venue, remark)
|
||||||
|
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`,
|
||||||
|
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`,
|
||||||
|
`INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
|
||||||
|
VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||||||
|
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
t.Fatalf("造数据 %q: %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。
|
||||||
|
func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Cells[field] == value {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("找不到 %s = %q 的行", field, value)
|
||||||
|
return BaseDataRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、
|
||||||
|
// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。
|
||||||
|
func TestBaseDataPackageRoundTrip(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedBaseData(t, db)
|
||||||
|
|
||||||
|
wb, err := LoadBaseDataWorkbook(db, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取基础数据: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteBaseDataXLSX(&buf, wb); err != nil {
|
||||||
|
t.Fatalf("写出基础数据包: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读回基础数据包: %v", err)
|
||||||
|
}
|
||||||
|
if len(tables) != 4 {
|
||||||
|
t.Fatalf("工作表数 = %d,期望 4", len(tables))
|
||||||
|
}
|
||||||
|
wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories}
|
||||||
|
wantRows := []int{2, 2, 3, 4}
|
||||||
|
for i, table := range tables {
|
||||||
|
if table.Sheet != wantSheets[i] {
|
||||||
|
t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i])
|
||||||
|
}
|
||||||
|
if table.HeaderLine != rowHeader {
|
||||||
|
t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader)
|
||||||
|
}
|
||||||
|
if len(table.Rows) != wantRows[i] {
|
||||||
|
t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i])
|
||||||
|
}
|
||||||
|
// 表头必须与导出契约逐字一致,否则导入端认不出列。
|
||||||
|
spec := BaseDataSpecs()[i]
|
||||||
|
for _, h := range spec.Headers {
|
||||||
|
found := false
|
||||||
|
for _, row := range table.Rows {
|
||||||
|
if row.Has[h.Field] {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found && len(table.Rows) > 0 {
|
||||||
|
t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teams := tables[0]
|
||||||
|
if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" {
|
||||||
|
t.Errorf("单位编码 = %q,期望 T1", got)
|
||||||
|
}
|
||||||
|
t1 := findRecord(t, teams.Rows, "code", "T1")
|
||||||
|
if got := t1.Cells["active"]; got != "是" {
|
||||||
|
t.Errorf("T1 启用 = %q,期望 是", got)
|
||||||
|
}
|
||||||
|
if got := t1.Cells["color"]; got != "#3482FF" {
|
||||||
|
t.Errorf("T1 颜色 = %q", got)
|
||||||
|
}
|
||||||
|
if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" {
|
||||||
|
t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := tables[2]
|
||||||
|
e1 := findRecord(t, events.Rows, "code", "E1")
|
||||||
|
if got := e1.Cells["sportCode"]; got != "SP1" {
|
||||||
|
t.Errorf("小项大项编码 = %q,期望 SP1", got)
|
||||||
|
}
|
||||||
|
if got := e1.Cells["multiplier"]; got != "2" {
|
||||||
|
t.Errorf("E1 权重 = %q,期望 2", got)
|
||||||
|
}
|
||||||
|
if got := e1.Cells["venue"]; got != "主体育场" {
|
||||||
|
t.Errorf("E1 场地 = %q", got)
|
||||||
|
}
|
||||||
|
if got := e1.Cells["resultType"]; got != "time" {
|
||||||
|
t.Errorf("E1 结果类型 = %q,期望 time", got)
|
||||||
|
}
|
||||||
|
if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" {
|
||||||
|
t.Errorf("E3 大项编码 = %q,期望 SP2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
cats := tables[3]
|
||||||
|
findRecord(t, cats.Rows, "eventCode", "E1")
|
||||||
|
c1 := findRecord(t, cats.Rows, "name", "男子青年组")
|
||||||
|
if got := c1.Cells["eventCode"]; got != "E1" {
|
||||||
|
t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got)
|
||||||
|
}
|
||||||
|
if got := c1.Cells["ageMin"]; got != "18" {
|
||||||
|
t.Errorf("年龄下限 = %q,期望 18", got)
|
||||||
|
}
|
||||||
|
if got := c1.Cells["gender"]; got != "男" {
|
||||||
|
t.Errorf("性别 = %q,期望 男", got)
|
||||||
|
}
|
||||||
|
if got := c1.Cells["multiplier"]; got != "1.5" {
|
||||||
|
t.Errorf("组别权重 = %q,期望 1.5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。
|
||||||
|
raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开基础数据包: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = raw.Close() }()
|
||||||
|
headerRow, err := raw.GetRows(BaseDataSheetCategories)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取组别表: %v", err)
|
||||||
|
}
|
||||||
|
for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} {
|
||||||
|
if got := cell(headerRow, rowHeader-1, i); got != h {
|
||||||
|
t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。
|
||||||
|
func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||||||
|
t.Fatalf("重命名工作表: %v", err)
|
||||||
|
}
|
||||||
|
rows := [][]string{
|
||||||
|
{"单位编码", "单位名称", "启用"},
|
||||||
|
{"T9", "第九分公司", "是"},
|
||||||
|
{"T8", "第八分公司", "否"},
|
||||||
|
}
|
||||||
|
for r, row := range rows {
|
||||||
|
for c, v := range row {
|
||||||
|
name, _ := excelize.CoordinatesToCellName(c+1, r+1)
|
||||||
|
if err := f.SetCellValue("单位", name, v); err != nil {
|
||||||
|
t.Fatalf("写单元格: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := f.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("写出测试工作簿: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读回: %v", err)
|
||||||
|
}
|
||||||
|
if len(tables) != 1 || tables[0].Sheet != "单位" {
|
||||||
|
t.Fatalf("只应认出单位表,得到 %+v", tables)
|
||||||
|
}
|
||||||
|
if tables[0].HeaderLine != 1 {
|
||||||
|
t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine)
|
||||||
|
}
|
||||||
|
if len(tables[0].Rows) != 2 {
|
||||||
|
t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows))
|
||||||
|
}
|
||||||
|
if got := tables[0].Rows[0].Line; got != 2 {
|
||||||
|
t.Errorf("首行行号 = %d,期望 2", got)
|
||||||
|
}
|
||||||
|
if got := tables[0].Rows[1].Line; got != 3 {
|
||||||
|
t.Errorf("次行行号 = %d,期望 3", got)
|
||||||
|
}
|
||||||
|
// 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。
|
||||||
|
if tables[0].Rows[0].Has["shortName"] {
|
||||||
|
t.Errorf("文件里没有简称列,Has[shortName] 不该为 true")
|
||||||
|
}
|
||||||
|
if !tables[0].Rows[0].Has["active"] {
|
||||||
|
t.Errorf("文件里有启用列,Has[active] 应为 true")
|
||||||
|
}
|
||||||
|
if tables[0].Rows[0].Cells["name"] != "第九分公司" {
|
||||||
|
t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadBaseDataPackageErrors(t *testing.T) {
|
||||||
|
// 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||||||
|
t.Fatalf("重命名工作表: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil {
|
||||||
|
t.Fatalf("写单元格: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := f.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("写出测试工作簿: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil {
|
||||||
|
t.Fatal("找不到表头应报错")
|
||||||
|
} else if !strings.Contains(err.Error(), "单位编码") {
|
||||||
|
t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一张可识别的表都没有。
|
||||||
|
f2 := excelize.NewFile()
|
||||||
|
defer func() { _ = f2.Close() }()
|
||||||
|
if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil {
|
||||||
|
t.Fatalf("写单元格: %v", err)
|
||||||
|
}
|
||||||
|
var buf2 bytes.Buffer
|
||||||
|
if err := f2.Write(&buf2); err != nil {
|
||||||
|
t.Fatalf("写出测试工作簿: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil {
|
||||||
|
t.Fatal("没有任何可识别工作表应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBaseDataHelpers(t *testing.T) {
|
||||||
|
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
|
||||||
|
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
|
||||||
|
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} {
|
||||||
|
if v, ok := ParseBaseDataBool(raw, true); !ok || v {
|
||||||
|
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := ParseBaseDataBool("", true); !ok || !v {
|
||||||
|
t.Errorf("空值应取 fallback,得到 %v,%v", v, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseBaseDataBool("也许", true); ok {
|
||||||
|
t.Error("认不出的写法应报不合法")
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 {
|
||||||
|
t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseBaseDataFloat("两倍", 1); ok {
|
||||||
|
t.Error("非数字权重应报不合法")
|
||||||
|
}
|
||||||
|
if v, ok := ParseBaseDataInt(""); !ok || v != nil {
|
||||||
|
t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseBaseDataInt("三"); ok {
|
||||||
|
t.Error("非整数应报不合法")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entriesSheet 是「报名名单」唯一那张表的表名。
|
||||||
|
const entriesSheet = "报名名单"
|
||||||
|
|
||||||
|
// EntriesFilter 是报名名单的筛选条件;某一项为零值即该维度不筛选(全部)。
|
||||||
|
// heat 的合法值从 1 起,所以 0 可以安全地表示「不按批次筛选」。
|
||||||
|
type EntriesFilter struct {
|
||||||
|
EventID int64
|
||||||
|
CategoryID int64
|
||||||
|
TeamID int64
|
||||||
|
Heat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntryExportRow 是报名名单的一行。
|
||||||
|
// 批次(heat)属于报名数据,是用户点名要看到的列:未分批时留空,也要占着一列。
|
||||||
|
type EntryExportRow struct {
|
||||||
|
EventCode string
|
||||||
|
EventName string
|
||||||
|
Category string
|
||||||
|
TeamCode string
|
||||||
|
TeamName string
|
||||||
|
Name string
|
||||||
|
IDCard string
|
||||||
|
Bib string
|
||||||
|
Heat string
|
||||||
|
Gender string
|
||||||
|
Age any
|
||||||
|
Status string
|
||||||
|
Remark string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntriesWorkbook 是「报名名单」的内容:一张平铺表。
|
||||||
|
type EntriesWorkbook struct {
|
||||||
|
Meta Meta
|
||||||
|
Rows []EntryExportRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// entriesColumns 的列序与用户点名要的口径一致:批次紧跟在号码布之后。
|
||||||
|
// 号码布用 kindText:它是带前导零的字符串("007"),任何数字格式都会把它吃掉。
|
||||||
|
var entriesColumns = []column{
|
||||||
|
{"小项编码", 12, kindCenter},
|
||||||
|
{"小项名", 18, kindText},
|
||||||
|
{"组别", 14, kindText},
|
||||||
|
{"单位编码", 12, kindCenter},
|
||||||
|
{"单位名", 22, kindText},
|
||||||
|
{"姓名", 16, kindText},
|
||||||
|
{"证件号", 22, kindCenter},
|
||||||
|
{"号码布", 10, kindText},
|
||||||
|
{"批次", 8, kindCenter},
|
||||||
|
{"性别", 8, kindCenter},
|
||||||
|
{"年龄", 8, kindInt},
|
||||||
|
{"状态", 12, kindCenter},
|
||||||
|
{"备注", 20, kindText},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportEntriesXLSX 导出报名名单到 dst。
|
||||||
|
func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error {
|
||||||
|
wb, err := LoadEntriesWorkbook(db, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := entriesFile(wb)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveWorkbook(f, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEntriesXLSX 把报名名单写成 xlsx 流,供 HTTP 直接响应下载。
|
||||||
|
func WriteEntriesXLSX(w io.Writer, wb EntriesWorkbook) error {
|
||||||
|
f, err := entriesFile(wb)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Write(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entriesFile(wb EntriesWorkbook) (*excelize.File, error) {
|
||||||
|
return buildWorkbook(entriesSheet, func(f *excelize.File, st *styleSet) error {
|
||||||
|
meta := wb.Meta.normalized()
|
||||||
|
name := sanitizeSheetName(entriesSheet)
|
||||||
|
if err := addSheet(f, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeSheet(f, st, name, meta, sheetTitle(meta, entriesSheet), entriesColumns, entriesDataRows(wb.Rows))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func entriesDataRows(rows []EntryExportRow) [][]any {
|
||||||
|
out := make([][]any, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, []any{
|
||||||
|
r.EventCode, r.EventName, r.Category, r.TeamCode, r.TeamName,
|
||||||
|
r.Name, r.IDCard, r.Bib, r.Heat, r.Gender, r.Age, r.Status, r.Remark,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEntriesWorkbook 读取报名名单;筛选条件为空表示导出全部。
|
||||||
|
//
|
||||||
|
// 读的是报名表本身(entries),不是成绩表:批次、号码布、状态都是报名侧的数据。
|
||||||
|
// 姓名取运动员姓名,团体行(athlete_id 为空)回落到成员名单,与分项成绩表同口径。
|
||||||
|
func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (EntriesWorkbook, error) {
|
||||||
|
if db == nil {
|
||||||
|
return EntriesWorkbook{}, ErrNoDatabase
|
||||||
|
}
|
||||||
|
meta, _, err := resolveMeta(db, opts.Meta)
|
||||||
|
if err != nil {
|
||||||
|
return EntriesWorkbook{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
|
||||||
|
COALESCE(t.code, ''), COALESCE(t.name, ''),
|
||||||
|
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, '')
|
||||||
|
FROM entries en
|
||||||
|
JOIN events ev ON ev.id = en.event_id
|
||||||
|
JOIN teams t ON t.id = en.team_id
|
||||||
|
LEFT JOIN event_categories c ON c.id = en.category_id
|
||||||
|
LEFT JOIN athletes a ON a.id = en.athlete_id`
|
||||||
|
var (
|
||||||
|
conds []string
|
||||||
|
args []any
|
||||||
|
)
|
||||||
|
for _, f := range []struct {
|
||||||
|
column string
|
||||||
|
value int64
|
||||||
|
}{
|
||||||
|
{"en.event_id", filter.EventID},
|
||||||
|
{"en.category_id", filter.CategoryID},
|
||||||
|
{"en.team_id", filter.TeamID},
|
||||||
|
{"en.heat", filter.Heat},
|
||||||
|
} {
|
||||||
|
if f.value != 0 {
|
||||||
|
conds = append(conds, f.column+" = ?")
|
||||||
|
args = append(args, f.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(conds) > 0 {
|
||||||
|
query += " WHERE " + strings.Join(conds, " AND ")
|
||||||
|
}
|
||||||
|
// 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。
|
||||||
|
// 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。
|
||||||
|
query += `
|
||||||
|
ORDER BY ev.sort_order, ev.code, c.sort_order, c.id,
|
||||||
|
en.heat IS NOT NULL, en.heat, t.sort_order, t.code, en.id`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return EntriesWorkbook{}, fmt.Errorf("export: 读取报名名单: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []EntryExportRow
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
row EntryExportRow
|
||||||
|
memberJSON string
|
||||||
|
heat sql.NullInt64
|
||||||
|
age sql.NullInt64
|
||||||
|
athleteName string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
|
||||||
|
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
|
||||||
|
&row.Gender, &age, &row.Status, &row.Remark); err != nil {
|
||||||
|
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
|
||||||
|
}
|
||||||
|
row.Name = participantName(athleteName, memberJSON)
|
||||||
|
if heat.Valid {
|
||||||
|
row.Heat = strconv.FormatInt(heat.Int64, 10)
|
||||||
|
}
|
||||||
|
if age.Valid {
|
||||||
|
row.Age = int(age.Int64)
|
||||||
|
} else {
|
||||||
|
row.Age = ""
|
||||||
|
}
|
||||||
|
row.Status = entryExportStatusLabel(row.Status)
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return EntriesWorkbook{}, fmt.Errorf("export: 遍历报名名单: %w", err)
|
||||||
|
}
|
||||||
|
return EntriesWorkbook{Meta: meta, Rows: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryExportStatusLabel 与录入页报名状态徽标同口径(registered 正常参赛 / scratched 已退赛 /
|
||||||
|
// dq 取消资格 / dns 未出场),导出件上的字与现场在界面上看到的一致。
|
||||||
|
func entryExportStatusLabel(status string) string {
|
||||||
|
switch domain.EntryStatus(status) {
|
||||||
|
case domain.EntryStatusRegistered, "":
|
||||||
|
return "正常参赛"
|
||||||
|
case domain.EntryStatusScratched:
|
||||||
|
return "已退赛"
|
||||||
|
case domain.EntryStatusDQ:
|
||||||
|
return "取消资格"
|
||||||
|
case domain.EntryStatusDNS:
|
||||||
|
return "未出场"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedEntries 造一份覆盖筛选维度的报名名单:两个单位、两个小项、两个组别,
|
||||||
|
// 含批次(有无各一)、中文姓名、带前导零的号码布,以及一条团体报名(无运动员)。
|
||||||
|
func seedEntries(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
stmts := []string{
|
||||||
|
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一分公司', '一公司', 1, 1)`,
|
||||||
|
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T2', '第二分公司', '二公司', 2, 1)`,
|
||||||
|
`INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('张伟', '男', 1, '110101199001011234', 35)`,
|
||||||
|
`INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('李强', '男', 2, '110101199202022345', 33)`,
|
||||||
|
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`,
|
||||||
|
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 1)`,
|
||||||
|
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E1', '100米跑', 'ongoing', 1)`,
|
||||||
|
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E2', '4×100米接力', 'ongoing', 2)`,
|
||||||
|
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (2, 'E3', '100米自由泳', 'pending', 3)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
|
||||||
|
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||||||
|
// 批次 2、号码布带前导零。
|
||||||
|
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, heat, status, remark)
|
||||||
|
VALUES (1, 1, 1, 1, '007', 2, 'registered', '卫冕冠军')`,
|
||||||
|
// 未分批,号码布 0012。
|
||||||
|
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
|
||||||
|
VALUES (1, 1, 2, 2, '0012', 'scratched')`,
|
||||||
|
// 团体报名:没有运动员,姓名回落成员名单。
|
||||||
|
`INSERT INTO entries(event_id, category_id, team_id, member_names, heat, status)
|
||||||
|
VALUES (2, 3, 1, '["王磊","赵雷"]', 1, 'registered')`,
|
||||||
|
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
t.Fatalf("造数据 %q: %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportEntriesXLSXColumnsAndRows(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEntries(t, db)
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
|
||||||
|
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
|
||||||
|
t.Fatalf("导出报名名单: %v", err)
|
||||||
|
}
|
||||||
|
f, err := excelize.OpenFile(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("重新打开 xlsx: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
wantSheet := []string{"报名名单"}
|
||||||
|
if got := f.GetSheetList(); !equalStrings(got, wantSheet) {
|
||||||
|
t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheet)
|
||||||
|
}
|
||||||
|
rows, err := f.GetRows("报名名单")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取报名名单: %v", err)
|
||||||
|
}
|
||||||
|
// 4 行版式 + 3 条报名。
|
||||||
|
if len(rows) != 7 {
|
||||||
|
t.Fatalf("行数 = %d,期望 7(4 行版式 + 3 条报名)", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名", "证件号",
|
||||||
|
"号码布", "批次", "性别", "年龄", "状态", "备注"}
|
||||||
|
for i, header := range want {
|
||||||
|
if got := cell(rows, 3, i); got != header {
|
||||||
|
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:未分批的排最前(heat IS NOT NULL 升序 = false < true)。
|
||||||
|
if got := cell(rows, 4, 5); got != "李强" {
|
||||||
|
t.Fatalf("第一行姓名 = %q,期望 李强(未分批排最前)", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 4, 7); got != "0012" {
|
||||||
|
t.Errorf("未分批行号码布 = %q,期望 0012(前导零不能丢)", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 4, 8); got != "" {
|
||||||
|
t.Errorf("未分批行批次 = %q,期望留空", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 4, 11); got != "已退赛" {
|
||||||
|
t.Errorf("scratched 状态 = %q,期望 已退赛", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 4, 2); got != "男子" {
|
||||||
|
t.Errorf("组别 = %q,期望 男子", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 4, 10); got != "33" {
|
||||||
|
t.Errorf("年龄 = %q,期望 33", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := cell(rows, 5, 5); got != "张伟" {
|
||||||
|
t.Fatalf("第二行姓名 = %q,期望 张伟", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 5, 7); got != "007" {
|
||||||
|
t.Errorf("号码布 = %q,期望 007", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 5, 8); got != "2" {
|
||||||
|
t.Errorf("批次 = %q,期望 2", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 5, 11); got != "正常参赛" {
|
||||||
|
t.Errorf("registered 状态 = %q,期望 正常参赛", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 5, 12); got != "卫冕冠军" {
|
||||||
|
t.Errorf("备注 = %q,期望 卫冕冠军", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 团体行:姓名取成员名单,批次 1。
|
||||||
|
if got := cell(rows, 6, 5); got != "王磊、赵雷" {
|
||||||
|
t.Errorf("团体行姓名 = %q,期望 王磊、赵雷", got)
|
||||||
|
}
|
||||||
|
if got := cell(rows, 6, 1); got != "4×100米接力" {
|
||||||
|
t.Errorf("团体行小项名 = %q,期望 4×100米接力", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 号码布必须是文本单元格:写成数字会把 007 变成 7。
|
||||||
|
if typ, err := f.GetCellType("报名名单", "H5"); err != nil || typ != excelize.CellTypeSharedString {
|
||||||
|
t.Errorf("号码布单元格类型 = %v (err=%v),期望共享字符串(保住前导零)", typ, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportEntriesXLSXFilters(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEntries(t, db)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filter EntriesFilter
|
||||||
|
want int
|
||||||
|
first string
|
||||||
|
}{
|
||||||
|
{"全部", EntriesFilter{}, 3, "李强"},
|
||||||
|
{"按批次", EntriesFilter{Heat: 2}, 1, "张伟"},
|
||||||
|
{"按单位", EntriesFilter{TeamID: 1}, 2, "张伟"},
|
||||||
|
{"按小项", EntriesFilter{EventID: 1}, 2, "李强"},
|
||||||
|
{"按组别", EntriesFilter{CategoryID: 3}, 1, "王磊、赵雷"},
|
||||||
|
{"组合筛选", EntriesFilter{EventID: 1, TeamID: 1, Heat: 2}, 1, "张伟"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
wb, err := LoadEntriesWorkbook(db, tc.filter, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取报名名单: %v", err)
|
||||||
|
}
|
||||||
|
if len(wb.Rows) != tc.want {
|
||||||
|
t.Fatalf("行数 = %d,期望 %d", len(wb.Rows), tc.want)
|
||||||
|
}
|
||||||
|
if got := wb.Rows[0].Name; got != tc.first {
|
||||||
|
t.Errorf("首行姓名 = %q,期望 %q", got, tc.first)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的批次号不是错误,只是没有数据。
|
||||||
|
wb, err := LoadEntriesWorkbook(db, EntriesFilter{Heat: 99}, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取空结果: %v", err)
|
||||||
|
}
|
||||||
|
if len(wb.Rows) != 0 {
|
||||||
|
t.Errorf("批次 99 应为空,得到 %d 行", len(wb.Rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEntriesXLSXStream(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEntries(t, db)
|
||||||
|
|
||||||
|
wb, err := LoadEntriesWorkbook(db, EntriesFilter{}, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取报名名单: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEntriesXLSX(&buf, wb); err != nil {
|
||||||
|
t.Fatalf("流式导出: %v", err)
|
||||||
|
}
|
||||||
|
f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开流式导出的 xlsx: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
rows, err := f.GetRows("报名名单")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取报名名单: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 7 {
|
||||||
|
t.Fatalf("行数 = %d,期望 7", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ type EventResultRow struct {
|
|||||||
Points float64
|
Points float64
|
||||||
Status string
|
Status string
|
||||||
Remark string
|
Remark string
|
||||||
|
// Unpublished 表示这行有草稿成绩但还没发布:成绩值不写出来,
|
||||||
|
// 名次与积分对未发布成绩没有意义,一并换成占位符,见 eventRows。
|
||||||
|
Unpublished bool
|
||||||
|
// NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。
|
||||||
|
// 与 Unpublished 互斥——有成绩行才会有发布状态。
|
||||||
|
NoResult bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
|
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
|
||||||
@@ -117,12 +123,36 @@ func eventSheetTitle(meta Meta, sh EventSheet) string {
|
|||||||
return sheetTitle(meta, base)
|
return sheetTitle(meta, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventCellBlank 是「这一格还没有数值」的占位符。留着空格子会让整行读起来像缺数据,
|
||||||
|
// 占位符才读得出「这是一条完整记录,只是还没发布 / 还没录」。
|
||||||
|
const eventCellBlank = "—"
|
||||||
|
|
||||||
|
// eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。
|
||||||
|
const eventCellUnpublished = "分数未录入"
|
||||||
|
|
||||||
|
// eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。
|
||||||
|
//
|
||||||
|
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值,
|
||||||
|
// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。
|
||||||
func eventRows(rows []EventResultRow) [][]any {
|
func eventRows(rows []EventResultRow) [][]any {
|
||||||
out := make([][]any, 0, len(rows))
|
out := make([][]any, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
var (
|
||||||
|
rank any = r.Rank
|
||||||
|
result any = displayResult(r.ResultText, r.Unit)
|
||||||
|
points any = r.Points
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case r.NoResult:
|
||||||
|
// 连成绩行都没有:三格都没值,状态列才是「未录入」。
|
||||||
|
rank, result, points = eventCellBlank, eventCellBlank, eventCellBlank
|
||||||
|
case r.Unpublished:
|
||||||
|
// 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。
|
||||||
|
rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank
|
||||||
|
}
|
||||||
out = append(out, []any{
|
out = append(out, []any{
|
||||||
r.Rank, r.Bib, r.TeamName, r.Participant,
|
rank, r.Bib, r.TeamName, r.Participant,
|
||||||
displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark,
|
result, points, r.Status, r.Remark,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ func (m Meta) normalized() Meta {
|
|||||||
// Options 是导出选项;零值即「正式成绩」口径。
|
// Options 是导出选项;零值即「正式成绩」口径。
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Meta Meta
|
Meta Meta
|
||||||
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。
|
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出;
|
||||||
|
// 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。
|
||||||
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
|
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
|
||||||
IncludeDrafts bool
|
IncludeDrafts bool
|
||||||
}
|
}
|
||||||
@@ -441,6 +442,8 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
|||||||
|
|
||||||
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
|
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
|
||||||
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
|
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
|
||||||
|
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
|
||||||
|
// 连成绩行都没有的报名项同样占位(NoResult)。
|
||||||
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
|
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
||||||
@@ -459,9 +462,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
|||||||
query += ` AND en.category_id = ?`
|
query += ` AND en.category_id = ?`
|
||||||
args = append(args, categoryID)
|
args = append(args, categoryID)
|
||||||
}
|
}
|
||||||
if !includeDrafts {
|
// 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。
|
||||||
query += ` AND (r.state IS NULL OR r.state = 'published')`
|
// 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行,
|
||||||
}
|
// 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。
|
||||||
query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id`
|
query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id`
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
rows, err := db.Query(query, args...)
|
||||||
@@ -489,6 +492,11 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
|||||||
row.Rank = rankLabel(rank, isTie != 0)
|
row.Rank = rankLabel(rank, isTie != 0)
|
||||||
row.Status = statusLabel(resultStat, resultState, entryStat)
|
row.Status = statusLabel(resultStat, resultState, entryStat)
|
||||||
row.Remark = joinRemark(entryRemark, resultNote)
|
row.Remark = joinRemark(entryRemark, resultNote)
|
||||||
|
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
|
||||||
|
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
|
||||||
|
row.NoResult = !resultState.Valid
|
||||||
|
row.Unpublished = !includeDrafts && resultState.Valid &&
|
||||||
|
domain.ResultState(resultState.String) == domain.ResultStateDraft
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package export
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||||
@@ -293,3 +294,127 @@ func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedDraftAndBlank 在 seedEvent 之上补两类「还没发布」的报名:
|
||||||
|
// 王五有一条草稿成绩(录了没发布),赵六一条成绩都没有。种子里的两条是已发布成绩。
|
||||||
|
func seedDraftAndBlank(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
stmts := []string{
|
||||||
|
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
|
||||||
|
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
|
||||||
|
// rank 留空:草稿还没参与排名,也让行序稳定落在已发布成绩之后。
|
||||||
|
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||||
|
VALUES (1, 3, NULL, '12.10', 12.10, 's', 'draft')`,
|
||||||
|
`INSERT INTO athletes(name, gender, team_id) VALUES ('赵六', '男', 2)`,
|
||||||
|
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 4, '104')`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
t.Fatalf("造数据 %q: %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 草稿行不能从表上消失,但要按「未发布」呈现:成绩列写「分数未录入」,名次与积分填「—」;
|
||||||
|
// 连成绩行都没有的行三格一律占位;已发布的格子一个都不许变。
|
||||||
|
func TestEventRowsShowUnpublishedDraftWithoutDroppingIt(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEvent(t, db)
|
||||||
|
|
||||||
|
// 先取一份只有已发布成绩的导出,等下用它证明已发布行没被新行挤动。
|
||||||
|
before, err := LoadEventWorkbook(db, 1, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("导出已发布成绩: %v", err)
|
||||||
|
}
|
||||||
|
if len(before.Sheets) != 1 {
|
||||||
|
t.Fatalf("组别数 = %d,期望 1", len(before.Sheets))
|
||||||
|
}
|
||||||
|
publishedRows := eventRows(before.Sheets[0].Rows)
|
||||||
|
|
||||||
|
seedDraftAndBlank(t, db)
|
||||||
|
after, err := LoadEventWorkbook(db, 1, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("导出含草稿的成绩: %v", err)
|
||||||
|
}
|
||||||
|
rows := eventRows(after.Sheets[0].Rows)
|
||||||
|
if len(rows) != 4 {
|
||||||
|
t.Fatalf("行数 = %d,期望 4(已发布 2 + 草稿 1 + 没成绩 1): %v", len(rows), rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已发布的两行:每个格子与「没有那两条未发布报名时」完全一致。
|
||||||
|
for i, want := range publishedRows {
|
||||||
|
if !equalCells(rows[i], want) {
|
||||||
|
t.Errorf("第 %d 行被改动了:得到 %v,期望 %v", i+1, rows[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 再钉一遍字面值:这两行是现场打印件上的样子,不能因为新增占位逻辑而漂移。
|
||||||
|
wantPublished := [][]any{
|
||||||
|
{"1", "101", "第一分公司", "张三", "11.58 秒", 10.0, "有效", ""},
|
||||||
|
{"2", "102", "第二分公司", "李四", "11.90 秒", 8.0, "有效", ""},
|
||||||
|
}
|
||||||
|
for i, want := range wantPublished {
|
||||||
|
if !equalCells(rows[i], want) {
|
||||||
|
t.Errorf("已发布第 %d 行 = %v,期望 %v", i+1, rows[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 草稿行(王五):占一行,成绩列是「分数未录入」,名次与积分是「—」。
|
||||||
|
if got := rows[2]; !equalCells(got, []any{
|
||||||
|
"—", "103", "第一分公司", "王五", "分数未录入", "—", "草稿", "",
|
||||||
|
}) {
|
||||||
|
t.Errorf("草稿行 = %v,期望名次 / 积分占位、成绩写「分数未录入」", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完全没成绩的行(赵六):三个成绩格子都不留空白也不写 0,状态仍是未录入。
|
||||||
|
if got := rows[3]; !equalCells(got, []any{
|
||||||
|
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
|
||||||
|
}) {
|
||||||
|
t.Errorf("没成绩的行 = %v,期望名次 / 成绩 / 积分都是「—」、状态未录入", got)
|
||||||
|
}
|
||||||
|
// 逐列再钉一次:这三格的列序是 名次(0) / 成绩(4) / 积分(5),改列序时这里会先炸。
|
||||||
|
for _, col := range []int{0, 4, 5} {
|
||||||
|
if got := rows[3][col]; got != "—" {
|
||||||
|
t.Errorf("没成绩的行第 %d 列 = %v,期望「—」", col, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// includeDrafts 打开时(草稿也导出)草稿行的真实值照常写出;
|
||||||
|
// 连成绩行都没有的行仍然三格占位——它本来就没有值可写。
|
||||||
|
func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEvent(t, db)
|
||||||
|
seedDraftAndBlank(t, db)
|
||||||
|
|
||||||
|
wb, err := LoadEventWorkbook(db, 1, Options{IncludeDrafts: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("带草稿导出: %v", err)
|
||||||
|
}
|
||||||
|
rows := eventRows(wb.Sheets[0].Rows)
|
||||||
|
if len(rows) != 4 {
|
||||||
|
t.Fatalf("行数 = %d,期望 4", len(rows))
|
||||||
|
}
|
||||||
|
if got := rows[2]; !equalCells(got, []any{
|
||||||
|
"", "103", "第一分公司", "王五", "12.10 秒", 0.0, "草稿", "",
|
||||||
|
}) {
|
||||||
|
t.Errorf("带草稿导出时草稿行 = %v,期望写出草稿成绩值", got)
|
||||||
|
}
|
||||||
|
if got := rows[3]; !equalCells(got, []any{
|
||||||
|
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
|
||||||
|
}) {
|
||||||
|
t.Errorf("带草稿导出时没成绩的行 = %v,期望三格仍是「—」", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// equalCells 逐格比较两行;float64 与 string 都按值比。
|
||||||
|
func equalCells(got, want []any) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// batchData 解一次批量动作的响应体。
|
||||||
|
type batchItemView struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchResultView struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
Requested int `json:"requested"`
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Items []batchItemView `json:"items"`
|
||||||
|
Purge *struct {
|
||||||
|
DeletedEntries int `json:"deletedEntries"`
|
||||||
|
DeletedResults int `json:"deletedResults"`
|
||||||
|
DeletedLedger int `json:"deletedLedger"`
|
||||||
|
DeletedBreaks int `json:"deletedBreaks"`
|
||||||
|
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||||||
|
} `json:"purge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func doBatch(t *testing.T, env *testEnv, client *http.Client, body map[string]any) batchResultView {
|
||||||
|
t.Helper()
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", body, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("批量动作应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
return decodeData[batchResultView](t, env2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditCount 数某个 action 的审计条数;审计在响应之后落库,给一点重试窗口。
|
||||||
|
func auditCount(t *testing.T, env *testEnv, action string) int {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action); n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次请求停用 5 人:库内 5 行 active = 0,响应逐条成功,且只产生一条审计。
|
||||||
|
func TestBatchDisableAthletesOneRequest(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
targets := []int64{ids.athleteA, ids.athB}
|
||||||
|
for _, name := range []string{"赵一", "钱二", "孙三"} {
|
||||||
|
targets = append(targets, env.insert(t,
|
||||||
|
`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, name, ids.teamA))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
|
||||||
|
if result.Action != "disable" || result.Requested != 5 || result.Succeeded != 5 || result.Failed != 0 {
|
||||||
|
t.Fatalf("响应应为 5 成功 0 失败,得到 %+v", result)
|
||||||
|
}
|
||||||
|
for _, item := range result.Items {
|
||||||
|
if !item.OK {
|
||||||
|
t.Fatalf("每条都该成功,得到 %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 5 {
|
||||||
|
t.Fatalf("应有 5 人处于停用,得到 %d", n)
|
||||||
|
}
|
||||||
|
// 停用是软删除:报名与成绩一条不动。
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 {
|
||||||
|
t.Fatalf("停用不该动报名,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := auditCount(t, env, "athlete.batch.disable"); n != 1 {
|
||||||
|
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 混入不存在的 id:逐条回报,整批仍然成功,重复 id 只算一次。
|
||||||
|
func TestBatchDisableReportsMissingIDs(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
result := doBatch(t, env, client, map[string]any{
|
||||||
|
"action": "disable", "ids": []int64{ids.athleteA, ids.athleteA, 999999},
|
||||||
|
})
|
||||||
|
if result.Requested != 2 || result.Succeeded != 1 || result.Failed != 1 {
|
||||||
|
t.Fatalf("重复 id 应去重、缺失 id 应逐条失败,得到 %+v", result)
|
||||||
|
}
|
||||||
|
if len(result.Items) != 2 {
|
||||||
|
t.Fatalf("应逐条回报 2 条,得到 %+v", result.Items)
|
||||||
|
}
|
||||||
|
missing := result.Items[1]
|
||||||
|
if missing.ID != 999999 || missing.OK || missing.Code != "NOT_FOUND" || missing.Message != "运动员不存在" {
|
||||||
|
t.Fatalf("缺失 id 应回报 运动员不存在,得到 %+v", missing)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 {
|
||||||
|
t.Fatalf("存在的那个人应被停用,得到 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量彻底删除:删 2 人、重算一次受影响组别,第三个人升到第一并拿到 10 分;
|
||||||
|
// 缺失的 id 不影响其余两人被删掉。
|
||||||
|
func TestBatchPurgeRecomputesAndReportsPerItem(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
|
||||||
|
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athC)
|
||||||
|
publishResults(t, env, client, ids.event, "batch-purge",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
// 前置:A 第一 10 分,C 第三 6 分。
|
||||||
|
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
|
||||||
|
t.Fatalf("前置条件:C 应是 6 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := doBatch(t, env, client, map[string]any{
|
||||||
|
"action": "purge", "ids": []int64{ids.athleteA, ids.athB, 999999},
|
||||||
|
})
|
||||||
|
if result.Succeeded != 2 || result.Failed != 1 {
|
||||||
|
t.Fatalf("应 2 成功 1 失败,得到 %+v", result)
|
||||||
|
}
|
||||||
|
if result.Purge == nil {
|
||||||
|
t.Fatalf("purge 应带回删除规模,得到 %+v", result)
|
||||||
|
}
|
||||||
|
if result.Purge.DeletedEntries != 2 || result.Purge.DeletedResults != 2 || result.Purge.DeletedLedger != 2 {
|
||||||
|
t.Fatalf("删除规模应为 报名2 / 成绩2 / 流水2,得到 %+v", result.Purge)
|
||||||
|
}
|
||||||
|
if len(result.Purge.RecomputedEvent) != 1 || result.Purge.RecomputedEvent[0] != ids.event {
|
||||||
|
t.Fatalf("应重算 1 个受影响小项,得到 %+v", result.Purge.RecomputedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被删的两人痕迹全无,第三人原样保留。
|
||||||
|
for _, id := range []int64{ids.athleteA, ids.athB} {
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 {
|
||||||
|
t.Fatalf("运动员 %d 应被删掉,得到 %d", id, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, athC); n != 1 {
|
||||||
|
t.Fatalf("第三个人的报名不该被删,得到 %d", n)
|
||||||
|
}
|
||||||
|
// 别人的名次被重算:C 从第三升到第一,得分 6 → 10,单位总分跟着变。
|
||||||
|
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{10}) {
|
||||||
|
t.Fatalf("C 应随新名次重算成 10 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||||
|
t.Fatalf("B 单位删空后总分应归零,得到 %v", got)
|
||||||
|
}
|
||||||
|
// 榜单也重算了:C 所在的 A 单位仍是 10 分(A 的 10 分换成了 C 的 10 分)。
|
||||||
|
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
|
||||||
|
t.Fatalf("A 单位应保持 10 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
|
||||||
|
t.Fatalf("重算应把旧流水作废留下来")
|
||||||
|
}
|
||||||
|
assertNoForeignKeyViolation(t, env)
|
||||||
|
if n := auditCount(t, env, "athlete.batch.purge"); n != 1 {
|
||||||
|
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dryRun 只统计不改库:预览数字与真删同源,但一行都不能动,也不该留审计。
|
||||||
|
func TestBatchPurgeDryRunChangesNothing(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "batch-dry",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
before := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
|
||||||
|
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
|
||||||
|
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
|
||||||
|
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
|
||||||
|
|
||||||
|
result := doBatch(t, env, client, map[string]any{
|
||||||
|
"action": "purge", "ids": []int64{ids.athleteA}, "dryRun": true,
|
||||||
|
})
|
||||||
|
if !result.DryRun || result.Succeeded != 1 {
|
||||||
|
t.Fatalf("预览应逐条成功并标记 dryRun,得到 %+v", result)
|
||||||
|
}
|
||||||
|
if result.Purge == nil || result.Purge.DeletedEntries != 1 || result.Purge.DeletedResults != 1 {
|
||||||
|
t.Fatalf("预览应报出会删掉什么,得到 %+v", result.Purge)
|
||||||
|
}
|
||||||
|
if len(result.Purge.RecomputedEvent) != 1 {
|
||||||
|
t.Fatalf("预览应报出会重算哪个小项,得到 %+v", result.Purge.RecomputedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
|
||||||
|
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
|
||||||
|
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
|
||||||
|
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("预览不该改动任何数据:\n%s\n---\n%s", before, after)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'athlete.batch%'`); n != 0 {
|
||||||
|
t.Fatalf("预览不该写审计,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非法入参:action 只认 disable / purge,ids 去重后不能为空。
|
||||||
|
func TestBatchRejectsBadInput(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"action 非法", map[string]any{"action": "delete", "ids": []int64{ids.athleteA}}, "action 只能是 disable / purge"},
|
||||||
|
{"ids 为空", map[string]any{"action": "disable", "ids": []int64{}}, "ids 不能为空"},
|
||||||
|
{"ids 全非法", map[string]any{"action": "purge", "ids": []int64{0, -3}}, "ids 不能为空"},
|
||||||
|
} {
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", tc.body, true)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||||
|
}
|
||||||
|
if env2.Error == nil || env2.Error.Message != tc.want {
|
||||||
|
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录的写操作一律 401,且不改动任何数据。
|
||||||
|
func TestBatchRequiresLogin(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
client, _ := env.client()
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
|
||||||
|
map[string]any{"action": "disable", "ids": []int64{ids.athleteA}}, false)
|
||||||
|
if status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("未登录应 401,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 0 {
|
||||||
|
t.Fatalf("被拒后不该改动数据,得到 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rawData 发一次请求并把 data 原样作为字符串返回。
|
||||||
|
// 逐字节比较需要的是服务端的原始字节,不是 decode 再 encode 的结果。
|
||||||
|
func rawData(t *testing.T, env *testEnv, client *http.Client, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
status, resp := env.do(client, http.MethodGet, path, nil, false)
|
||||||
|
if status != http.StatusOK || !resp.OK {
|
||||||
|
t.Fatalf("GET %s 应 200,得到 %d %+v", path, status, resp.Error)
|
||||||
|
}
|
||||||
|
return string(resp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableDump 把查询结果按原始列顺序 dump 成文本。
|
||||||
|
// 逐字节比较要的是存储层的事实,而不是经过 join 加工后的展示字段。
|
||||||
|
func tableDump(t *testing.T, env *testEnv, query string) string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := env.ev.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dump %s: %v", query, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
cols, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读列名: %v", err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for rows.Next() {
|
||||||
|
vals := make([]any, len(cols))
|
||||||
|
ptrs := make([]any, len(cols))
|
||||||
|
for i := range vals {
|
||||||
|
ptrs[i] = &vals[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
t.Fatalf("扫描 %s: %v", query, err)
|
||||||
|
}
|
||||||
|
for i, v := range vals {
|
||||||
|
if raw, ok := v.([]byte); ok {
|
||||||
|
v = string(raw)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s=%v ", cols[i], v)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("遍历 %s: %v", query, err)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// categorySnapshot 抓下「报名 / 成绩 / 流水 / 成绩接口 / 积分榜」五份原始数据。
|
||||||
|
// 改个组别名不该让它们有任何变化,连一个字节都不行。
|
||||||
|
//
|
||||||
|
// 不含 GET /events/{id}/entries:它 join 了组别名,categoryName 跟着变是应该的,
|
||||||
|
// 这条由下面的断言单独检查。
|
||||||
|
func categorySnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64) string {
|
||||||
|
t.Helper()
|
||||||
|
return strings.Join([]string{
|
||||||
|
tableDump(t, env, fmt.Sprintf(`SELECT * FROM entries WHERE event_id = %d ORDER BY id`, eventID)),
|
||||||
|
tableDump(t, env, fmt.Sprintf(`SELECT * FROM results WHERE event_id = %d ORDER BY id`, eventID)),
|
||||||
|
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`),
|
||||||
|
rawData(t, env, client, fmt.Sprintf("/api/results?eventId=%d&size=500", eventID)),
|
||||||
|
rawData(t, env, client, "/api/standings"),
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有报名数据的小项必须能改组别名,且报名 / 成绩 / 榜单逐字节不变——这是本次修复的核心诉求。
|
||||||
|
func TestUpdateCategoryRenamesWithEntriesKeepingData(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "cat-rename",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
before := categorySnapshot(t, env, client, ids.event)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||||
|
map[string]any{
|
||||||
|
"id": ids.category, "eventId": ids.event, "name": "男子甲组",
|
||||||
|
"multiplier": 1, "gender": "男", "ageMin": 18, "ageMax": 35, "sortOrder": 0,
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应回的是该小项最新的组别列表,前端据此刷新。
|
||||||
|
cats := decodeData[[]map[string]any](t, env2)
|
||||||
|
found := false
|
||||||
|
for _, c := range cats {
|
||||||
|
if c["id"] == float64(ids.category) {
|
||||||
|
found = true
|
||||||
|
if c["name"] != "男子甲组" {
|
||||||
|
t.Fatalf("响应里的组别名应为 男子甲组,得到 %+v", c["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("响应里应有组别 %d,得到 %+v", ids.category, cats)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
multiplier float64
|
||||||
|
gender string
|
||||||
|
ageMin, ageMax int
|
||||||
|
)
|
||||||
|
if err := env.ev.QueryRow(`SELECT name, multiplier, gender, age_min, age_max FROM event_categories WHERE id = ?`,
|
||||||
|
ids.category).Scan(&name, &multiplier, &gender, &ageMin, &ageMax); err != nil {
|
||||||
|
t.Fatalf("读组别: %v", err)
|
||||||
|
}
|
||||||
|
if name != "男子甲组" || multiplier != 1 || gender != "男" || ageMin != 18 || ageMax != 35 {
|
||||||
|
t.Fatalf("组别字段应就地更新,得到 name=%s mult=%v gender=%s age=%d-%d", name, multiplier, gender, ageMin, ageMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组别 id 没变(整表替换会换 id,报名就跟着断链)。
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE category_id = ?`, ids.category); n != 2 {
|
||||||
|
t.Fatalf("两条报名应仍挂在原组别上,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := categorySnapshot(t, env, client, ids.event)
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("改名不该动报名 / 成绩 / 流水 / 榜单:\n--- before ---\n%s\n--- after ---\n%s", before, after)
|
||||||
|
}
|
||||||
|
// 改名不参与算分:一条流水都不该被作废或重建。
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
|
||||||
|
t.Fatalf("改名不该触发重算,得到 %d 条作废流水", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 2 {
|
||||||
|
t.Fatalf("流水条数不该变,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if audit := waitAudit(t, env, "event.category.update", ids.category); !strings.Contains(audit, `"recomputedLedger":false`) {
|
||||||
|
t.Fatalf("审计应标出没有重算,得到 %s", audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展示层该跟着变:报名接口里的 categoryName 是新名字(这就是用户想看到的结果)。
|
||||||
|
entries := rawData(t, env, client, fmt.Sprintf("/api/events/%d/entries", ids.event))
|
||||||
|
if !strings.Contains(entries, `"categoryName":"男子甲组"`) || strings.Contains(entries, `"categoryName":"男子"`) {
|
||||||
|
t.Fatalf("报名列表应显示新组别名,得到 %s", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改权重同样是就地更新:不重算已有流水,榜单在改名前后必须逐字节一致。
|
||||||
|
// 改权重要立刻重算该组已发布成绩:权重进算分公式,不重算就会让积分与榜单静默不一致。
|
||||||
|
func TestUpdateCategoryWeightChangeRecomputesLedger(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "cat-mult",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
// 前置:A 第一 10 分、B 第二 8 分,单位总分 10 / 8。
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||||
|
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
|
||||||
|
t.Fatalf("前置条件:A 单位应是 10 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
before := rawData(t, env, client, "/api/standings")
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||||
|
map[string]any{"name": "男子", "multiplier": 2, "sortOrder": 0}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("改权重应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧流水被作废保留痕迹,新流水按 ×2 重建:10 → 20、8 → 16。
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{20}) {
|
||||||
|
t.Fatalf("A 应重算成 20 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{16}) {
|
||||||
|
t.Fatalf("B 应重算成 16 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 2 {
|
||||||
|
t.Fatalf("两条旧流水都应被作废,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 4 {
|
||||||
|
t.Fatalf("应是「2 条作废 + 2 条有效」,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
// 名次不变(权重不改排序),但分数与榜单必须跟着变。
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE rank = 1`); n != 1 {
|
||||||
|
t.Fatalf("名次不该被改乱,得到 %d 个第一", n)
|
||||||
|
}
|
||||||
|
if after := rawData(t, env, client, "/api/standings"); before == after {
|
||||||
|
t.Fatalf("榜单必须随新权重重算,但仍与改前一致:%s", after)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamA); got != 20 {
|
||||||
|
t.Fatalf("改权重后 A 单位应是 20 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamB); got != 16 {
|
||||||
|
t.Fatalf("改权重后 B 单位应是 16 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审计要能看出这次动了权重并触发重算。
|
||||||
|
after := waitAudit(t, env, "event.category.update", ids.category)
|
||||||
|
if !strings.Contains(after, `"recomputedLedger":true`) {
|
||||||
|
t.Fatalf("审计应标出触发了重算,得到 %s", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权重原样提交(浮点比较的容差)不该白重算一次全组积分。
|
||||||
|
func TestUpdateCategorySameMultiplierDoesNotRecompute(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "cat-same",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
var logID int64
|
||||||
|
if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM score_ledger`).Scan(&logID); err != nil {
|
||||||
|
t.Fatalf("读流水: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||||
|
// 1 与 1+1e-12 在容差内视为同一个权重。
|
||||||
|
map[string]any{"name": "男子甲组", "multiplier": 1.000000000001, "sortOrder": 0}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE id > ?`, logID); n != 0 {
|
||||||
|
t.Fatalf("权重没变不该产生新流水,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
|
||||||
|
t.Fatalf("权重没变不该作废流水,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||||
|
t.Fatalf("分值不该变,得到 %v", got)
|
||||||
|
}
|
||||||
|
after := waitAudit(t, env, "event.category.update", ids.category)
|
||||||
|
if !strings.Contains(after, `"recomputedLedger":false`) {
|
||||||
|
t.Fatalf("审计应标出没有重算,得到 %s", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同小项内重名必须 409,且文案点名是哪一个名字。
|
||||||
|
func TestUpdateCategoryRejectsDuplicateName(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||||
|
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, cat2),
|
||||||
|
map[string]any{"name": "男子", "multiplier": 1, "sortOrder": 1}, true)
|
||||||
|
if status != http.StatusConflict {
|
||||||
|
t.Fatalf("重名应 409,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if env2.Error == nil || !strings.Contains(env2.Error.Message, "组别名称重复:男子") {
|
||||||
|
t.Fatalf("重名文案应含「组别名称重复:男子」,得到 %+v", env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原行没被改坏。
|
||||||
|
var name string
|
||||||
|
if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, cat2).Scan(&name); err != nil {
|
||||||
|
t.Fatalf("读组别: %v", err)
|
||||||
|
}
|
||||||
|
if name != "女子" {
|
||||||
|
t.Fatalf("冲突时不该改动原组别,得到 %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称必填、性别枚举、年龄范围都按新接口的口径给 400,而不是让数据库约束冒成 500。
|
||||||
|
func TestUpdateCategoryValidatesFields(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
path := fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"名称必填", map[string]any{"name": " ", "multiplier": 1}, "组别 name 必填"},
|
||||||
|
{"性别枚举", map[string]any{"name": "男子", "multiplier": 1, "gender": "其他"}, "组别 gender 只能是 男 / 女"},
|
||||||
|
{"年龄范围", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 130}, "组别年龄需要在 0-120 之间"},
|
||||||
|
{"年龄区间", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 30, "ageMax": 20}, "组别年龄下限不能大于上限"},
|
||||||
|
} {
|
||||||
|
status, env2 := env.do(client, http.MethodPut, path, tc.body, true)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||||
|
}
|
||||||
|
if env2.Error == nil || env2.Error.Message != tc.want {
|
||||||
|
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组别不存在、或 categoryId 属于别的小项,都按 404「组别不存在」处理,不给跨小项改名。
|
||||||
|
func TestUpdateCategoryMissingReturns404(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
otherEvent := 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)
|
||||||
|
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||||
|
VALUES (?, '女子', 1, 0)`, otherEvent)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"组别不存在", fmt.Sprintf("/api/events/%d/categories/999999", ids.event)},
|
||||||
|
{"组别属于别的小项", fmt.Sprintf("/api/events/%d/categories/%d", ids.event, otherCat)},
|
||||||
|
} {
|
||||||
|
status, env2 := env.do(client, http.MethodPut, tc.path, map[string]any{"name": "男子", "multiplier": 1}, true)
|
||||||
|
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "组别不存在" {
|
||||||
|
t.Fatalf("%s 应 404 组别不存在,得到 %d %+v", tc.name, status, env2.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 别的小项的组别没被改动。
|
||||||
|
var name string
|
||||||
|
if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, otherCat).Scan(&name); err != nil {
|
||||||
|
t.Fatalf("读组别: %v", err)
|
||||||
|
}
|
||||||
|
if name != "女子" {
|
||||||
|
t.Fatalf("跨小项改名必须被拒,得到 %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 老路径(整表替换)不能被改坏:有报名数据时仍然拒绝,但文案要说清是「不能增删」。
|
||||||
|
func TestReplaceCategoriesStillRejectsWithEntries(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/events/%d/categories", ids.event),
|
||||||
|
map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}, true)
|
||||||
|
if status != http.StatusConflict {
|
||||||
|
t.Fatalf("有报名数据时整表替换应 409,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if env2.Error == nil || !strings.Contains(env2.Error.Message, "不能新增或删除组别") {
|
||||||
|
t.Fatalf("文案应说清是「不能增删组别」,得到 %+v", env2.Error)
|
||||||
|
}
|
||||||
|
// 拒绝时不许留下半截改动。
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, ids.event); n != 1 {
|
||||||
|
t.Fatalf("拒绝后组别数不该变,得到 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 就地更新也要推大屏、写审计。
|
||||||
|
func TestUpdateCategoryWritesChangeLogAndAudit(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
before := changeLogCount(t, env, "events", "upsert")
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||||
|
map[string]any{"name": "男子甲组", "multiplier": 1, "sortOrder": 0}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if got := changeLogCount(t, env, "events", "upsert"); got != before+1 {
|
||||||
|
t.Fatalf("改名应写 1 条 events change_log,得到 %d(之前 %d)", got, before)
|
||||||
|
}
|
||||||
|
after := waitAudit(t, env, "event.category.update", ids.category)
|
||||||
|
if !strings.Contains(after, `"男子甲组"`) {
|
||||||
|
t.Fatalf("审计的 after 应含新名字,得到 %s", after)
|
||||||
|
}
|
||||||
|
var beforeJSON sql.NullString
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT before_json FROM audit_logs WHERE action = 'event.category.update' AND entity_id = ?`,
|
||||||
|
ids.category).Scan(&beforeJSON); err != nil {
|
||||||
|
t.Fatalf("读审计 before: %v", err)
|
||||||
|
}
|
||||||
|
if !beforeJSON.Valid || !strings.Contains(beforeJSON.String, `"男子"`) {
|
||||||
|
t.Fatalf("审计的 before 应含旧名字,得到 %+v", beforeJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setLiveOnPublish 直接改赛事库设置,等价于管理端 PUT /api/settings。
|
||||||
|
// 走库而不是接口是为了让用例只关心结算行为,设置写入由 TestLiveOnPublishSwitchPersists 单独覆盖。
|
||||||
|
func setLiveOnPublish(t *testing.T, env *testEnv, value bool) {
|
||||||
|
t.Helper()
|
||||||
|
env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.live_on_publish', ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, map[bool]string{true: "true", false: "false"}[value])
|
||||||
|
}
|
||||||
|
|
||||||
|
type settlementStatus struct {
|
||||||
|
LiveOnPublish bool `json:"liveOnPublish"`
|
||||||
|
Groups []struct {
|
||||||
|
EventID int64 `json:"eventId"`
|
||||||
|
CategoryID int64 `json:"categoryId"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
Required int `json:"required"`
|
||||||
|
Published int `json:"published"`
|
||||||
|
} `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSettlement(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) settlementStatus {
|
||||||
|
t.Helper()
|
||||||
|
status, resp := env.do(client, http.MethodGet,
|
||||||
|
fmt.Sprintf("/api/results/settlement?eventId=%d&categoryId=%d", eventID, categoryID), nil, false)
|
||||||
|
if status != http.StatusOK || !resp.OK {
|
||||||
|
t.Fatalf("读结算状态应 200,得到 %d %+v", status, resp.Error)
|
||||||
|
}
|
||||||
|
return decodeData[settlementStatus](t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeLedgerRows(t *testing.T, env *testEnv, eventID int64) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("统计流水: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func standingsTotal(t *testing.T, env *testEnv, client *http.Client) float64 {
|
||||||
|
t.Helper()
|
||||||
|
_, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||||
|
rows := decodeData[[]map[string]any](t, resp)
|
||||||
|
var total float64
|
||||||
|
for _, row := range rows {
|
||||||
|
total += row["totalPoints"].(float64)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankOrZero 读名次:未发布的成绩没有名次(NULL),这里折算成 0,方便分批快照比较。
|
||||||
|
func rankOrZero(t *testing.T, env *testEnv, entryID int64) int {
|
||||||
|
t.Helper()
|
||||||
|
var rank sql.NullInt64
|
||||||
|
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
|
||||||
|
t.Fatalf("读取名次: %v", err)
|
||||||
|
}
|
||||||
|
if !rank.Valid {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(rank.Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeLogCountOf(t *testing.T, env *testEnv, entityType string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ?`, entityType).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("统计 change_log: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认(设置键不存在)必须是历史行为:分批发一批,流水与榜当场就变。
|
||||||
|
func TestLiveOnPublishDefaultIsRealTime(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
entries := seedBatch(t, env, ids, 8)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
times := make([]float64, 8)
|
||||||
|
for i := range times {
|
||||||
|
times[i] = 12.0 + float64(i)*0.1
|
||||||
|
}
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||||
|
}, true)
|
||||||
|
status, resp := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !resp.OK {
|
||||||
|
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 条已发布 = 4 条流水;前四名 10+8+6+3 = 27 分上榜。
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event)
|
||||||
|
if got := standingsTotal(t, env, client); got != 27 {
|
||||||
|
t.Fatalf("默认设置下发布一批后榜单总分 = %v,期望 27", got)
|
||||||
|
}
|
||||||
|
if got := changeLogCountOf(t, env, "standings"); got != 1 {
|
||||||
|
t.Fatalf("默认设置下发布应推 1 次榜单,得到 %d", got)
|
||||||
|
}
|
||||||
|
if got := rankOfEntry(t, env, entries[0]); got != 1 {
|
||||||
|
t.Fatalf("第一名应第 1 名,得到 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开关默认(键不存在)与显式 true 必须逐字节一致:
|
||||||
|
// 这是「加开关不能悄悄改变现场既有行为」的证明。
|
||||||
|
func TestLiveOnPublishDefaultMatchesExplicitTrue(t *testing.T) {
|
||||||
|
// snapshot 是一次发布之后的可比状态:名次、流水、榜单原始字节、榜单推送次数。
|
||||||
|
type snapshot struct {
|
||||||
|
ranks []int
|
||||||
|
ledger []string
|
||||||
|
standings string
|
||||||
|
pushes int
|
||||||
|
}
|
||||||
|
run := func(t *testing.T, value *bool) (afterFirst, afterSecond snapshot) {
|
||||||
|
t.Helper()
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
entries := seedBatch(t, env, ids, 8)
|
||||||
|
if value != nil {
|
||||||
|
setLiveOnPublish(t, env, *value)
|
||||||
|
}
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
read := func() snapshot {
|
||||||
|
t.Helper()
|
||||||
|
var snap snapshot
|
||||||
|
for _, id := range entries {
|
||||||
|
snap.ranks = append(snap.ranks, rankOrZero(t, env, id))
|
||||||
|
}
|
||||||
|
rows, err := env.ev.Query(
|
||||||
|
`SELECT entry_id, team_id, points FROM score_ledger WHERE event_id = ? AND voided = 0 ORDER BY entry_id, team_id`, ids.event)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读流水: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
for rows.Next() {
|
||||||
|
var entryID, teamID int64
|
||||||
|
var points float64
|
||||||
|
if err := rows.Scan(&entryID, &teamID, &points); err != nil {
|
||||||
|
t.Fatalf("扫描流水: %v", err)
|
||||||
|
}
|
||||||
|
snap.ledger = append(snap.ledger, fmt.Sprintf("%d/%d/%v", entryID, teamID, points))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("遍历流水: %v", err)
|
||||||
|
}
|
||||||
|
snap.standings = rawData(t, env, client, "/api/standings")
|
||||||
|
snap.pushes = changeLogCountOf(t, env, "standings")
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
slow := make([]float64, 8)
|
||||||
|
fast := make([]float64, 8)
|
||||||
|
for i := range slow {
|
||||||
|
slow[i] = 12.0 + float64(i)*0.1
|
||||||
|
fast[i] = 10.0 + float64(i)*0.1
|
||||||
|
}
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow),
|
||||||
|
}, true)
|
||||||
|
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true)
|
||||||
|
afterFirst = read()
|
||||||
|
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast),
|
||||||
|
}, true)
|
||||||
|
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true)
|
||||||
|
afterSecond = read()
|
||||||
|
return afterFirst, afterSecond
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSame := func(t *testing.T, label string, absent, explicit snapshot) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(absent.ranks, explicit.ranks) {
|
||||||
|
t.Fatalf("%s:名次不一致:默认 %v vs 显式 true %v", label, absent.ranks, explicit.ranks)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(absent.ledger, explicit.ledger) {
|
||||||
|
t.Fatalf("%s:流水不一致:默认 %v vs 显式 true %v", label, absent.ledger, explicit.ledger)
|
||||||
|
}
|
||||||
|
if absent.standings != explicit.standings {
|
||||||
|
t.Fatalf("%s:榜单字节不一致:\n默认 %s\n显式 %s", label, absent.standings, explicit.standings)
|
||||||
|
}
|
||||||
|
if absent.pushes != explicit.pushes {
|
||||||
|
t.Fatalf("%s:榜单推送次数不一致:默认 %d vs 显式 true %d", label, absent.pushes, explicit.pushes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
absentFirst, absentSecond := run(t, nil)
|
||||||
|
explicit := true
|
||||||
|
trueFirst, trueSecond := run(t, &explicit)
|
||||||
|
assertSame(t, "第一批", absentFirst, trueFirst)
|
||||||
|
assertSame(t, "第二批", absentSecond, trueSecond)
|
||||||
|
|
||||||
|
// 反向证明这份快照确实受开关控制,否则上面的「一致」可能只是两条路径都坏了。
|
||||||
|
// 关掉开关后第一批只发 4/8:名次照写(延迟结算只省流水与榜单),流水必须为空、榜单必须不动。
|
||||||
|
off := false
|
||||||
|
offFirst, _ := run(t, &off)
|
||||||
|
if len(offFirst.ledger) != 0 {
|
||||||
|
t.Fatalf("关闭开关后第一批不该写流水,得到 %v", offFirst.ledger)
|
||||||
|
}
|
||||||
|
if offFirst.pushes != 0 {
|
||||||
|
t.Fatalf("关闭开关后第一批不该推榜单,得到 %d 次", offFirst.pushes)
|
||||||
|
}
|
||||||
|
if offFirst.standings == absentFirst.standings {
|
||||||
|
t.Fatalf("关闭开关后榜单应与默认不同,得到同一份:%s", offFirst.standings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟结算:第一批只落成绩,名次 / 流水 / 榜单都不动;
|
||||||
|
// 第二批录完自动结算,整组重算并把名次与榜单补上。
|
||||||
|
func TestDeferredSettlementWaitsForWholeGroup(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
entries := seedBatch(t, env, ids, 8)
|
||||||
|
setLiveOnPublish(t, env, false)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
slow := make([]float64, 8)
|
||||||
|
fast := make([]float64, 8)
|
||||||
|
for i := range slow {
|
||||||
|
slow[i] = 12.0 + float64(i)*0.1
|
||||||
|
fast[i] = 10.0 + float64(i)*0.1
|
||||||
|
}
|
||||||
|
// 发布之前的榜单原始字节:延迟结算下这批发布不允许改动它(含奖牌列)。
|
||||||
|
beforeStandings := rawData(t, env, client, "/api/standings")
|
||||||
|
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow),
|
||||||
|
}, true)
|
||||||
|
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("第一批发布应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成绩要上屏(可以看到时间),但名次 / 流水 / 榜单都要等结算。
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 4, ids.event)
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`, 0)
|
||||||
|
// 名次不写:奖牌榜统计读的就是 results.rank,分批写名次会让奖牌列跟总分对不上。
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event)
|
||||||
|
if got := rawData(t, env, client, "/api/standings"); got != beforeStandings {
|
||||||
|
t.Fatalf("延迟结算下榜单不该动:\n发布前 %s\n发布后 %s", beforeStandings, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未结算清单要能查到这一组。
|
||||||
|
first := readSettlement(t, env, client, ids.event, ids.category)
|
||||||
|
if first.LiveOnPublish {
|
||||||
|
t.Fatalf("设置已关闭,接口应回报 liveOnPublish=false: %+v", first)
|
||||||
|
}
|
||||||
|
if len(first.Groups) != 1 || first.Groups[0].Required != 8 || first.Groups[0].Published != 4 {
|
||||||
|
t.Fatalf("未结算清单应含 4/8 的该组,得到 %+v", first.Groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二批录完 → 自动结算:整组重算,流水齐全,榜单正确。
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast),
|
||||||
|
}, true)
|
||||||
|
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event)
|
||||||
|
// 前六名得分 10+8+6+3+2+1 = 30,共 6 条流水。
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||||
|
if got := standingsTotal(t, env, client); got != 30 {
|
||||||
|
t.Fatalf("结算后榜单总分 = %v,期望 30", got)
|
||||||
|
}
|
||||||
|
if got := changeLogCountOf(t, env, "standings"); got != 1 {
|
||||||
|
t.Fatalf("结算应推 1 次榜单,得到 %d", got)
|
||||||
|
}
|
||||||
|
// 整组重算的证明:第二批更快,第一批第一名退到第 5 名。
|
||||||
|
if got := rankOfEntry(t, env, entries[4]); got != 1 {
|
||||||
|
t.Fatalf("第二批最快者应第 1 名,得到 %d", got)
|
||||||
|
}
|
||||||
|
if got := rankOfEntry(t, env, entries[0]); got != 5 {
|
||||||
|
t.Fatalf("第一批第一名应退到第 5 名,得到 %d", got)
|
||||||
|
}
|
||||||
|
if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 {
|
||||||
|
t.Fatalf("结算后未结算清单应为空,得到 %+v", got.Groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有人弃权(scratched)时不该再等他:7 人录完 + 1 人退赛也算全组录完,自动结算。
|
||||||
|
func TestDeferredSettlementTreatsScratchedAsFinished(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
entries := seedBatch(t, env, ids, 8)
|
||||||
|
setLiveOnPublish(t, env, false)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
// 第 8 人退赛(与前端「批量退赛」同一条接口)。
|
||||||
|
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "scratch", "ids": []int64{entries[7]},
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
times := make([]float64, 8)
|
||||||
|
for i := range times {
|
||||||
|
times[i] = 12.0 + float64(i)*0.1
|
||||||
|
}
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times),
|
||||||
|
}, true)
|
||||||
|
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||||
|
if got := standingsTotal(t, env, client); got != 30 {
|
||||||
|
t.Fatalf("退赛者不该阻塞结算,榜单总分 = %v,期望 30", got)
|
||||||
|
}
|
||||||
|
if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 {
|
||||||
|
t.Fatalf("全组(含退赛)录完后清单应为空,得到 %+v", got.Groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布之后才标退赛:整组因此录完,退赛动作本身也要把这组结算掉,不能停在空流水上。
|
||||||
|
func TestScratchAfterDeferredPublishSettlesGroup(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
entries := seedBatch(t, env, ids, 8)
|
||||||
|
setLiveOnPublish(t, env, false)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
times := make([]float64, 8)
|
||||||
|
for i := range times {
|
||||||
|
times[i] = 12.0 + float64(i)*0.1
|
||||||
|
}
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times),
|
||||||
|
}, true)
|
||||||
|
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true)
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||||
|
|
||||||
|
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "scratch", "ids": []int64{entries[7]},
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||||
|
if got := standingsTotal(t, env, client); got != 30 {
|
||||||
|
t.Fatalf("退赛补全整组后应结算,榜单总分 = %v,期望 30", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即结算是延迟结算的人工兜底:按当前已发布成绩结算,并写审计。
|
||||||
|
func TestManualSettleUsesPublishedResults(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
entries := seedBatch(t, env, ids, 8)
|
||||||
|
setLiveOnPublish(t, env, false)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
times := make([]float64, 8)
|
||||||
|
for i := range times {
|
||||||
|
times[i] = 12.0 + float64(i)*0.1
|
||||||
|
}
|
||||||
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||||
|
}, true)
|
||||||
|
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category, "requestId": "settle-1",
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有 4 条已发布 → 前四名 10+8+6+3 = 27 分。
|
||||||
|
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event)
|
||||||
|
if got := standingsTotal(t, env, client); got != 27 {
|
||||||
|
t.Fatalf("立即结算后榜单总分 = %v,期望 27", got)
|
||||||
|
}
|
||||||
|
waitAudit(t, env, "result.settle", ids.event)
|
||||||
|
|
||||||
|
// 未登录不能结算。
|
||||||
|
guest, _ := env.client()
|
||||||
|
if status, _ := env.do(guest, http.MethodPost, "/api/results/settle", map[string]any{
|
||||||
|
"eventId": ids.event, "categoryId": ids.category,
|
||||||
|
}, false); status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("未登录结算应 401,得到 %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开关键写入 / 读出:PUT /api/settings 落库后 GET 要能读回,且缺键默认 true。
|
||||||
|
func TestLiveOnPublishSwitchPersists(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
read := func() bool {
|
||||||
|
t.Helper()
|
||||||
|
_, resp := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||||
|
settings := decodeData[map[string]any](t, resp)
|
||||||
|
return settings["standings"].(map[string]any)["liveOnPublish"].(bool)
|
||||||
|
}
|
||||||
|
if !read() {
|
||||||
|
t.Fatalf("缺键时 liveOnPublish 应默认 true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||||
|
"standings": map[string]any{"liveOnPublish": false},
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("关闭开关应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
if read() {
|
||||||
|
t.Fatalf("关闭后 liveOnPublish 应读回 false")
|
||||||
|
}
|
||||||
|
// 只改一个字段不能把榜单口径的其余取值抹掉。
|
||||||
|
_, resp := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||||
|
standings := decodeData[map[string]any](t, resp)["standings"].(map[string]any)
|
||||||
|
if standings["individualToTeamWeight"].(float64) != 1 || standings["rankingMode"] != "points" {
|
||||||
|
t.Fatalf("其余榜单口径不应被改动: %+v", standings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||||
|
"standings": map[string]any{"liveOnPublish": true},
|
||||||
|
}, true); status != http.StatusOK || !e.OK {
|
||||||
|
t.Fatalf("重新打开应 200,得到 %d %+v", status, e.Error)
|
||||||
|
}
|
||||||
|
if !read() {
|
||||||
|
t.Fatalf("重新打开后 liveOnPublish 应读回 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,6 +248,9 @@ type currentUserDTO struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
LastLoginAt *string `json:"lastLoginAt"`
|
LastLoginAt *string `json:"lastLoginAt"`
|
||||||
MustChangePassword bool `json:"mustChangePassword"`
|
MustChangePassword bool `json:"mustChangePassword"`
|
||||||
|
// SessionExpiresAt 是当前会话的到期时间(RFC3339 UTC),供界面显示
|
||||||
|
// 「当前会话将于 … 过期」;未登录时为 null。
|
||||||
|
SessionExpiresAt *string `json:"sessionExpiresAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type backupResultDTO struct {
|
type backupResultDTO struct {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxEntryBatch 是单次批量操作的上限:前端一次勾选几十条是常态,
|
||||||
|
// 500 足够覆盖「一个组别全选」,再大说明是脚本误用,要求分批提交。
|
||||||
|
const maxEntryBatch = 500
|
||||||
|
|
||||||
|
// entryBatchItemDTO 是逐条回报:ids 里每一个 id 都必有一条,
|
||||||
|
// 不存在 / 被跳过都要写清原因,不能整批失败,也不能静默略过。
|
||||||
|
type entryBatchItemDTO struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryBatchSummaryDTO 是整批汇总:ok 含跳过的行,changed 才是真正改动的行数;
|
||||||
|
// failed 只统计 id 不存在这类逐条失败(整批 SQL 出错会直接 5xx / 409,不进这里)。
|
||||||
|
type entryBatchSummaryDTO struct {
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Changed int `json:"changed"`
|
||||||
|
RemovedEntries int `json:"removedEntries"`
|
||||||
|
RemovedResults int `json:"removedResults"`
|
||||||
|
RemovedLedger int `json:"removedLedger"`
|
||||||
|
RemovedBreaks int `json:"removedBreaks"`
|
||||||
|
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type entryBatchResultDTO struct {
|
||||||
|
Items []entryBatchItemDTO `json:"items"`
|
||||||
|
Summary entryBatchSummaryDTO `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryBatchTarget 是勾选行在库里的落点:换状态要看当前 status,重算要按 (小项, 组别) 分组。
|
||||||
|
type entryBatchTarget struct {
|
||||||
|
id int64
|
||||||
|
eventID int64
|
||||||
|
categoryID int64
|
||||||
|
status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchEntriesHandler 是 POST /api/entries/batch:
|
||||||
|
// scratch(批量退赛,可恢复)/ restore(批量恢复)/ remove(批量移出报名,物理删除)。
|
||||||
|
func (s *server) batchEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
var in entryBatchPayload
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
action := strings.TrimSpace(in.Action)
|
||||||
|
switch action {
|
||||||
|
case "scratch", "restore", "remove":
|
||||||
|
default:
|
||||||
|
return nil, badRequest("action 只能是 scratch / restore / remove", in.Action)
|
||||||
|
}
|
||||||
|
// 重复 id 去重(前端全选后二次勾选很容易重复),非法 id 直接丢掉。
|
||||||
|
ids := dedupeIDs(in.IDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, badRequest("ids 不能为空", nil)
|
||||||
|
}
|
||||||
|
if len(ids) > maxEntryBatch {
|
||||||
|
return nil, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.batchEntries(r.Context(), action, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.audit(r.Context(), "entry."+action, "entry", 0, nil, map[string]any{
|
||||||
|
"ids": ids,
|
||||||
|
"reason": strings.TrimSpace(in.Reason),
|
||||||
|
"summary": result.Summary,
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrySkipNote 解释某一行为什么没被改动:跳过必须带出现状,
|
||||||
|
// 否则操作员只看到「没退赛」会以为是系统漏了。
|
||||||
|
func entrySkipNote(action, status string) string {
|
||||||
|
switch status {
|
||||||
|
case "scratched":
|
||||||
|
return "已是退赛状态,跳过"
|
||||||
|
case "registered":
|
||||||
|
return "当前正常参赛,无需恢复"
|
||||||
|
case "dq":
|
||||||
|
return "已被判罚取消资格(dq),跳过,不覆盖原判定"
|
||||||
|
case "dns":
|
||||||
|
return "已标记未出场(dns),跳过,不覆盖原判定"
|
||||||
|
}
|
||||||
|
return "当前状态为「" + status + "」,跳过"
|
||||||
|
}
|
||||||
|
|
||||||
|
// recomputeReason 是流水作废原因,写进 score_ledger.void_reason,事后能对上「榜为什么变了」。
|
||||||
|
func recomputeReason(action string) string {
|
||||||
|
switch action {
|
||||||
|
case "scratch":
|
||||||
|
return "批量退赛后重算"
|
||||||
|
case "restore":
|
||||||
|
return "批量恢复后重算"
|
||||||
|
}
|
||||||
|
return "批量移出报名后重算"
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEntryBatchTargets 读回请求里存在的报名项;不存在的由调用方逐条回报。
|
||||||
|
func loadEntryBatchTargets(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64]entryBatchTarget, error) {
|
||||||
|
args := idsToArgs(ids)
|
||||||
|
rows, err := tx.QueryContext(ctx,
|
||||||
|
`SELECT id, event_id, category_id, status FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make(map[int64]entryBatchTarget, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var t entryBatchTarget
|
||||||
|
if err := rows.Scan(&t.id, &t.eventID, &t.categoryID, &t.status); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
out[t.id] = t
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRemoveStats 是移出报名的物理删除规模,随响应汇总返回。
|
||||||
|
type entryRemoveStats struct {
|
||||||
|
entries int
|
||||||
|
results int
|
||||||
|
ledger int
|
||||||
|
breaks int
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeEntriesTx 物理删除报名项及其成绩痕迹。
|
||||||
|
//
|
||||||
|
// 顺序按外键方向来(与 repo.go 的 purgeAthlete 同一套解法):
|
||||||
|
// score_ledger 对 entries / results 都是 ON DELETE SET NULL,直接删 entries 只会把
|
||||||
|
// entry_id 置空——人没了分还留在榜上,所以必须按 entry_id 显式清掉(result_id 一并兜住);
|
||||||
|
// record_breaks 是 CASCADE、results 对 entries 没有 ON DELETE,都要显式先删。
|
||||||
|
func (s *server) removeEntriesTx(ctx context.Context, tx *sql.Tx, ids []int64) (entryRemoveStats, error) {
|
||||||
|
stats := entryRemoveStats{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
ph := placeholders(len(ids))
|
||||||
|
idArgs := idsToArgs(ids)
|
||||||
|
steps := []struct {
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
count *int
|
||||||
|
}{
|
||||||
|
{`DELETE FROM score_ledger
|
||||||
|
WHERE entry_id IN (` + ph + `)
|
||||||
|
OR result_id IN (SELECT id FROM results WHERE entry_id IN (` + ph + `))`,
|
||||||
|
append(append([]any{}, idArgs...), idArgs...), &stats.ledger},
|
||||||
|
{`DELETE FROM record_breaks WHERE entry_id IN (` + ph + `)`, idArgs, &stats.breaks},
|
||||||
|
{`DELETE FROM results WHERE entry_id IN (` + ph + `)`, idArgs, &stats.results},
|
||||||
|
{`DELETE FROM entries WHERE id IN (` + ph + `)`, idArgs, &stats.entries},
|
||||||
|
}
|
||||||
|
for _, step := range steps {
|
||||||
|
res, err := tx.ExecContext(ctx, step.query, step.args...)
|
||||||
|
if err != nil {
|
||||||
|
return entryRemoveStats{}, s.entryBatchBlocked(ctx, tx, err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return entryRemoveStats{}, internal(err)
|
||||||
|
}
|
||||||
|
*step.count = int(n)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryBatchBlocked 兜底:撞上外键约束时回滚并给可读的 409,不让它冒成 500。
|
||||||
|
// 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。
|
||||||
|
func (s *server) entryBatchBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
|
||||||
|
if !isForeignKeyViolation(cause) {
|
||||||
|
return internal(cause)
|
||||||
|
}
|
||||||
|
s.logger.WarnContext(ctx, "批量移出报名撞上外键约束", "err", cause)
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return conflict("报名项仍被其它数据引用,本批已回滚,报名与成绩都没有改动。请刷新后重试。")
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchEntries 在一个事务里完成整批状态变更或删除,并按 (小项, 组别) 分组重算积分。
|
||||||
|
//
|
||||||
|
// 重算口径照 repo.go 的 purgeAthlete:删完成绩(或改完报名状态)后必须重算流水、
|
||||||
|
// 把新名次写回 results,再推小项与榜单,否则大屏还挂着旧分数、别人的名次也不会变。
|
||||||
|
func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (entryBatchResultDTO, error) {
|
||||||
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return entryBatchResultDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
targets, err := loadEntryBatchTargets(ctx, tx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := entryBatchResultDTO{Items: make([]entryBatchItemDTO, 0, len(ids))}
|
||||||
|
affected := make([]entryBatchTarget, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
target, ok := targets[id]
|
||||||
|
if !ok {
|
||||||
|
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: false, Error: "报名项不存在"})
|
||||||
|
out.Summary.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 退赛只认 registered、恢复只认 scratched;dq/dns 是别人的判定,一律不覆盖。
|
||||||
|
want := ""
|
||||||
|
switch action {
|
||||||
|
case "scratch":
|
||||||
|
want = "registered"
|
||||||
|
case "restore":
|
||||||
|
want = "scratched"
|
||||||
|
}
|
||||||
|
if want != "" && target.status != want {
|
||||||
|
out.Items = append(out.Items, entryBatchItemDTO{
|
||||||
|
ID: id, OK: true, Skipped: true, Note: entrySkipNote(action, target.status),
|
||||||
|
})
|
||||||
|
out.Summary.OK++
|
||||||
|
out.Summary.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
affected = append(affected, target)
|
||||||
|
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: true})
|
||||||
|
out.Summary.OK++
|
||||||
|
out.Summary.Changed++
|
||||||
|
}
|
||||||
|
|
||||||
|
changedIDs := targetIDs(affected)
|
||||||
|
switch action {
|
||||||
|
case "scratch", "restore":
|
||||||
|
if len(changedIDs) > 0 {
|
||||||
|
status := "scratched"
|
||||||
|
if action == "restore" {
|
||||||
|
status = "registered"
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE entries SET status = ? WHERE id IN (`+placeholders(len(changedIDs))+`)`,
|
||||||
|
append([]any{status}, idsToArgs(changedIDs)...)...); err != nil {
|
||||||
|
return entryBatchResultDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "remove":
|
||||||
|
stats, err := s.removeEntriesTx(ctx, tx, changedIDs)
|
||||||
|
if err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
out.Summary.RemovedEntries = stats.entries
|
||||||
|
out.Summary.RemovedResults = stats.results
|
||||||
|
out.Summary.RemovedLedger = stats.ledger
|
||||||
|
out.Summary.RemovedBreaks = stats.breaks
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
|
||||||
|
actorID := middleware.User(ctx).ID
|
||||||
|
live, err := liveOnPublishQ(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
eventIDs := make([]int64, 0, 2)
|
||||||
|
settled := false
|
||||||
|
for _, scope := range distinctScopes(affected) {
|
||||||
|
// 退赛 / 恢复会改变「这一组该不该等它」:标成退赛后整组可能刚好录完,
|
||||||
|
// 延迟结算下必须补一次结算,否则榜单会永远停在没有流水的那一版。
|
||||||
|
settle := live
|
||||||
|
if !live {
|
||||||
|
var err error
|
||||||
|
settle, err = scopeSettled(ctx, tx, scope.eventID, scope.categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settle {
|
||||||
|
// 退赛的成绩不再占名次:results.rank 必须一并清掉。
|
||||||
|
// buildRanked(奖牌统计 / 运动员榜)只认 results.rank + state='published',
|
||||||
|
// 不清就会留下「0 分却还挂着金牌」;恢复时由 applyComputedRanks 按新名次写回。
|
||||||
|
if action == "scratch" {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE results SET rank = NULL
|
||||||
|
WHERE state = 'published'
|
||||||
|
AND entry_id IN (SELECT id FROM entries
|
||||||
|
WHERE category_id = ? AND id IN (`+placeholders(len(changedIDs))+`))`,
|
||||||
|
append([]any{scope.categoryID}, idsToArgs(changedIDs)...)...); err != nil {
|
||||||
|
return entryBatchResultDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score, err := computeEventScore(ctx, tx, scope.eventID, scope.categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
// 只重建流水不够:成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
|
||||||
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
if err := writeLedgerTx(ctx, tx, score, scope.eventID, scope.categoryID, actorID, recomputeReason(action)); err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
}
|
||||||
|
if !slices.Contains(eventIDs, scope.eventID) {
|
||||||
|
eventIDs = append(eventIDs, scope.eventID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。
|
||||||
|
for _, eventID := range eventIDs {
|
||||||
|
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。
|
||||||
|
if len(eventIDs) > 0 && settled {
|
||||||
|
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||||
|
return entryBatchResultDTO{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Summary.RecomputedEvent = eventIDs
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return entryBatchResultDTO{}, s.entryBatchBlocked(ctx, tx, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetIDs(targets []entryBatchTarget) []int64 {
|
||||||
|
out := make([]int64, 0, len(targets))
|
||||||
|
for _, t := range targets {
|
||||||
|
out = append(out, t.id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryScope 是重算的最小单位:积分与名次都按「小项 + 组别」算。
|
||||||
|
type entryScope struct{ eventID, categoryID int64 }
|
||||||
|
|
||||||
|
// distinctScopes 按出现顺序去重,保证同一批请求的重算次序稳定、可复现。
|
||||||
|
func distinctScopes(targets []entryBatchTarget) []entryScope {
|
||||||
|
out := make([]entryScope, 0, len(targets))
|
||||||
|
for _, t := range targets {
|
||||||
|
scope := entryScope{t.eventID, t.categoryID}
|
||||||
|
if !slices.Contains(out, scope) {
|
||||||
|
out = append(out, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func idsToArgs(ids []int64) []any {
|
||||||
|
out := make([]any, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约,
|
||||||
|
// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。
|
||||||
|
type batchItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchSummary struct {
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Changed int `json:"changed"`
|
||||||
|
RemovedEntries int `json:"removedEntries"`
|
||||||
|
RemovedResults int `json:"removedResults"`
|
||||||
|
RemovedLedger int `json:"removedLedger"`
|
||||||
|
RemovedBreaks int `json:"removedBreaks"`
|
||||||
|
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchResult struct {
|
||||||
|
Items []batchItem `json:"items"`
|
||||||
|
Summary batchSummary `json:"summary"`
|
||||||
|
Raw map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryStatus 读报名项当前状态。
|
||||||
|
func entryStatus(t *testing.T, env *testEnv, entryID int64) string {
|
||||||
|
t.Helper()
|
||||||
|
var status string
|
||||||
|
if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil {
|
||||||
|
t.Fatalf("读报名项状态: %v", err)
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。
|
||||||
|
func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) {
|
||||||
|
t.Helper()
|
||||||
|
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`,
|
||||||
|
code, name, name)
|
||||||
|
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||||||
|
athleteName, teamID)
|
||||||
|
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
|
||||||
|
return teamID, entryID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。
|
||||||
|
func TestEntryBatchScratchAndRestore(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||||||
|
publishResults(t, env, client, ids.event, "batch",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 前置:10 / 8 / 6 分。
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||||||
|
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||||||
|
t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假",
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[batchResult](t, env2)
|
||||||
|
if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK {
|
||||||
|
t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items)
|
||||||
|
}
|
||||||
|
if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 {
|
||||||
|
t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := entryStatus(t, env, ids.entryB); got != "scratched" {
|
||||||
|
t.Fatalf("B 应已退赛,得到 %s", got)
|
||||||
|
}
|
||||||
|
if got := entryStatus(t, env, entryC); got != "scratched" {
|
||||||
|
t.Fatalf("C 应已退赛,得到 %s", got)
|
||||||
|
}
|
||||||
|
// 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 {
|
||||||
|
t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
|
||||||
|
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
// 别人被重算:A 仍是第一 10 分,C 退赛不占名次。
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||||
|
t.Fatalf("A 应保持 10 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, teamC); got != 0 {
|
||||||
|
t.Fatalf("C 单位总分应归零,得到 %v", got)
|
||||||
|
}
|
||||||
|
// 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。
|
||||||
|
for _, id := range []int64{ids.entryB, entryC} {
|
||||||
|
var rank sql.NullInt64
|
||||||
|
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
|
||||||
|
t.Fatalf("读退赛行的名次: %v", err)
|
||||||
|
}
|
||||||
|
if rank.Valid {
|
||||||
|
t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countRows(t, env,
|
||||||
|
`SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id
|
||||||
|
WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 {
|
||||||
|
t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复:同一端点 restore,只有 scratched 会被改回 registered。
|
||||||
|
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "restore", "ids": []int64{ids.entryB, entryC},
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if got := entryStatus(t, env, ids.entryB); got != "registered" {
|
||||||
|
t.Fatalf("B 应恢复为 registered,得到 %s", got)
|
||||||
|
}
|
||||||
|
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||||||
|
t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
|
||||||
|
t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
|
||||||
|
t.Fatalf("B 单位总分应恢复为 8,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||||||
|
t.Fatalf("C 单位总分应恢复为 6,得到 %v", got)
|
||||||
|
}
|
||||||
|
// 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。
|
||||||
|
var restoredRank sql.NullInt64
|
||||||
|
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil {
|
||||||
|
t.Fatalf("读恢复后的名次: %v", err)
|
||||||
|
}
|
||||||
|
if !restoredRank.Valid || restoredRank.Int64 != 2 {
|
||||||
|
t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank)
|
||||||
|
}
|
||||||
|
assertNoForeignKeyViolation(t, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。
|
||||||
|
func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)},
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[batchResult](t, env2)
|
||||||
|
if len(result.Items) != 3 {
|
||||||
|
t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items)
|
||||||
|
}
|
||||||
|
if !result.Items[0].OK || result.Items[0].Skipped {
|
||||||
|
t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0])
|
||||||
|
}
|
||||||
|
if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") {
|
||||||
|
t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1])
|
||||||
|
}
|
||||||
|
if result.Items[2].OK || result.Items[2].Error != "报名项不存在" {
|
||||||
|
t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2])
|
||||||
|
}
|
||||||
|
if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 {
|
||||||
|
t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary)
|
||||||
|
}
|
||||||
|
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||||
|
t.Fatalf("A 应已退赛,得到 %s", got)
|
||||||
|
}
|
||||||
|
if got := entryStatus(t, env, ids.entryB); got != "dq" {
|
||||||
|
t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got)
|
||||||
|
}
|
||||||
|
assertNoForeignKeyViolation(t, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水),
|
||||||
|
// 留下的人按新名次重算。
|
||||||
|
func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||||||
|
publishResults(t, env, client, ids.event, "remove",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
// 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。
|
||||||
|
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
|
||||||
|
VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "remove", "ids": []int64{ids.entryB},
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[batchResult](t, env2)
|
||||||
|
if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 ||
|
||||||
|
result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 {
|
||||||
|
t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 {
|
||||||
|
t.Fatalf("报名项应被物理删除,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||||||
|
t.Fatalf("成绩应被删除,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||||||
|
t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||||||
|
t.Fatalf("破纪录登记应被删除,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
// 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。
|
||||||
|
if n := countRows(t, env,
|
||||||
|
`SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 {
|
||||||
|
t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
// 留下的人被重算:C 从第三 6 分升到第二 8 分。
|
||||||
|
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) {
|
||||||
|
t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, teamC); got != 8 {
|
||||||
|
t.Fatalf("C 单位总分应重算成 8,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||||
|
t.Fatalf("被移出的单位总分应归零,得到 %v", got)
|
||||||
|
}
|
||||||
|
assertNoForeignKeyViolation(t, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。
|
||||||
|
func TestEntryBatchScratchAcrossEvents(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
event2 := 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)
|
||||||
|
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||||
|
VALUES (?, '男子', 1, 0)`, event2)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||||
|
VALUES (?, 'athlete', 'result', ?, 0)`, event2, profileID)
|
||||||
|
entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA)
|
||||||
|
publishResults(t, env, client, ids.event, "cross1",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
|
||||||
|
publishResults(t, env, client, event2, "cross2",
|
||||||
|
map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"})
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "scratch", "ids": []int64{ids.entryA, entry2},
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[batchResult](t, env2)
|
||||||
|
if len(result.Summary.RecomputedEvent) != 2 ||
|
||||||
|
result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 {
|
||||||
|
t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent)
|
||||||
|
}
|
||||||
|
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||||
|
t.Fatalf("小项一的报名应已退赛,得到 %s", got)
|
||||||
|
}
|
||||||
|
if got := entryStatus(t, env, entry2); got != "scratched" {
|
||||||
|
t.Fatalf("小项二的报名应已退赛,得到 %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。
|
||||||
|
func TestEntryBatchValidation(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch",
|
||||||
|
map[string]any{"action": "scratch", "ids": []int64{}}, true)
|
||||||
|
if status != http.StatusBadRequest || env2.Error == nil {
|
||||||
|
t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||||||
|
map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复 id 去重后只回报一条。
|
||||||
|
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||||||
|
map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
dup := decodeData[batchResult](t, env2)
|
||||||
|
if len(dup.Items) != 1 || dup.Summary.Changed != 1 {
|
||||||
|
t.Fatalf("重复 id 应去重成一条,得到 %+v", dup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过上限要在动手之前拒绝。
|
||||||
|
big := make([]int64, 501)
|
||||||
|
for i := range big {
|
||||||
|
big[i] = int64(i + 1)
|
||||||
|
}
|
||||||
|
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||||||
|
map[string]any{"action": "remove", "ids": big}, true)
|
||||||
|
if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") {
|
||||||
|
t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guest(未登录)不能写。
|
||||||
|
guest, _ := env.client()
|
||||||
|
status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch",
|
||||||
|
map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false)
|
||||||
|
if status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
|
||||||
|
t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id
|
||||||
|
// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。
|
||||||
|
func waitBatchAudit(t *testing.T, env *testEnv, action string) string {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
var after sql.NullString
|
||||||
|
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`,
|
||||||
|
action).Scan(&after)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return after.String
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
default:
|
||||||
|
t.Fatalf("查询审计: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("等不到 %s 的审计记录", action)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审计要带 ids 与 summary,三个动作各一条。
|
||||||
|
func TestEntryBatchAudit(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
for _, action := range []string{"scratch", "restore", "remove"} {
|
||||||
|
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": action, "ids": []int64{ids.entryA}, "reason": "实机验证",
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error)
|
||||||
|
}
|
||||||
|
after := waitBatchAudit(t, env, "entry."+action)
|
||||||
|
for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} {
|
||||||
|
if !strings.Contains(after, want) {
|
||||||
|
t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。
|
||||||
|
func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "keep",
|
||||||
|
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||||
|
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||||
|
)
|
||||||
|
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||||
|
"action": "remove", "ids": []int64{ids.entryA},
|
||||||
|
}, true); status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rank sql.NullInt64
|
||||||
|
state string
|
||||||
|
)
|
||||||
|
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB).
|
||||||
|
Scan(&rank, &state); err != nil {
|
||||||
|
t.Fatalf("读 B 的成绩: %v", err)
|
||||||
|
}
|
||||||
|
if !rank.Valid || rank.Int64 != 1 || state != "published" {
|
||||||
|
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cellAt 取单元格文本,越界返回空串(表格行可能比表头短)。
|
||||||
|
func cellAt(rows [][]string, r, c int) string {
|
||||||
|
if r < 0 || r >= len(rows) || c < 0 || c >= len(rows[r]) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rows[r][c]
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSheet struct {
|
||||||
|
name string
|
||||||
|
rows [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestXLSX 手工拼一份上传用的工作簿(表头在第 1 行),模拟现场自己整理的表。
|
||||||
|
func buildTestXLSX(t *testing.T, sheets []testSheet) []byte {
|
||||||
|
t.Helper()
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
for i, sh := range sheets {
|
||||||
|
if i == 0 {
|
||||||
|
if err := f.SetSheetName("Sheet1", sh.name); err != nil {
|
||||||
|
t.Fatalf("重命名工作表: %v", err)
|
||||||
|
}
|
||||||
|
} else if _, err := f.NewSheet(sh.name); err != nil {
|
||||||
|
t.Fatalf("新建工作表: %v", err)
|
||||||
|
}
|
||||||
|
for r, row := range sh.rows {
|
||||||
|
for c, v := range row {
|
||||||
|
name, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("算单元格坐标: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue(sh.name, name, v); err != nil {
|
||||||
|
t.Fatalf("写单元格: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := f.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("写出测试工作簿: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// editWorkbookCell 在导出的工作簿里找到「A 列等于 key」的那一行,把另一列改成新值。
|
||||||
|
// 行号不写死:导出的版式(标题 / 副标题 / 空行 / 表头)以后变了也不会让测试失效。
|
||||||
|
func editWorkbookCell(t *testing.T, f *excelize.File, sheet, keyCol, key, targetCol, value string) {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := f.GetRows(sheet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取工作表 %s: %v", sheet, err)
|
||||||
|
}
|
||||||
|
for r := range rows {
|
||||||
|
if cellAt(rows, r, columnIndex(keyCol)) != key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cell, err := excelize.CoordinatesToCellName(columnIndex(targetCol)+1, r+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("算单元格坐标: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue(sheet, cell, value); err != nil {
|
||||||
|
t.Fatalf("改单元格: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("工作表 %s 里找不到 %s=%s 的行", sheet, keyCol, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnIndex(letter string) int {
|
||||||
|
n, _ := excelize.ColumnNameToNumber(letter)
|
||||||
|
return n - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadBaseData 以 multipart 上传基础数据包,返回状态码与原始响应体。
|
||||||
|
func (e *testEnv) uploadBaseData(t *testing.T, client *http.Client, filename string, data []byte, fields map[string]string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
for k, v := range fields {
|
||||||
|
if err := w.WriteField(k, v); err != nil {
|
||||||
|
t.Fatalf("写表单字段: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fw, err := w.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建上传字段: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(data); err != nil {
|
||||||
|
t.Fatalf("写工作簿: %v", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("关闭 multipart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/basedata/import", &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造请求: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("上传基础数据包: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseDataResult 是导入响应里我们关心的部分(与后端 baseDataImportResult 同构)。
|
||||||
|
type baseDataResult struct {
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
Added int `json:"added"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Unchanged int `json:"unchanged"`
|
||||||
|
ErrorCount int `json:"errorCount"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
Changes []struct {
|
||||||
|
Sheet string `json:"sheet"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Fields []struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
} `json:"fields"`
|
||||||
|
} `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBaseDataResult(t *testing.T, raw []byte) baseDataResult {
|
||||||
|
t.Helper()
|
||||||
|
var env struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Data baseDataResult `json:"data"`
|
||||||
|
Error *apiErrorBody `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
t.Fatalf("解析导入响应: %v (%s)", err, raw)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatalf("导入失败: %+v", env.Error)
|
||||||
|
}
|
||||||
|
return env.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 往返硬要求:刚导出的基础数据包原样导入,差异必须是 0/0/0。
|
||||||
|
func TestBaseDataPackageRoundTripIsNoop(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
status, header, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("导出基础数据包应 200,得到 %d %s", status, body)
|
||||||
|
}
|
||||||
|
assertContentDisposition(t, header.Get("Content-Disposition"), "basedata.xlsx", "基础数据包.xlsx")
|
||||||
|
|
||||||
|
f := openWorkbook(t, body)
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if got := f.GetSheetList(); !sameStrings(got, []string{"单位", "大项", "小项", "组别"}) {
|
||||||
|
t.Fatalf("工作表列表 = %v,期望 单位 / 大项 / 小项 / 组别", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", body, map[string]string{"dryRun": "1"})
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("预览应 200,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
res := decodeBaseDataResult(t, raw)
|
||||||
|
if !res.DryRun {
|
||||||
|
t.Errorf("dryRun 应为 true")
|
||||||
|
}
|
||||||
|
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||||
|
t.Fatalf("原样导入应无差异,得到 新增 %d / 更新 %d / 错误 %d:%v",
|
||||||
|
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||||
|
}
|
||||||
|
if len(res.Changes) != 0 {
|
||||||
|
t.Errorf("无差异时不该有逐行差异,得到 %+v", res.Changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改掉一个单位名与一个小项名再导入:差异应正好是这 2 条更新,其余 0。
|
||||||
|
func TestBaseDataImportReportsUpdates(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
_, _, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
|
||||||
|
|
||||||
|
// 在导出的工作簿上改两处:一个单位名、一个小项名。
|
||||||
|
f := openWorkbook(t, body)
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
editWorkbookCell(t, f, "单位", "B", "第一分公司", "B", "第一分公司(改)")
|
||||||
|
editWorkbookCell(t, f, "小项", "B", "M100", "C", "百米飞人大战")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := f.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("写回工作簿: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", buf.Bytes(), map[string]string{"dryRun": "1"})
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("预览应 200,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
res := decodeBaseDataResult(t, raw)
|
||||||
|
if res.Added != 0 || res.ErrorCount != 0 {
|
||||||
|
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d:%v", res.Added, res.ErrorCount, res.Errors)
|
||||||
|
}
|
||||||
|
if res.Updated != 2 {
|
||||||
|
t.Fatalf("更新条数 = %d,期望 2", res.Updated)
|
||||||
|
}
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, ch := range res.Changes {
|
||||||
|
for _, f := range ch.Fields {
|
||||||
|
seen[ch.Sheet+"."+f.Field] = f.From + "→" + f.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := seen["单位.单位名称"]; got != "第一分公司→第一分公司(改)" {
|
||||||
|
t.Errorf("单位名差异 = %q,期望 第一分公司→第一分公司(改)", got)
|
||||||
|
}
|
||||||
|
if got := seen["小项.小项名称"]; got != "100米跑→百米飞人大战" {
|
||||||
|
t.Errorf("小项名差异 = %q,期望 100米跑→百米飞人大战", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dryRun 绝不落库:库里的名字还是原来的。
|
||||||
|
var name string
|
||||||
|
if err := env.ev.QueryRow(`SELECT name FROM teams WHERE id = ?`, ids.teamA).Scan(&name); err != nil {
|
||||||
|
t.Fatalf("查询单位: %v", err)
|
||||||
|
}
|
||||||
|
if name != "第一分公司" {
|
||||||
|
t.Fatalf("dryRun 不该写库,单位名变成了 %q", name)
|
||||||
|
}
|
||||||
|
var eventName string
|
||||||
|
if err := env.ev.QueryRow(`SELECT name FROM events WHERE id = ?`, ids.event).Scan(&eventName); err != nil {
|
||||||
|
t.Fatalf("查询小项: %v", err)
|
||||||
|
}
|
||||||
|
if eventName != "100米跑" {
|
||||||
|
t.Fatalf("dryRun 不该写库,小项名变成了 %q", eventName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绝不删除:只含 1 个新单位的表导进去,其余单位一条不少。
|
||||||
|
func TestBaseDataImportNeverDeletes(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
before := tableCount(t, env, "teams")
|
||||||
|
beforeSports := tableCount(t, env, "sports")
|
||||||
|
beforeEvents := tableCount(t, env, "events")
|
||||||
|
|
||||||
|
pack := buildTestXLSX(t, []testSheet{{
|
||||||
|
name: "单位",
|
||||||
|
rows: [][]string{
|
||||||
|
{"单位编码", "单位名称", "启用"},
|
||||||
|
{"C", "第三分公司", "是"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
res := decodeBaseDataResult(t, raw)
|
||||||
|
if res.Added != 1 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||||
|
t.Fatalf("应新增 1 条,得到 新增 %d / 更新 %d / 错误 %d %v", res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := tableCount(t, env, "teams"); got != before+1 {
|
||||||
|
t.Fatalf("单位数 = %d,期望 %d(只多 1 条,其余一条不少)", got, before+1)
|
||||||
|
}
|
||||||
|
for _, code := range []string{"A", "B", "C"} {
|
||||||
|
var n int
|
||||||
|
if err := env.ev.QueryRow(`SELECT count(*) FROM teams WHERE code = ?`, code).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("查询单位 %s: %v", code, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("单位 %s 的记录数 = %d,期望 1", code, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := tableCount(t, env, "sports"); got != beforeSports {
|
||||||
|
t.Errorf("大项数 = %d,期望 %d(表里没写的不动)", got, beforeSports)
|
||||||
|
}
|
||||||
|
if got := tableCount(t, env, "events"); got != beforeEvents {
|
||||||
|
t.Errorf("小项数 = %d,期望 %d(表里没写的不动)", got, beforeEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建的单位 / 大项不能被建成停用:缺省 = 启用。
|
||||||
|
func TestBaseDataImportCreatesEnabledRows(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
pack := buildTestXLSX(t, []testSheet{
|
||||||
|
{name: "单位", rows: [][]string{
|
||||||
|
{"单位编码", "单位名称"},
|
||||||
|
{"C", "第三分公司"},
|
||||||
|
}},
|
||||||
|
{name: "大项", rows: [][]string{
|
||||||
|
{"大项编码", "大项名称"},
|
||||||
|
{"SWIM", "游泳"},
|
||||||
|
}},
|
||||||
|
{name: "小项", rows: [][]string{
|
||||||
|
{"大项编码", "小项编码", "小项名称", "类型", "结果类型"},
|
||||||
|
{"SWIM", "S100", "100米自由泳", "individual", "time"},
|
||||||
|
}},
|
||||||
|
{name: "组别", rows: [][]string{
|
||||||
|
{"小项编码", "组别名", "性别", "年龄下限", "年龄上限"},
|
||||||
|
{"S100", "男子", "男", "18", "35"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
res := decodeBaseDataResult(t, raw)
|
||||||
|
if res.Added != 4 || res.ErrorCount != 0 {
|
||||||
|
t.Fatalf("应新增 4 条,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
var active int
|
||||||
|
if err := env.ev.QueryRow(`SELECT active FROM teams WHERE code = 'C'`).Scan(&active); err != nil {
|
||||||
|
t.Fatalf("查询新单位: %v", err)
|
||||||
|
}
|
||||||
|
if active != 1 {
|
||||||
|
t.Fatalf("新单位应启用(active=1),得到 %d", active)
|
||||||
|
}
|
||||||
|
if err := env.ev.QueryRow(`SELECT active FROM sports WHERE code = 'SWIM'`).Scan(&active); err != nil {
|
||||||
|
t.Fatalf("查询新大项: %v", err)
|
||||||
|
}
|
||||||
|
if active != 1 {
|
||||||
|
t.Fatalf("新大项应启用(active=1),得到 %d", active)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小项挂到了新建的大项下,组别挂到了新建的小项下,枚举值也按原样落库。
|
||||||
|
var (
|
||||||
|
sportID int64
|
||||||
|
evType string
|
||||||
|
rType string
|
||||||
|
)
|
||||||
|
if err := env.ev.QueryRow(`SELECT sport_id, event_type, result_type FROM events WHERE code = 'S100'`).
|
||||||
|
Scan(&sportID, &evType, &rType); err != nil {
|
||||||
|
t.Fatalf("查询新小项: %v", err)
|
||||||
|
}
|
||||||
|
var wantSport int64
|
||||||
|
if err := env.ev.QueryRow(`SELECT id FROM sports WHERE code = 'SWIM'`).Scan(&wantSport); err != nil {
|
||||||
|
t.Fatalf("查询新大项 id: %v", err)
|
||||||
|
}
|
||||||
|
if sportID != wantSport {
|
||||||
|
t.Errorf("新小项的大项 id = %d,期望 %d", sportID, wantSport)
|
||||||
|
}
|
||||||
|
if evType != "individual" || rType != "time" {
|
||||||
|
t.Errorf("枚举值 = %s / %s,期望 individual / time", evType, rType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gender string
|
||||||
|
var ageMin, ageMax int
|
||||||
|
if err := env.ev.QueryRow(`SELECT gender, age_min, age_max FROM event_categories WHERE name = '男子' AND event_id = ?`,
|
||||||
|
func() int64 {
|
||||||
|
var id int64
|
||||||
|
_ = env.ev.QueryRow(`SELECT id FROM events WHERE code = 'S100'`).Scan(&id)
|
||||||
|
return id
|
||||||
|
}()).
|
||||||
|
Scan(&gender, &ageMin, &ageMax); err != nil {
|
||||||
|
t.Fatalf("查询新组别: %v", err)
|
||||||
|
}
|
||||||
|
if gender != "男" || ageMin != 18 || ageMax != 35 {
|
||||||
|
t.Errorf("组别 = %s %d-%d,期望 男 18-35", gender, ageMin, ageMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行级错误:中文、带行号、带工作表名,且 dryRun 下库一点没动。
|
||||||
|
func TestBaseDataImportRowErrors(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
before := countAllTables(t, env)
|
||||||
|
|
||||||
|
pack := buildTestXLSX(t, []testSheet{
|
||||||
|
{name: "小项", rows: [][]string{
|
||||||
|
{"大项编码", "小项编码", "小项名称", "类型"},
|
||||||
|
{"NOPE", "X1", "幽灵小项", "individual"}, // 第 2 行:大项不存在
|
||||||
|
{"TRACK", "X2", "坏类型", "乱七八糟"}, // 第 3 行:枚举非法
|
||||||
|
}},
|
||||||
|
{name: "组别", rows: [][]string{
|
||||||
|
{"小项编码", "组别名"},
|
||||||
|
{"NOPE", "甲组"}, // 第 2 行:小项不存在
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, map[string]string{"dryRun": "1"})
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("预览应 200,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
res := decodeBaseDataResult(t, raw)
|
||||||
|
if res.ErrorCount != 3 {
|
||||||
|
t.Fatalf("错误条数 = %d,期望 3:%v", res.ErrorCount, res.Errors)
|
||||||
|
}
|
||||||
|
if res.Added != 0 || res.Updated != 0 {
|
||||||
|
t.Errorf("全错时不该有变更,得到 新增 %d / 更新 %d", res.Added, res.Updated)
|
||||||
|
}
|
||||||
|
wantParts := [][]string{
|
||||||
|
{"小项", "第 2 行", "大项编码 NOPE 不存在"},
|
||||||
|
{"小项", "第 3 行", "eventType 只能是 individual / team / match / individual_team"},
|
||||||
|
{"组别", "第 2 行", "小项编码 NOPE 不存在"},
|
||||||
|
}
|
||||||
|
for i, parts := range wantParts {
|
||||||
|
if i >= len(res.Errors) {
|
||||||
|
t.Fatalf("缺少第 %d 条错误", i+1)
|
||||||
|
}
|
||||||
|
for _, p := range parts {
|
||||||
|
if !strings.Contains(res.Errors[i], p) {
|
||||||
|
t.Errorf("错误 %d = %q,应包含 %q", i+1, res.Errors[i], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dryRun 不写库:四张表计数一条不变。
|
||||||
|
if got := countAllTables(t, env); !sameCounts(got, before) {
|
||||||
|
t.Fatalf("dryRun 不该写库,导入前后计数不同:%v → %v", before, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入是批量写,只有 admin 能做。
|
||||||
|
func TestBaseDataImportAdminOnly(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
|
||||||
|
pack := buildTestXLSX(t, []testSheet{{name: "单位", rows: [][]string{
|
||||||
|
{"单位编码", "单位名称"},
|
||||||
|
{"C", "第三分公司"},
|
||||||
|
}}})
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("recorder 导入应 403,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
if got := tableCount(t, env, "teams"); got != 2 {
|
||||||
|
t.Fatalf("403 不该写库,单位数 = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseDataImportRejectsNonXlsx(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
status, raw := env.uploadBaseData(t, client, "basedata.csv", []byte("单位编码,单位名称\nC,第三分公司\n"), nil)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("CSV 应 400,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "xlsx") {
|
||||||
|
t.Errorf("错误信息应说明必须是 xlsx,得到 %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 小工具 ----------
|
||||||
|
|
||||||
|
func tableCount(t *testing.T, env *testEnv, table string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := env.ev.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("统计 %s: %v", table, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countAllTables(t *testing.T, env *testEnv) map[string]int {
|
||||||
|
t.Helper()
|
||||||
|
out := make(map[string]int, 4)
|
||||||
|
for _, table := range []string{"teams", "sports", "events", "event_categories"} {
|
||||||
|
out[table] = tableCount(t, env, table)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCounts(a, b map[string]int) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range a {
|
||||||
|
if b[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 报名名单导出:guest 可用、表头含批次、前导零不丢、筛选参数生效。
|
||||||
|
func TestExportEntriesXLSX(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
// 号码布带前导零,entryA 分到批次 2,entryB 未分批。
|
||||||
|
env.exec(t, `UPDATE entries SET bib = '007', heat = 2, remark = '卫冕冠军' WHERE id = ?`, ids.entryA)
|
||||||
|
env.exec(t, `UPDATE entries SET bib = '0012' WHERE id = ?`, ids.entryB)
|
||||||
|
|
||||||
|
client, _ := env.client()
|
||||||
|
status, header, body := env.getRaw(t, client, "/api/export/entries.xlsx")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("导出报名名单应 200,得到 %d %s", status, body)
|
||||||
|
}
|
||||||
|
assertContentDisposition(t, header.Get("Content-Disposition"), "entries.xlsx", "报名名单.xlsx")
|
||||||
|
|
||||||
|
f := openWorkbook(t, body)
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
rows, err := f.GetRows("报名名单")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取报名名单: %v", err)
|
||||||
|
}
|
||||||
|
// 4 行版式 + 2 条报名。
|
||||||
|
if len(rows) != 6 {
|
||||||
|
t.Fatalf("行数 = %d,期望 6", len(rows))
|
||||||
|
}
|
||||||
|
wantHeaders := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名",
|
||||||
|
"证件号", "号码布", "批次", "性别", "年龄", "状态", "备注"}
|
||||||
|
for i, want := range wantHeaders {
|
||||||
|
if got := cellAt(rows, 3, i); got != want {
|
||||||
|
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 未分批(heat 为空)的排最前。
|
||||||
|
if got := cellAt(rows, 4, 5); got != "李强" {
|
||||||
|
t.Fatalf("第一行姓名 = %q,期望 李强", got)
|
||||||
|
}
|
||||||
|
if got := cellAt(rows, 4, 7); got != "0012" {
|
||||||
|
t.Errorf("号码布 = %q,期望 0012(前导零不能丢)", got)
|
||||||
|
}
|
||||||
|
if got := cellAt(rows, 4, 8); got != "" {
|
||||||
|
t.Errorf("未分批行批次 = %q,期望留空", got)
|
||||||
|
}
|
||||||
|
if got := cellAt(rows, 5, 5); got != "张伟" {
|
||||||
|
t.Fatalf("第二行姓名 = %q,期望 张伟", got)
|
||||||
|
}
|
||||||
|
if got := cellAt(rows, 5, 8); got != "2" {
|
||||||
|
t.Errorf("批次 = %q,期望 2", got)
|
||||||
|
}
|
||||||
|
if got := cellAt(rows, 5, 11); got != "正常参赛" {
|
||||||
|
t.Errorf("状态 = %q,期望 正常参赛", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按批次筛选:只剩批次 2 的那条。
|
||||||
|
status, _, filtered := env.getRaw(t, client, "/api/export/entries.xlsx?heat=2")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("按批次筛选应 200,得到 %d", status)
|
||||||
|
}
|
||||||
|
f2 := openWorkbook(t, filtered)
|
||||||
|
defer func() { _ = f2.Close() }()
|
||||||
|
fRows, err := f2.GetRows("报名名单")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取筛选结果: %v", err)
|
||||||
|
}
|
||||||
|
if len(fRows) != 5 {
|
||||||
|
t.Fatalf("按批次筛选后行数 = %d,期望 5", len(fRows))
|
||||||
|
}
|
||||||
|
if got := cellAt(fRows, 4, 8); got != "2" {
|
||||||
|
t.Errorf("筛选后批次 = %q,期望 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按单位 + 小项 + 组别组合筛选。
|
||||||
|
path := "/api/export/entries.xlsx?teamId=1&eventId=1&categoryId=1"
|
||||||
|
status, _, filtered = env.getRaw(t, client, path)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("组合筛选应 200,得到 %d", status)
|
||||||
|
}
|
||||||
|
f3 := openWorkbook(t, filtered)
|
||||||
|
defer func() { _ = f3.Close() }()
|
||||||
|
cRows, _ := f3.GetRows("报名名单")
|
||||||
|
if len(cRows) != 5 {
|
||||||
|
t.Fatalf("组合筛选后行数 = %d,期望 5", len(cRows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选参数填错必须报错:静默当成「不筛」会让现场拿到全量名单还以为筛过了。
|
||||||
|
func TestExportEntriesXLSXRejectsBadFilter(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
client, _ := env.client()
|
||||||
|
|
||||||
|
status, _, body := env.getRaw(t, client, "/api/export/entries.xlsx?heat=abc")
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("非法批次筛选应 400,得到 %d %s", status, body)
|
||||||
|
}
|
||||||
|
status, _, body = env.getRaw(t, client, "/api/export/entries.xlsx?teamId=0")
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("0 不是合法单位 id,应 400,得到 %d %s", status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
"github.com/xuri/excelize/v2"
|
||||||
@@ -59,7 +60,12 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
|
|||||||
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
|
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
|
||||||
After: map[string]any{"username": u.Username},
|
After: map[string]any{"username": u.Username},
|
||||||
})
|
})
|
||||||
return userDTO(u), nil
|
out := userDTO(u)
|
||||||
|
// 刚建好的会话到期时间 = 此刻 + 当前有效期(与 createSession 同一请求内,误差在毫秒级);
|
||||||
|
// 下发它,界面登录后就能立刻显示「当前会话将于 … 过期」,不用等下一次 /auth/me。
|
||||||
|
expiresAt := time.Now().UTC().Add(s.auth.SessionTTL()).Format(time.RFC3339)
|
||||||
|
out.SessionExpiresAt = &expiresAt
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
@@ -75,7 +81,31 @@ func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
|||||||
|
|
||||||
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
u := middleware.User(r.Context())
|
u := middleware.User(r.Context())
|
||||||
return userDTO(u), nil
|
out := userDTO(u)
|
||||||
|
if u.Role == auth.RoleGuest {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顺带把会话 Cookie 的期限同步到最新的到期时间:
|
||||||
|
// 滑动续期只改服务端 expires_at,Cookie 的 Max-Age 不跟着走的话,
|
||||||
|
// 浏览器会在「登录时刻 + 有效期」处丢掉 Cookie,人还是被登出。
|
||||||
|
// /auth/me 是管理端启动与定时心跳都会走的接口,放在这里就够。
|
||||||
|
c, err := r.Cookie(auth.CookieSession)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
|
||||||
|
if err != nil || sess == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
expiresAt := sess.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
out.SessionExpiresAt = &expiresAt
|
||||||
|
csrf := ""
|
||||||
|
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
|
||||||
|
csrf = c.Value
|
||||||
|
}
|
||||||
|
s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt)
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type changePasswordInput struct {
|
type changePasswordInput struct {
|
||||||
@@ -356,9 +386,19 @@ func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, e
|
|||||||
|
|
||||||
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
var in eventDTO
|
var in eventDTO
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
keys, err := decodeJSONPresence(r, &in)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 与建单位 / 大项同口径:缺省 = 建表默认值(migrations/event/0004_sports_events.sql 里
|
||||||
|
// scored 与 record_eligible 都 DEFAULT 1)。Go 的 bool 零值是 false,漏传会被静默建成
|
||||||
|
// 「只排名不计分、不能破纪录」的小项 —— 这与导入侧按建表默认值建档的口径也不一致。
|
||||||
|
if !keys.has("scored") {
|
||||||
|
in.Scored = true
|
||||||
|
}
|
||||||
|
if !keys.has("recordEligible") {
|
||||||
|
in.RecordEligible = true
|
||||||
|
}
|
||||||
created, err := s.createEvent(r.Context(), in)
|
created, err := s.createEvent(r.Context(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -373,13 +413,22 @@ func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var in eventDTO
|
var in eventDTO
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
keys, err := decodeJSONPresence(r, &in)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
before, err := s.getEvent(r.Context(), id)
|
before, err := s.getEvent(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 更新仍是全量覆盖语义,但漏传这两个开关不等于「关掉它」:沿用库里的现值,
|
||||||
|
// 与 active 在单位 / 大项上的处理一致。
|
||||||
|
if !keys.has("scored") {
|
||||||
|
in.Scored = before.Scored
|
||||||
|
}
|
||||||
|
if !keys.has("recordEligible") {
|
||||||
|
in.RecordEligible = before.RecordEligible
|
||||||
|
}
|
||||||
updated, err := s.updateEvent(r.Context(), id, in)
|
updated, err := s.updateEvent(r.Context(), id, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -611,9 +660,12 @@ type entryImportResult struct {
|
|||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
Conflicts []importConflict `json:"conflicts"`
|
Conflicts []importConflict `json:"conflicts"`
|
||||||
|
// SameNameNewPersons 是「号码布不同 → 按同名的另一个人新建档案」的清单。
|
||||||
|
// 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。
|
||||||
|
SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。
|
// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。
|
||||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||||
type importConflict struct {
|
type importConflict struct {
|
||||||
Row int `json:"row"`
|
Row int `json:"row"`
|
||||||
@@ -886,9 +938,9 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||||
// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。
|
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
|
||||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
|
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{}}
|
||||||
if len(rows) < 2 {
|
if len(rows) < 2 {
|
||||||
return result, badRequest("报名表为空", nil)
|
return result, badRequest("报名表为空", nil)
|
||||||
}
|
}
|
||||||
@@ -904,8 +956,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。
|
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||||
seenIDCards := make(map[string]bool)
|
// 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。
|
||||||
seenBibs := make(map[string]bool)
|
seenBibs := make(map[string]bool)
|
||||||
for n, row := range rows[1:] {
|
for n, row := range rows[1:] {
|
||||||
line := n + 2
|
line := n + 2
|
||||||
@@ -982,7 +1034,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
|||||||
if athleteName != "" {
|
if athleteName != "" {
|
||||||
idCard := cell("id_card")
|
idCard := cell("id_card")
|
||||||
bib := cell("bib")
|
bib := cell("bib")
|
||||||
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
|
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
|
||||||
if idCard == "" && bib == "" {
|
if idCard == "" && bib == "" {
|
||||||
result.Skipped++
|
result.Skipped++
|
||||||
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||||
@@ -994,14 +1046,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
|||||||
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
|
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if idCard != "" {
|
|
||||||
if seenIDCards[idCard] {
|
|
||||||
result.Skipped++
|
|
||||||
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenIDCards[idCard] = true
|
|
||||||
}
|
|
||||||
if bib != "" {
|
if bib != "" {
|
||||||
if seenBibs[bib] {
|
if seenBibs[bib] {
|
||||||
result.Skipped++
|
result.Skipped++
|
||||||
@@ -1023,13 +1067,16 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
|||||||
}, decisions)
|
}, decisions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Skipped++
|
result.Skipped++
|
||||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if matched.Conflict != nil {
|
if matched.Conflict != nil {
|
||||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if matched.SameNameNewPerson != nil {
|
||||||
|
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||||
|
}
|
||||||
athleteID = &matched.AthleteID
|
athleteID = &matched.AthleteID
|
||||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||||
teamID = matched.TeamID
|
teamID = matched.TeamID
|
||||||
|
|||||||
@@ -231,12 +231,12 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||||
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||||
env, ids, client := recorderWithSeed(t)
|
env, ids, client := recorderWithSeed(t)
|
||||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
if status != http.StatusOK || !env2.OK {
|
if status != http.StatusOK || !env2.OK {
|
||||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
@@ -285,8 +285,8 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一文件里证件号重复出现 → 该行报错并跳过。
|
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
|
||||||
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||||
env, _, client := recorderWithSeed(t)
|
env, _, client := recorderWithSeed(t)
|
||||||
|
|
||||||
body := importHeader + "\n" +
|
body := importHeader + "\n" +
|
||||||
@@ -297,12 +297,14 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
|||||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
}
|
}
|
||||||
result := decodeData[map[string]any](t, env2)
|
result := decodeData[map[string]any](t, env2)
|
||||||
if result["created"] != float64(1) || result["skipped"] != float64(1) {
|
if result["skipped"] != float64(0) {
|
||||||
t.Fatalf("首行应新建、次行应跳过: %+v", result)
|
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
|
||||||
}
|
}
|
||||||
errs, _ := result["errors"].([]any)
|
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
|
t.Fatalf("不应有行级报错,得到 %+v", errs)
|
||||||
t.Fatalf("应报证件号重复,得到 %+v", errs)
|
}
|
||||||
|
if result["created"] != float64(1) || result["updated"] != float64(1) {
|
||||||
|
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
var athletes int
|
var athletes int
|
||||||
@@ -310,7 +312,118 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
|||||||
t.Fatalf("统计人员: %v", err)
|
t.Fatalf("统计人员: %v", err)
|
||||||
}
|
}
|
||||||
if athletes != 1 {
|
if athletes != 1 {
|
||||||
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
|
t.Fatalf("同一个人只应建 1 个人员,得到 %d", athletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
|
||||||
|
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
|
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
|
||||||
|
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||||
|
t.Fatalf("撞号的证件号应跳过该行: %+v", result)
|
||||||
|
}
|
||||||
|
errs, _ := result["errors"].([]any)
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
|
||||||
|
}
|
||||||
|
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||||
|
if errs[0] != want {
|
||||||
|
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库里的张伟一行没动,也没有多出一个人。
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
bib string
|
||||||
|
count int
|
||||||
|
)
|
||||||
|
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
|
||||||
|
t.Fatalf("查询人员: %v", err)
|
||||||
|
}
|
||||||
|
if name != "张伟" || bib != "A100" {
|
||||||
|
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||||
|
}
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("统计人员: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("撞号不应新建人员,得到 %d 个", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
|
||||||
|
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||||
|
t.Fatalf("同名不同号码布应新建报名: %+v", result)
|
||||||
|
}
|
||||||
|
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||||
|
t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts)
|
||||||
|
}
|
||||||
|
reported, _ := result["sameNameNewPersons"].([]any)
|
||||||
|
if len(reported) != 1 {
|
||||||
|
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||||||
|
}
|
||||||
|
item := reported[0].(map[string]any)
|
||||||
|
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
|
||||||
|
t.Fatalf("同名新建报告字段不对: %+v", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
var athletes int
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
|
||||||
|
t.Fatalf("统计人员: %v", err)
|
||||||
|
}
|
||||||
|
if athletes != 2 {
|
||||||
|
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
|
||||||
|
}
|
||||||
|
// 原档案的号码布保持不动,新档案拿表里的号码布。
|
||||||
|
var oldBib string
|
||||||
|
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil {
|
||||||
|
t.Fatalf("查询原人员: %v", err)
|
||||||
|
}
|
||||||
|
if oldBib != "1001" {
|
||||||
|
t.Fatalf("原档案号码布不应被改,得到 %s", oldBib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。
|
||||||
|
func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
// 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。
|
||||||
|
env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||||
|
t.Fatalf("歧义行不落库也不跳过: %+v", result)
|
||||||
|
}
|
||||||
|
conflicts, _ := result["conflicts"].([]any)
|
||||||
|
if len(conflicts) != 1 {
|
||||||
|
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||||
|
}
|
||||||
|
if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) {
|
||||||
|
t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,9 +473,9 @@ func TestImportToleratesMissingOptionalColumns(t *testing.T) {
|
|||||||
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
|
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
|
||||||
func TestImportResolveKeepExisting(t *testing.T) {
|
func TestImportResolveKeepExisting(t *testing.T) {
|
||||||
env, ids, client := recorderWithSeed(t)
|
env, ids, client := recorderWithSeed(t)
|
||||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||||
|
|
||||||
// 未给决策时,冲突原样返回。
|
// 未给决策时,冲突原样返回。
|
||||||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
|
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
|
||||||
@@ -415,9 +528,9 @@ func TestImportResolveKeepExisting(t *testing.T) {
|
|||||||
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
|
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
|
||||||
func TestImportResolveKeepNew(t *testing.T) {
|
func TestImportResolveKeepNew(t *testing.T) {
|
||||||
env, ids, client := recorderWithSeed(t)
|
env, ids, client := recorderWithSeed(t)
|
||||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||||
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||||||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||||
if status != http.StatusOK || !env2.OK {
|
if status != http.StatusOK || !env2.OK {
|
||||||
@@ -618,44 +731,36 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
|
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
|
||||||
func TestImportConflictIncludesBibDiff(t *testing.T) {
|
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
|
||||||
|
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||||
env, ids, client := recorderWithSeed(t)
|
env, ids, client := recorderWithSeed(t)
|
||||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n"
|
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n"
|
||||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
if status != http.StatusOK || !env2.OK {
|
if status != http.StatusOK || !env2.OK {
|
||||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||||
}
|
}
|
||||||
result := decodeData[map[string]any](t, env2)
|
result := decodeData[map[string]any](t, env2)
|
||||||
conflicts, _ := result["conflicts"].([]any)
|
if result["skipped"] != float64(1) || result["created"] != float64(0) {
|
||||||
if len(conflicts) != 1 {
|
t.Fatalf("撞号应跳过该行: %+v", result)
|
||||||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
|
||||||
}
|
}
|
||||||
conflict := conflicts[0].(map[string]any)
|
errs, _ := result["errors"].([]any)
|
||||||
if conflict["bib"] != "A200" {
|
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||||
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
|
if len(errs) != 1 || errs[0] != want {
|
||||||
|
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||||
}
|
}
|
||||||
changed, _ := conflict["changedFields"].([]any)
|
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||||
if len(changed) != 1 || changed[0] != "bib" {
|
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
|
||||||
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
|
|
||||||
}
|
|
||||||
existing := conflict["existing"].(map[string]any)
|
|
||||||
if existing["bib"] != "A100" {
|
|
||||||
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
|
|
||||||
}
|
|
||||||
incoming := conflict["incoming"].(map[string]any)
|
|
||||||
if incoming["bib"] != "A200" {
|
|
||||||
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bib string
|
var athletes int
|
||||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||||||
t.Fatalf("查询人员号码布: %v", err)
|
t.Fatalf("统计人员: %v", err)
|
||||||
}
|
}
|
||||||
if bib != "A100" {
|
if athletes != 2 {
|
||||||
t.Fatalf("冲突不应改库中号码布,得到 %s", bib)
|
t.Fatalf("不应新建人员,得到 %d 个", athletes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1266,3 +1371,94 @@ func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
|
|||||||
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
|
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1),
|
||||||
|
// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。
|
||||||
|
// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。
|
||||||
|
func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
client := recorderClient(t, env)
|
||||||
|
|
||||||
|
boolField := func(t *testing.T, body envelope, field string) bool {
|
||||||
|
t.Helper()
|
||||||
|
got, ok := decodeData[map[string]any](t, body)[field].(bool)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库里也真的按接口回报的值落着,不只是响应体好看。
|
||||||
|
assertStoredFlags := func(t *testing.T, code string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
var scored, recordEligible int
|
||||||
|
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code).
|
||||||
|
Scan(&scored, &recordEligible); err != nil {
|
||||||
|
t.Fatalf("查询小项 %s 的开关: %v", code, err)
|
||||||
|
}
|
||||||
|
if scored != want || recordEligible != want {
|
||||||
|
t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"}
|
||||||
|
|
||||||
|
status, body := env.do(client, http.MethodPost, "/api/events", base, true)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
|
||||||
|
t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data)
|
||||||
|
}
|
||||||
|
assertStoredFlags(t, "E-BOOL", 1)
|
||||||
|
eventID := int64(decodeData[map[string]any](t, body)["id"].(float64))
|
||||||
|
|
||||||
|
// 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。
|
||||||
|
rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"}
|
||||||
|
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("改名应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
|
||||||
|
t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式 false 才关得掉:接口不能无视调用方的明确意图。
|
||||||
|
turnOff := map[string]any{
|
||||||
|
"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)",
|
||||||
|
"scored": false, "recordEligible": false,
|
||||||
|
}
|
||||||
|
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||||||
|
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反过来:库里是 false/false 时,更新漏传也不能把它打开。
|
||||||
|
rename["name"] = "开关缺省(改名三)"
|
||||||
|
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||||||
|
t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。
|
||||||
|
explicitOff := map[string]any{
|
||||||
|
"sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭",
|
||||||
|
"scored": false, "recordEligible": false,
|
||||||
|
}
|
||||||
|
status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||||||
|
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
|
||||||
|
}
|
||||||
|
assertStoredFlags(t, "E-BOOL-OFF", 0)
|
||||||
|
assertStoredFlags(t, "E-BOOL", 0)
|
||||||
|
}
|
||||||
@@ -63,6 +63,27 @@ func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。
|
||||||
|
func (s *server) settleResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
var in publishPayload
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||||
|
return s.settleResults(r.Context(), in, middlewareUserID(r))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// settlementStatusHandler 返回未结算的 (小项, 组别) 清单,供录入页提示与「立即结算」入口。
|
||||||
|
func (s *server) settlementStatusHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
eventID, _ := int64Query(r, "eventId")
|
||||||
|
if eventID == 0 {
|
||||||
|
return nil, badRequest("eventId 必填", nil)
|
||||||
|
}
|
||||||
|
categoryID, _ := int64Query(r, "categoryId")
|
||||||
|
return s.settlementStatus(r.Context(), eventID, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
id, err := pathID(r, "id")
|
id, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ type entryHeatPayload struct {
|
|||||||
RequestID string `json:"requestId"`
|
RequestID string `json:"requestId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entryBatchPayload 是报名项批量操作的请求体:
|
||||||
|
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
|
||||||
|
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
|
||||||
|
type entryBatchPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
|
||||||
type adjustmentInput struct {
|
type adjustmentInput struct {
|
||||||
TeamID int64 `json:"teamId"`
|
TeamID int64 `json:"teamId"`
|
||||||
Delta float64 `json:"delta"`
|
Delta float64 `json:"delta"`
|
||||||
@@ -117,12 +127,29 @@ type settingsStandingsPatch struct {
|
|||||||
RankingMode *string `json:"rankingMode"`
|
RankingMode *string `json:"rankingMode"`
|
||||||
ShowIndividualBoard *bool `json:"showIndividualBoard"`
|
ShowIndividualBoard *bool `json:"showIndividualBoard"`
|
||||||
ShowIndividualMedals *bool `json:"showIndividualMedals"`
|
ShowIndividualMedals *bool `json:"showIndividualMedals"`
|
||||||
|
// LiveOnPublish 是「分批发布时是否实时更新各单位总分与奖牌榜」;nil 表示不改动。
|
||||||
|
LiveOnPublish *bool `json:"liveOnPublish"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionSettingsPatch 是 PUT /api/system/session 的请求体:
|
||||||
|
// 只改「登录有效期(小时)」这一个系统级配置。
|
||||||
|
type sessionSettingsPatch struct {
|
||||||
|
SessionHours *int `json:"sessionHours"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type categoriesPayload struct {
|
type categoriesPayload struct {
|
||||||
Categories []categoryDTO `json:"categories"`
|
Categories []categoryDTO `json:"categories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// athletesBatchPayload 是运动员批量动作的请求体:action 决定停用还是彻底删除。
|
||||||
|
type athletesBatchPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
// DryRun 只统计不改库:前端用它先看清「会删掉多少、重算哪些小项」再确认。
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
}
|
||||||
|
|
||||||
type channelsPayload struct {
|
type channelsPayload struct {
|
||||||
Channels []channelDTO `json:"channels"`
|
Channels []channelDTO `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -645,6 +646,323 @@ func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause e
|
|||||||
return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
|
return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 运动员批量动作 ----------
|
||||||
|
|
||||||
|
const (
|
||||||
|
athleteBatchDisable = "disable"
|
||||||
|
athleteBatchPurge = "purge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// athleteBatchItem 是批量动作里逐条的结果。
|
||||||
|
//
|
||||||
|
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
|
||||||
|
// 静默跳过又会让他以为整批都成了,所以一条一条回报。
|
||||||
|
type athleteBatchItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。
|
||||||
|
// Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。
|
||||||
|
type athleteBatchResult struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
DryRun bool `json:"dryRun,omitempty"`
|
||||||
|
Requested int `json:"requested"`
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Items []athleteBatchItem `json:"items"`
|
||||||
|
Purge *athletePurge `json:"purge,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。
|
||||||
|
type purgeScope struct{ eventID, categoryID int64 }
|
||||||
|
|
||||||
|
func idArgs(ids []int64) []any {
|
||||||
|
args := make([]any, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。
|
||||||
|
func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) {
|
||||||
|
rows, err := q.QueryContext(ctx,
|
||||||
|
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
found := make(map[int64]bool, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
found[id] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(found))
|
||||||
|
for _, id := range ids {
|
||||||
|
if found[id] {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。
|
||||||
|
func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult {
|
||||||
|
ok := make(map[int64]bool, len(existing))
|
||||||
|
for _, id := range existing {
|
||||||
|
ok[id] = true
|
||||||
|
}
|
||||||
|
items := make([]athleteBatchItem, 0, len(ids))
|
||||||
|
result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge}
|
||||||
|
for _, id := range ids {
|
||||||
|
if ok[id] {
|
||||||
|
items = append(items, athleteBatchItem{ID: id, OK: true})
|
||||||
|
result.Succeeded++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"})
|
||||||
|
result.Failed++
|
||||||
|
}
|
||||||
|
result.Items = items
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。
|
||||||
|
//
|
||||||
|
// 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流;
|
||||||
|
// 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。
|
||||||
|
// dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。
|
||||||
|
func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) {
|
||||||
|
switch action {
|
||||||
|
case athleteBatchDisable, athleteBatchPurge:
|
||||||
|
default:
|
||||||
|
return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action)
|
||||||
|
}
|
||||||
|
ids := dedupeIDs(rawIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
|
||||||
|
}
|
||||||
|
if action == athleteBatchPurge {
|
||||||
|
return s.batchPurgeAthletes(ctx, ids, dryRun)
|
||||||
|
}
|
||||||
|
return s.batchDisableAthletes(ctx, ids, dryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0,
|
||||||
|
// 报名、成绩、流水一行不动,也不触发任何重算。
|
||||||
|
func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
|
||||||
|
existing, err := existingAthleteIDs(ctx, s.ev, ids)
|
||||||
|
if err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
if !dryRun && len(existing) > 0 {
|
||||||
|
if _, err := s.ev.ExecContext(ctx,
|
||||||
|
`UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`,
|
||||||
|
idArgs(existing)...); err != nil {
|
||||||
|
return athleteBatchResult{}, internal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录,
|
||||||
|
// 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。
|
||||||
|
//
|
||||||
|
// 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。
|
||||||
|
// 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。
|
||||||
|
func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
|
||||||
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return athleteBatchResult{}, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务,
|
||||||
|
// 而且「已被别人删掉」和「id 不存在」本来就该一起回报。
|
||||||
|
existing, err := existingAthleteIDs(ctx, tx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
// 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。
|
||||||
|
scopes, err := purgeScopesTx(ctx, tx, existing)
|
||||||
|
if err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
counts, err := deleteAthletesTx(ctx, tx, existing)
|
||||||
|
if err != nil {
|
||||||
|
var api *apiError
|
||||||
|
if errors.As(err, &api) {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
purge := &athletePurge{
|
||||||
|
DeletedEntries: counts.DeletedEntries,
|
||||||
|
DeletedResults: counts.DeletedResults,
|
||||||
|
DeletedLedger: counts.DeletedLedger,
|
||||||
|
DeletedBreaks: counts.DeletedBreaks,
|
||||||
|
}
|
||||||
|
eventIDs := make([]int64, 0, len(scopes))
|
||||||
|
for _, sc := range scopes {
|
||||||
|
if !slices.Contains(eventIDs, sc.eventID) {
|
||||||
|
eventIDs = append(eventIDs, sc.eventID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
purge.RecomputedEvent = eventIDs
|
||||||
|
if dryRun {
|
||||||
|
// 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。
|
||||||
|
return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := middleware.User(ctx).ID
|
||||||
|
for _, sc := range scopes {
|
||||||
|
score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算")
|
||||||
|
if err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, eventID := range eventIDs {
|
||||||
|
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(eventIDs) > 0 {
|
||||||
|
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||||
|
return athleteBatchResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
|
||||||
|
}
|
||||||
|
return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。
|
||||||
|
func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx,
|
||||||
|
`SELECT DISTINCT e.event_id, e.category_id
|
||||||
|
FROM results r JOIN entries e ON e.id = r.entry_id
|
||||||
|
WHERE e.athlete_id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make([]purgeScope, 0, 4)
|
||||||
|
for rows.Next() {
|
||||||
|
var sc purgeScope
|
||||||
|
if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
out = append(out, sc)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。
|
||||||
|
// 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。
|
||||||
|
func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) {
|
||||||
|
var out athletePurge
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
ph := placeholders(len(ids))
|
||||||
|
args := idArgs(ids)
|
||||||
|
// score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。
|
||||||
|
ledgerArgs := append(append(append([]any{}, args...), args...), args...)
|
||||||
|
|
||||||
|
for _, step := range []struct {
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
count *int
|
||||||
|
}{
|
||||||
|
{`DELETE FROM score_ledger
|
||||||
|
WHERE athlete_id IN (` + ph + `)
|
||||||
|
OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))
|
||||||
|
OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id
|
||||||
|
WHERE e.athlete_id IN (` + ph + `))`,
|
||||||
|
ledgerArgs, &out.DeletedLedger},
|
||||||
|
{`DELETE FROM record_breaks
|
||||||
|
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
|
||||||
|
args, &out.DeletedBreaks},
|
||||||
|
{`DELETE FROM results
|
||||||
|
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
|
||||||
|
args, &out.DeletedResults},
|
||||||
|
{`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries},
|
||||||
|
} {
|
||||||
|
res, err := tx.ExecContext(ctx, step.query, step.args...)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return out, internal(err)
|
||||||
|
}
|
||||||
|
*step.count = int(n)
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return out, internal(err)
|
||||||
|
}
|
||||||
|
// 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态,
|
||||||
|
// 用 409 明说并让整个事务回滚,别把成绩删了却报成功。
|
||||||
|
if int(n) != len(ids) {
|
||||||
|
return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。
|
||||||
|
func (s *server) batchPurgeBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
|
||||||
|
if !isForeignKeyViolation(cause) {
|
||||||
|
return internal(cause)
|
||||||
|
}
|
||||||
|
s.logger.WarnContext(ctx, "批量彻底删除运动员撞上外键约束", "err", cause)
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return conflict("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。")
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。
|
||||||
|
func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
var in athletesBatchPayload
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。
|
||||||
|
if result.DryRun {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
// 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。
|
||||||
|
// 逐条明细与失败原因都进 after,事后能对着名单查。
|
||||||
|
s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- sports ----------
|
// ---------- sports ----------
|
||||||
|
|
||||||
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
|
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
@@ -302,6 +304,9 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD
|
|||||||
|
|
||||||
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
|
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
|
||||||
// 否则外键会把历史报名项一并带走。
|
// 否则外键会把历史报名项一并带走。
|
||||||
|
//
|
||||||
|
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
|
||||||
|
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
|
||||||
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
|
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
|
||||||
has, err := exists(ctx, s.ev,
|
has, err := exists(ctx, s.ev,
|
||||||
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
|
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
|
||||||
@@ -310,7 +315,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
|||||||
return nil, internal(err)
|
return nil, internal(err)
|
||||||
}
|
}
|
||||||
if has {
|
if has {
|
||||||
return nil, conflict("该项目已有报名数据,不能整体替换组别")
|
return nil, conflict("该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存")
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.ev.BeginTx(ctx, nil)
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
@@ -345,6 +350,168 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
|||||||
return s.listCategories(ctx, eventID)
|
return s.listCategories(ctx, eventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCategory 读单个组别,并校验它属于该小项。不属于(或用别的小项 id 来探)
|
||||||
|
// 一律按「组别不存在」处理,不给跨小项改名的机会。
|
||||||
|
func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (categoryDTO, error) {
|
||||||
|
var (
|
||||||
|
c categoryDTO
|
||||||
|
gender sql.NullString
|
||||||
|
ageMin, ageMax sql.NullInt64
|
||||||
|
)
|
||||||
|
err := s.ev.QueryRowContext(ctx,
|
||||||
|
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
|
||||||
|
FROM event_categories WHERE id = ? AND event_id = ?`, categoryID, eventID).
|
||||||
|
Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return categoryDTO{}, notFound("组别不存在")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return categoryDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
c.Gender = strPtr(gender)
|
||||||
|
c.AgeMin = intPtr(ageMin)
|
||||||
|
c.AgeMax = intPtr(ageMax)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
|
||||||
|
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
|
||||||
|
func validateCategoryAges(ageMin, ageMax *int) error {
|
||||||
|
for _, v := range []*int{ageMin, ageMax} {
|
||||||
|
if v != nil && (*v < 0 || *v > 120) {
|
||||||
|
return badRequest("组别年龄需要在 0-120 之间", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ageMin != nil && ageMax != nil && *ageMin > *ageMax {
|
||||||
|
return badRequest("组别年龄下限不能大于上限", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
|
||||||
|
//
|
||||||
|
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
|
||||||
|
// 用 == 会把「原样提交」也当成改动、白重算一次全组积分。1e-9 远小于现场能填出的最小差
|
||||||
|
// (权重最小刻度 0.1),又远大于浮点噪声。
|
||||||
|
const multiplierRecomputeEpsilon = 1e-9
|
||||||
|
|
||||||
|
func multiplierChanged(before, after float64) bool {
|
||||||
|
return math.Abs(before-after) > multiplierRecomputeEpsilon
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCategory 就地更新单个组别,可改 name / multiplier / gender / ageMin / ageMax / sortOrder。
|
||||||
|
//
|
||||||
|
// 与 replaceCategories 的「DELETE 全表 + INSERT」不同,这里只发一条 UPDATE:组别 id 不变,
|
||||||
|
// entries.category_id 以及它名下的成绩、流水、名次、积分全部原地保留。
|
||||||
|
// 有报名数据的小项正是靠它改名 / 改年龄 / 改性别 / 改权重 / 改排序;增删组别仍然只能走整表替换。
|
||||||
|
//
|
||||||
|
// 只有 multiplier 真的变了才在同一个事务里重算该组别的流水与名次:权重进算分公式,
|
||||||
|
// 不重算就会让已发布成绩的积分与榜单和配置静默不一致。name / gender / ageMin / ageMax /
|
||||||
|
// sortOrder 都不参与算分,改了不重算(报名、成绩、流水一个字节都不动)。
|
||||||
|
// 第二个返回值报告本次是否触发了重算,供审计留痕。
|
||||||
|
func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64, in categoryDTO) (categories []categoryDTO, recomputed bool, err error) {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, false, badRequest("组别 name 必填", nil)
|
||||||
|
}
|
||||||
|
if in.Gender != nil && *in.Gender != "" && *in.Gender != "男" && *in.Gender != "女" {
|
||||||
|
return nil, false, badRequest("组别 gender 只能是 男 / 女", in.Gender)
|
||||||
|
}
|
||||||
|
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
// 先读旧值:要不要重算全靠这次比对,拿不到旧值就无从判断。
|
||||||
|
before, err := s.getCategory(ctx, eventID, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
newMultiplier := multiplierOrOne(in.Multiplier)
|
||||||
|
recompute := multiplierChanged(before.Multiplier, newMultiplier)
|
||||||
|
|
||||||
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// event_id 一起进 WHERE:id 存在但挂在别的小项上时影响 0 行,按「组别不存在」处理。
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE event_categories SET name = ?, multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ?
|
||||||
|
WHERE id = ? AND event_id = ?`,
|
||||||
|
name, newMultiplier, nullStr(in.Gender), nullInt(in.AgeMin), nullInt(in.AgeMax),
|
||||||
|
in.SortOrder, categoryID, eventID)
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return nil, false, conflict("组别名称重复:" + name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, internal(err)
|
||||||
|
}
|
||||||
|
if err := requireChanges(res, "组别不存在"); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := middleware.User(ctx).ID
|
||||||
|
if recompute {
|
||||||
|
// 与新权重保持一致:作废旧流水、按新分数重建,并把名次写回 results(榜单看的就是它)。
|
||||||
|
score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "组别权重变更重算")
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if recompute {
|
||||||
|
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, false, internal(err)
|
||||||
|
}
|
||||||
|
cats, err := s.listCategories(ctx, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return cats, recompute, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCategoryHandler 是 PUT /api/events/{id}/categories/{categoryId}:组别的就地更新。
|
||||||
|
func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
eventID, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
categoryID, err := pathID(r, "categoryId")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var in categoryDTO
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
before, err := s.getCategory(r.Context(), eventID, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated, recomputed, err := s.updateCategory(r.Context(), eventID, categoryID, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
after, err := s.getCategory(r.Context(), eventID, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 审计要能看出这次是不是还动了权重:权重变更会连带重算已发布成绩的积分与榜单,
|
||||||
|
// 事后查「榜为什么变了」时,光看新旧权重容易漏掉这一层。
|
||||||
|
s.audit(r.Context(), "event.category.update", "event_category", categoryID, before,
|
||||||
|
map[string]any{"category": after, "recomputedLedger": recomputed})
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- score channels ----------
|
// ---------- score channels ----------
|
||||||
|
|
||||||
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
|
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
|
||||||
@@ -35,10 +36,21 @@ func (in athleteImportRow) decisionKey() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
||||||
|
// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。
|
||||||
type athleteImportMatch struct {
|
type athleteImportMatch struct {
|
||||||
AthleteID int64
|
AthleteID int64
|
||||||
TeamID int64
|
TeamID int64
|
||||||
Conflict *importConflict
|
Conflict *importConflict
|
||||||
|
SameNameNewPerson *sameNameNewPerson
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameNameNewPerson 是「号码布不同 → 按同名的另一个人新建」的清单项:
|
||||||
|
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
|
||||||
|
type sameNameNewPerson struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TeamCode string `json:"teamCode"`
|
||||||
|
NewBib string `json:"newBib"`
|
||||||
|
ExistingBib string `json:"existingBib"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// existingAthlete 是库中已有人员的可比字段。
|
// existingAthlete 是库中已有人员的可比字段。
|
||||||
@@ -53,35 +65,136 @@ type existingAthlete struct {
|
|||||||
Age *int
|
Age *int
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。
|
// matchImportedAthlete 按新口径匹配人员:号码布 → 姓名 + 单位 → 新建。
|
||||||
|
// 号码布是单人唯一标识,证件号不参与匹配(只作为可选档案信息写入/更新)。
|
||||||
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
|
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
|
||||||
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||||
existing, err := s.athleteByIdentity(ctx, in)
|
if in.Bib != "" {
|
||||||
switch {
|
ex, err := s.athleteByBib(ctx, in.Bib)
|
||||||
case err == nil:
|
switch {
|
||||||
return s.matchExistingAthlete(ctx, in, existing, decisions)
|
case err == nil:
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||||
return s.matchOrCreateAthleteByName(ctx, in, decisions)
|
case !errors.Is(err, sql.ErrNoRows):
|
||||||
default:
|
return athleteImportMatch{}, internal(err)
|
||||||
return athleteImportMatch{}, internal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。
|
|
||||||
func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) {
|
|
||||||
if in.IDCard != "" {
|
|
||||||
ex, err := s.athleteByIDCard(ctx, in.IDCard)
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return ex, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if in.Bib != "" {
|
return s.matchByNameTeam(ctx, in, decisions)
|
||||||
return s.athleteByBib(ctx, in.Bib)
|
|
||||||
}
|
|
||||||
return existingAthlete{}, sql.ErrNoRows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段,
|
// matchByNameTeam 处理「号码布为空、或按号码布没找到」的情况,按姓名 + 单位找候选:
|
||||||
|
// 0 条 → 新建;1 条且对方没有号码布 → 命中并回填;1 条但对方号码布不同 → 同名的另一个人,新建并报告;
|
||||||
|
// 多条 → 仍有歧义,进冲突面板等用户决策。
|
||||||
|
func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||||
|
candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID)
|
||||||
|
if err != nil {
|
||||||
|
return athleteImportMatch{}, internal(err)
|
||||||
|
}
|
||||||
|
switch len(candidates) {
|
||||||
|
case 0:
|
||||||
|
return s.createImportedAthlete(ctx, in)
|
||||||
|
case 1:
|
||||||
|
ex := candidates[0]
|
||||||
|
// 只有表里也给了号码布、且与库里不同时才算「同名的另一个人」:
|
||||||
|
// 表里没给号码布只是「未指定」,不能凭空多建一个没有号码布的同名人。
|
||||||
|
if ex.Bib != nil && *ex.Bib != "" && in.Bib != "" && *ex.Bib != in.Bib {
|
||||||
|
created, err := s.createImportedAthlete(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return athleteImportMatch{}, err
|
||||||
|
}
|
||||||
|
created.SameNameNewPerson = &sameNameNewPerson{
|
||||||
|
Name: in.Name,
|
||||||
|
TeamCode: in.TeamCode,
|
||||||
|
NewBib: in.Bib,
|
||||||
|
ExistingBib: *ex.Bib,
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||||
|
default:
|
||||||
|
return s.matchAmbiguousCandidates(ctx, in, candidates, decisions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。
|
||||||
|
// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选,
|
||||||
|
// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。
|
||||||
|
func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportRow, candidates []existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||||
|
ex := candidates[0]
|
||||||
|
if _, decided := decisions[in.decisionKey()]; decided {
|
||||||
|
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||||
|
}
|
||||||
|
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in))
|
||||||
|
if err != nil {
|
||||||
|
return athleteImportMatch{}, err
|
||||||
|
}
|
||||||
|
return athleteImportMatch{Conflict: &conflict}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createImportedAthlete 新建人员:证件号只作为可选档案信息写入,
|
||||||
|
// 但它与库里另一个人撞号时仍然明确报错(id_card 的部分唯一索引保留不动,防的是录入错误)。
|
||||||
|
func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
|
||||||
|
if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil {
|
||||||
|
return athleteImportMatch{}, err
|
||||||
|
}
|
||||||
|
res, err := s.ev.ExecContext(ctx,
|
||||||
|
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||||
|
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
|
||||||
|
switch {
|
||||||
|
case isIDCardConflict(err):
|
||||||
|
// 并发下唯一索引兜底:再查一次占用者,把「撞上谁」说清楚。
|
||||||
|
if taken := s.idCardTakenError(ctx, in.IDCard, 0); taken != nil {
|
||||||
|
return athleteImportMatch{}, taken
|
||||||
|
}
|
||||||
|
return athleteImportMatch{}, internal(err)
|
||||||
|
case isBibConflict(err):
|
||||||
|
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
|
||||||
|
case err != nil:
|
||||||
|
return athleteImportMatch{}, internal(err)
|
||||||
|
}
|
||||||
|
id, err := lastInsertID(res)
|
||||||
|
if err != nil {
|
||||||
|
return athleteImportMatch{}, internal(err)
|
||||||
|
}
|
||||||
|
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。
|
||||||
|
// 证件号不参与匹配,但库里一个证件号只能属于一个人(唯一索引保留):撞号时必须报清楚占用者是谁,
|
||||||
|
// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。
|
||||||
|
func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error {
|
||||||
|
if idCard == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
bib sql.NullString
|
||||||
|
)
|
||||||
|
err := s.ev.QueryRowContext(ctx,
|
||||||
|
`SELECT name, bib FROM athletes WHERE id_card = ? AND id <> ? ORDER BY id LIMIT 1`, idCard, excludeID).
|
||||||
|
Scan(&name, &bib)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return internal(err)
|
||||||
|
}
|
||||||
|
occupied := "未填"
|
||||||
|
if bib.Valid && bib.String != "" {
|
||||||
|
occupied = bib.String
|
||||||
|
}
|
||||||
|
return conflict(fmt.Sprintf("证件号 %s 已被另一个人占用(姓名 %s,号码布 %s)", idCard, name, occupied))
|
||||||
|
}
|
||||||
|
|
||||||
|
// importRowMessage 把业务错误转成行级提示:去掉「CONFLICT:」这类 code 前缀,只留中文 message。
|
||||||
|
func importRowMessage(err error) string {
|
||||||
|
var api *apiError
|
||||||
|
if errors.As(err, &api) && api.message != "" {
|
||||||
|
return api.message
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchExistingAthlete 处理号码布或姓名 + 单位命中的人员:无差异时直接复用并补空字段,
|
||||||
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
|
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
|
||||||
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||||
changed := changedAthleteFields(ex, in)
|
changed := changedAthleteFields(ex, in)
|
||||||
@@ -110,30 +223,6 @@ func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况:
|
|
||||||
// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。
|
|
||||||
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
|
||||||
existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
return s.matchExistingAthlete(ctx, in, existing, decisions)
|
|
||||||
case !errors.Is(err, sql.ErrNoRows):
|
|
||||||
return athleteImportMatch{}, internal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := s.ev.ExecContext(ctx,
|
|
||||||
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
|
||||||
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
|
|
||||||
if err != nil {
|
|
||||||
return athleteImportMatch{}, internal(err)
|
|
||||||
}
|
|
||||||
id, err := lastInsertID(res)
|
|
||||||
if err != nil {
|
|
||||||
return athleteImportMatch{}, internal(err)
|
|
||||||
}
|
|
||||||
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
|
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
|
||||||
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
|
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
|
||||||
|
|
||||||
@@ -155,22 +244,30 @@ func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete,
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
|
|
||||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
|
||||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
|
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
|
||||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
|
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
|
||||||
}
|
}
|
||||||
|
|
||||||
// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。
|
// athletesByNameTeam 按「姓名 + 单位」列出全部候选,供调用方按候选数量决定是回填、新建还是进冲突面板。
|
||||||
func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) {
|
// 证件号不参与匹配,所以这里不再过滤证件号为空的人。
|
||||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
func (s *server) athletesByNameTeam(ctx context.Context, name string, teamID int64) ([]existingAthlete, error) {
|
||||||
`SELECT `+importAthleteColumns+` FROM athletes
|
rows, err := s.ev.QueryContext(ctx,
|
||||||
WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
|
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id = ? ORDER BY id`, name, teamID)
|
||||||
ORDER BY id LIMIT 1`, name, teamID))
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make([]existingAthlete, 0, 1)
|
||||||
|
for rows.Next() {
|
||||||
|
ex, err := scanExistingAthlete(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, ex)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
|
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
|
||||||
@@ -208,7 +305,15 @@ func identityDiffers(dbVal *string, inVal string) bool {
|
|||||||
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
|
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
|
||||||
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
|
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
|
||||||
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
|
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
|
||||||
|
if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
|
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
|
||||||
|
if isIDCardConflict(err) {
|
||||||
|
if taken := s.idCardTakenError(ctx, in.IDCard, ex.ID); taken != nil {
|
||||||
|
return taken
|
||||||
|
}
|
||||||
|
}
|
||||||
return internal(err)
|
return internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,10 +366,23 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
|
|||||||
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
|
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
|
||||||
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
|
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
|
||||||
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
|
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
|
||||||
|
if in.IDCard != "" {
|
||||||
|
if err := s.idCardTakenError(ctx, in.IDCard, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := s.ev.ExecContext(ctx,
|
if _, err := s.ev.ExecContext(ctx,
|
||||||
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
|
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
|
||||||
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
|
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
|
||||||
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
|
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
|
||||||
|
switch {
|
||||||
|
case isIDCardConflict(err):
|
||||||
|
if taken := s.idCardTakenError(ctx, in.IDCard, id); taken != nil {
|
||||||
|
return taken
|
||||||
|
}
|
||||||
|
case isBibConflict(err):
|
||||||
|
return conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
|
||||||
|
}
|
||||||
return internal(err)
|
return internal(err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||||
@@ -203,6 +205,11 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
|||||||
return dto.EventSettings{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.LiveOnPublish != nil {
|
||||||
|
if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil {
|
||||||
|
return dto.EventSettings{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if patch.DefaultProfileID != nil {
|
if patch.DefaultProfileID != nil {
|
||||||
code := ""
|
code := ""
|
||||||
@@ -263,6 +270,104 @@ func boolDefault(v string, def bool) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 系统级设置:登录有效期 ----------
|
||||||
|
//
|
||||||
|
// 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的
|
||||||
|
// /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性,
|
||||||
|
// 切换到另一个赛事项目时不该跟着变。
|
||||||
|
|
||||||
|
func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
return s.sessionSettings(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
var in sessionSettingsPatch
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.SessionHours == nil {
|
||||||
|
return nil, badRequest("登录有效期 sessionHours 必填", nil)
|
||||||
|
}
|
||||||
|
hours := *in.SessionHours
|
||||||
|
if hours < auth.MinSessionHours || hours > auth.MaxSessionHours {
|
||||||
|
return nil, badRequest(
|
||||||
|
fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours)
|
||||||
|
}
|
||||||
|
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||||
|
if err := s.writeSessionHours(r.Context(), hours); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.audit(r.Context(), "settings.session_hours", "system", 0, nil,
|
||||||
|
map[string]any{"sessionHours": hours})
|
||||||
|
return s.sessionSettings(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。
|
||||||
|
func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) {
|
||||||
|
values, err := loadSettingsMap(r.Context(), s.sys)
|
||||||
|
if err != nil {
|
||||||
|
return dto.SessionSettings{}, err
|
||||||
|
}
|
||||||
|
return dto.SessionSettings{
|
||||||
|
SessionHours: auth.EffectiveSessionHours(values),
|
||||||
|
DefaultHours: auth.DefaultSessionHours,
|
||||||
|
MinHours: auth.MinSessionHours,
|
||||||
|
MaxHours: auth.MaxSessionHours,
|
||||||
|
ExpiresAt: s.sessionExpiresAt(r),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。
|
||||||
|
// 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。
|
||||||
|
func (s *server) sessionExpiresAt(r *http.Request) *string {
|
||||||
|
c, err := r.Cookie(auth.CookieSession)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
|
||||||
|
if err != nil || sess == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := sess.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSessionHours 落库登录有效期并立刻在进程内生效。
|
||||||
|
// 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。
|
||||||
|
func (s *server) writeSessionHours(ctx context.Context, hours int) error {
|
||||||
|
tx, err := s.sys.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
upsert := func(key, value string) error {
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||||
|
key, value)
|
||||||
|
if err != nil {
|
||||||
|
return internal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值,
|
||||||
|
// 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。
|
||||||
|
if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return internal(err)
|
||||||
|
}
|
||||||
|
// 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。
|
||||||
|
s.auth.SetSessionTTL(time.Duration(hours) * time.Hour)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 大屏公告 ----------
|
// ---------- 大屏公告 ----------
|
||||||
|
|
||||||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||||||
@@ -340,6 +341,22 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
|||||||
// 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id
|
// 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id
|
||||||
// (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。
|
// (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。
|
||||||
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
|
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
|
||||||
|
score, err := computeEventScore(ctx, tx, eventID, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EventScore{}, err
|
||||||
|
}
|
||||||
|
if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil {
|
||||||
|
return domain.EventScore{}, err
|
||||||
|
}
|
||||||
|
return score, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。
|
||||||
|
//
|
||||||
|
// 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落,
|
||||||
|
// 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger,
|
||||||
|
// 所以「先算后作废」与老的「先作废后算」结果完全一致。
|
||||||
|
func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error {
|
||||||
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
|
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
|
||||||
args := []any{eventID}
|
args := []any{eventID}
|
||||||
if categoryID > 0 {
|
if categoryID > 0 {
|
||||||
@@ -351,25 +368,21 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
|||||||
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
|
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
|
||||||
WHERE voided = 0 AND `+scope,
|
WHERE voided = 0 AND `+scope,
|
||||||
append([]any{nullID(actorID), reason}, args...)...); err != nil {
|
append([]any{nullID(actorID), reason}, args...)...); err != nil {
|
||||||
return domain.EventScore{}, internal(err)
|
return internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
score, err := computeEventScore(ctx, tx, eventID, categoryID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EventScore{}, err
|
|
||||||
}
|
|
||||||
ledger := score.Ledger
|
ledger := score.Ledger
|
||||||
if categoryID > 0 {
|
if categoryID > 0 {
|
||||||
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
||||||
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
||||||
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.EventScore{}, err
|
return err
|
||||||
}
|
}
|
||||||
if hasAggregate {
|
if hasAggregate {
|
||||||
all, err := computeEventScore(ctx, tx, eventID, 0)
|
all, err := computeEventScore(ctx, tx, eventID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.EventScore{}, err
|
return err
|
||||||
}
|
}
|
||||||
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
||||||
for _, l := range score.Ledger {
|
for _, l := range score.Ledger {
|
||||||
@@ -390,10 +403,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
|
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
|
||||||
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
|
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
|
||||||
return domain.EventScore{}, internal(err)
|
return internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return score, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
||||||
@@ -445,6 +458,106 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
|||||||
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
|
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
|
||||||
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
|
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
|
||||||
|
|
||||||
|
// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。
|
||||||
|
// 两处判据必须同源:SQL 那份决定哪些行能被发布,这份决定「这一条算不算已经出成绩」。
|
||||||
|
func publishableResult(r domain.Result) bool {
|
||||||
|
return r.ResultValue != nil || r.Rank > 0 || r.Status != domain.ResultStatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupStats 统计某组「该出成绩的报名项数」与「已经有可发布成绩的项数」。
|
||||||
|
//
|
||||||
|
// 非 registered 的报名项(scratched / dq / dns)本来就不该等它出成绩:
|
||||||
|
// 计分把非 registered 视为 0 分且不占名次,等它只会让整组永远结算不了。
|
||||||
|
func groupStats(entries []domain.Entry, results []domain.Result) (total, published int) {
|
||||||
|
byEntry := make(map[int64]domain.Result, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
byEntry[r.EntryID] = r
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Status != domain.EntryStatusRegistered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
if r, ok := byEntry[e.ID]; ok && r.State == domain.ResultStatePublished && publishableResult(r) {
|
||||||
|
published++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, published
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupOf 从整项的行里挑出某个组别的报名与成绩。
|
||||||
|
// 成绩表本身不带组别,只能靠报名项 id 反查,所以先收组内报名 id 再过滤成绩。
|
||||||
|
func groupOf(entries []domain.Entry, results []domain.Result, categoryID int64) ([]domain.Entry, []domain.Result) {
|
||||||
|
inGroup := make(map[int64]bool)
|
||||||
|
groupEntries := make([]domain.Entry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.CategoryID != categoryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inGroup[e.ID] = true
|
||||||
|
groupEntries = append(groupEntries, e)
|
||||||
|
}
|
||||||
|
groupResults := make([]domain.Result, 0, len(groupEntries))
|
||||||
|
for _, r := range results {
|
||||||
|
if inGroup[r.EntryID] {
|
||||||
|
groupResults = append(groupResults, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groupEntries, groupResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeStats 按发布 / 结算的作用域累加 groupStats:categoryID > 0 只看该组别,
|
||||||
|
// categoryID = 0 看整项(逐组别累加,任何一组没录完都算没录完)。
|
||||||
|
func scopeStats(ctx context.Context, q queryer, eventID, categoryID int64) (total, published int, err error) {
|
||||||
|
entries, err := loadEntriesQ(ctx, q, eventID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
results, err := loadResultsQ(ctx, q, eventID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := []int64{categoryID}
|
||||||
|
if categoryID == 0 {
|
||||||
|
cats, err := loadCategoriesQ(ctx, q, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
categories = make([]int64, 0, len(cats))
|
||||||
|
for _, cat := range cats {
|
||||||
|
categories = append(categories, cat.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range categories {
|
||||||
|
groupEntries, groupResults := groupOf(entries, results, id)
|
||||||
|
t, p := groupStats(groupEntries, groupResults)
|
||||||
|
total += t
|
||||||
|
published += p
|
||||||
|
}
|
||||||
|
return total, published, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeSettled 判断作用域内是否「所有批次都录完」,即可以结算。
|
||||||
|
// 没有报名项(total = 0)视为录完:没有要等的东西,结算一次把范围清干净。
|
||||||
|
func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bool, error) {
|
||||||
|
total, published, err := scopeStats(ctx, q, eventID, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return published == total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
|
||||||
|
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
|
||||||
|
var raw string
|
||||||
|
err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, dto.SettingKeyLiveOnPublish).Scan(&raw)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, internal(err)
|
||||||
|
}
|
||||||
|
return boolDefault(raw, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||||
if in.EventID == 0 {
|
if in.EventID == 0 {
|
||||||
return nil, badRequest("eventId 必填", nil)
|
return nil, badRequest("eventId 必填", nil)
|
||||||
@@ -470,7 +583,8 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
|||||||
|
|
||||||
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
|
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
|
||||||
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
|
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
|
||||||
// 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。
|
// 判据见 publishableResultCond。名次与积分是否当场落库,取决于「发布后是否立即结算」
|
||||||
|
// (见下方 liveOnPublishQ):实时模式按已发布部分重算,后续再发一批会自动纳入。
|
||||||
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
|
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE event_id = ? AND ` + publishableResultCond
|
WHERE event_id = ? AND ` + publishableResultCond
|
||||||
@@ -483,12 +597,33 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
|||||||
return nil, internal(err)
|
return nil, internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算")
|
live, err := liveOnPublishQ(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
settle := live
|
||||||
return nil, err
|
if !live {
|
||||||
|
// 延迟结算:等该 (小项, 组别) 所有批次都录完才结算一次。
|
||||||
|
// 结算时按整组重算,之前各批的成绩一起纳入,避免名次停留在分批算的口径。
|
||||||
|
settle, err = scopeSettled(ctx, tx, in.EventID, catID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settle {
|
||||||
|
// 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked),
|
||||||
|
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
||||||
|
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
|
||||||
|
score, err := computeEventScore(ctx, tx, in.EventID, catID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if catID == 0 {
|
if catID == 0 {
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
|
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
|
||||||
@@ -506,8 +641,11 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
|||||||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
|
||||||
return nil, err
|
if settle {
|
||||||
|
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, internal(err)
|
return nil, internal(err)
|
||||||
@@ -563,6 +701,9 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
|
|||||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||||
return nil, internal(err)
|
return nil, internal(err)
|
||||||
}
|
}
|
||||||
|
// 撤销发布是回滚动作,延迟结算也要立刻重算:这一组已经没有已发布成绩,
|
||||||
|
// 留着上一次结算写下的流水就是「榜上有分、成绩却撤了」。重算本身会把旧流水作废、
|
||||||
|
// 按当前已发布内容重写,之后再次发布时判据回到「未结算」,行为自然衔接。
|
||||||
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
|
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -643,12 +784,28 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
|||||||
return resultDTO{}, internal(err)
|
return resultDTO{}, internal(err)
|
||||||
}
|
}
|
||||||
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
||||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
|
// withdrawn 仍是「可发布成绩」,作废不会让该组变成没录完,所以录完过的组照样能结算;
|
||||||
if err != nil {
|
// 没录完的组按延迟结算处理:名次与流水都等结算,只把作废这件事落库。
|
||||||
|
settle := true
|
||||||
|
if live, err := liveOnPublishQ(ctx, tx); err != nil {
|
||||||
return resultDTO{}, err
|
return resultDTO{}, err
|
||||||
|
} else if !live {
|
||||||
|
settle, err = scopeSettled(ctx, tx, eventID, catID)
|
||||||
|
if err != nil {
|
||||||
|
return resultDTO{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
if settle {
|
||||||
return resultDTO{}, err
|
score, err := computeEventScore(ctx, tx, eventID, catID)
|
||||||
|
if err != nil {
|
||||||
|
return resultDTO{}, err
|
||||||
|
}
|
||||||
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
|
return resultDTO{}, err
|
||||||
|
}
|
||||||
|
if err := writeLedgerTx(ctx, tx, score, eventID, catID, userID, "作废成绩:"+reason); err != nil {
|
||||||
|
return resultDTO{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
||||||
@@ -658,8 +815,10 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
|||||||
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
|
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
|
||||||
return resultDTO{}, err
|
return resultDTO{}, err
|
||||||
}
|
}
|
||||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
if settle {
|
||||||
return resultDTO{}, err
|
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||||
|
return resultDTO{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return resultDTO{}, internal(err)
|
return resultDTO{}, internal(err)
|
||||||
@@ -669,6 +828,119 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
|||||||
return s.getResult(ctx, resultID)
|
return s.getResult(ctx, resultID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 分批结算 ----------
|
||||||
|
|
||||||
|
// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
|
||||||
|
// Required / Published 只数「该出成绩的报名项」,弃权 / 失格不计入,与结算判据同源。
|
||||||
|
type settlementGroupDTO struct {
|
||||||
|
EventID int64 `json:"eventId"`
|
||||||
|
CategoryID int64 `json:"categoryId"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
Required int `json:"required"`
|
||||||
|
Published int `json:"published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// settlementStatusDTO 是 GET /api/results/settlement 的线上结构。
|
||||||
|
type settlementStatusDTO struct {
|
||||||
|
LiveOnPublish bool `json:"liveOnPublish"`
|
||||||
|
Groups []settlementGroupDTO `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// settlementStatus 列出范围内尚未结算的组别,供录入页提示「榜单还没反映这批」。
|
||||||
|
// 实时结算(默认)时清单恒为空:那种模式下每次发布都当场结算了。
|
||||||
|
func (s *server) settlementStatus(ctx context.Context, eventID, catID int64) (settlementStatusDTO, error) {
|
||||||
|
out := settlementStatusDTO{Groups: []settlementGroupDTO{}}
|
||||||
|
live, err := liveOnPublishQ(ctx, s.ev)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.LiveOnPublish = live
|
||||||
|
if live {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := loadCategoriesQ(ctx, s.ev, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
entries, err := loadEntriesQ(ctx, s.ev, eventID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
results, err := loadResultsQ(ctx, s.ev, eventID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, cat := range categories {
|
||||||
|
if catID > 0 && cat.ID != catID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groupEntries, groupResults := groupOf(entries, results, cat.ID)
|
||||||
|
required, published := groupStats(groupEntries, groupResults)
|
||||||
|
// 一条都没发布 = 还没开始结算;已录完 = 下次发布就会结算(或被立即结算过)。
|
||||||
|
if required == 0 || published == 0 || published == required {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Groups = append(out.Groups, settlementGroupDTO{
|
||||||
|
EventID: eventID,
|
||||||
|
CategoryID: cat.ID,
|
||||||
|
CategoryName: cat.Name,
|
||||||
|
Required: required,
|
||||||
|
Published: published,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleResults 立即结算:无视延迟结算设置,按当前已发布成绩重算积分流水并刷新榜单。
|
||||||
|
//
|
||||||
|
// 这是现场安全绳:只要有一条成绩永远录不出来(退赛却没标 scratched),
|
||||||
|
// 延迟结算就永远不会自动触发,必须有一个显式入口把已发布部分结算掉。
|
||||||
|
func (s *server) settleResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||||
|
if in.EventID == 0 {
|
||||||
|
return nil, badRequest("eventId 必填", nil)
|
||||||
|
}
|
||||||
|
catID := int64(0)
|
||||||
|
if in.CategoryID != nil {
|
||||||
|
catID = *in.CategoryID
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "立即结算")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit(ctx, "result.settle", "event", in.EventID, before, after)
|
||||||
|
return s.resultsByEvent(ctx, in.EventID)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 变更日志与版本 ----------
|
// ---------- 变更日志与版本 ----------
|
||||||
|
|
||||||
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
|
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ func (s *server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
|
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
|
||||||
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
|
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
|
||||||
|
|
||||||
|
// 登录有效期是机器级配置(系统库 settings),与上面的赛事级 /api/settings 不共用;
|
||||||
|
// 它决定所有人的会话寿命,所以读写都限 admin。
|
||||||
|
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
|
||||||
|
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
|
||||||
|
|
||||||
// 基础配置
|
// 基础配置
|
||||||
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
|
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
|
||||||
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
|
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
|
||||||
@@ -218,6 +223,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
|
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
|
||||||
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
|
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
|
||||||
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
|
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
|
||||||
|
// 单个组别的就地更新:有报名数据时改名 / 改年龄 / 改性别 / 改权重只走这条,不重建组别。
|
||||||
|
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}", s.write(auth.RoleRecorder, s.updateCategoryHandler))
|
||||||
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
|
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
|
||||||
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
|
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
|
||||||
|
|
||||||
@@ -231,17 +238,25 @@ func (s *server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
|
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
|
||||||
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
|
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
|
||||||
mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler))
|
mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler))
|
||||||
|
// 批量动作端点:一次请求完成整批停用 / 彻底删除,逐条回报成败;逐个删会逐个消耗写配额。
|
||||||
|
mux.HandleFunc("POST /api/athletes/batch", s.write(auth.RoleRecorder, s.batchAthletesHandler))
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||||
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||||
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
|
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
|
||||||
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
|
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
|
||||||
|
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
|
||||||
|
// 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。
|
||||||
|
mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler))
|
||||||
|
|
||||||
// 成绩
|
// 成绩
|
||||||
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
||||||
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
|
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
|
||||||
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
|
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
|
||||||
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
|
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
|
||||||
|
// 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。
|
||||||
|
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
|
||||||
|
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
|
||||||
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
|
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
|
||||||
|
|
||||||
// 积分与排名
|
// 积分与排名
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 登录有效期(系统级设置)的测试脚手架 ----------
|
||||||
|
|
||||||
|
type sessionSettingsDTO struct {
|
||||||
|
SessionHours int `json:"sessionHours"`
|
||||||
|
DefaultHours int `json:"defaultSessionHours"`
|
||||||
|
MinHours int `json:"minHours"`
|
||||||
|
MaxHours int `json:"maxHours"`
|
||||||
|
ExpiresAt *string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
// sysSetting 读系统库 settings 里的键。
|
||||||
|
func sysSetting(t *testing.T, env *testEnv, key string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var v string
|
||||||
|
err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读系统设置 %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionExpiry 取某用户当前会话的到期时间(测试里每个用户只保留一个会话)。
|
||||||
|
func sessionExpiry(t *testing.T, env *testEnv, username string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
var raw string
|
||||||
|
if err := env.sys.QueryRow(
|
||||||
|
`SELECT s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
|
||||||
|
username).Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("读 %s 的会话到期时间: %v", username, err)
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.DateTime, raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析到期时间 %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionLastSeen 取某用户当前会话的活动时间。
|
||||||
|
func sessionLastSeen(t *testing.T, env *testEnv, username string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
var raw string
|
||||||
|
if err := env.sys.QueryRow(
|
||||||
|
`SELECT COALESCE(s.last_seen_at, '') FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
|
||||||
|
username).Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("读 %s 的会话活动时间: %v", username, err)
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.DateTime, raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析活动时间 %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionExpiry(t *testing.T, env *testEnv, username string, at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := env.sys.Exec(
|
||||||
|
`UPDATE sessions SET expires_at = ?
|
||||||
|
WHERE user_id = (SELECT id FROM users WHERE username = ?)`,
|
||||||
|
at.UTC().Format(time.DateTime), username); err != nil {
|
||||||
|
t.Fatalf("改 %s 的会话到期时间: %v", username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminClient(t *testing.T, env *testEnv) *http.Client {
|
||||||
|
t.Helper()
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 默认值与落库 ----------
|
||||||
|
|
||||||
|
// 面板默认 24 小时:迁移种下的历史占位值 12 从没被任何代码读过,按「未配置」处理;
|
||||||
|
// 写完之后落到系统库,重启(新建 Service)也读得到。
|
||||||
|
func TestSessionSettingsDefaultsAndWrite(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := adminClient(t, env)
|
||||||
|
|
||||||
|
status, body := env.do(client, http.MethodGet, "/api/system/session", nil, false)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("读取登录有效期应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
got := decodeData[sessionSettingsDTO](t, body)
|
||||||
|
if got.SessionHours != 24 || got.DefaultHours != 24 || got.MinHours != 1 || got.MaxHours != 720 {
|
||||||
|
t.Fatalf("默认值/范围不符:%+v", got)
|
||||||
|
}
|
||||||
|
if got.ExpiresAt == nil || !strings.HasSuffix(*got.ExpiresAt, "Z") {
|
||||||
|
t.Fatalf("应回带当前会话的到期时间(RFC3339 UTC),得到 %v", got.ExpiresAt)
|
||||||
|
}
|
||||||
|
if v, ok := sysSetting(t, env, auth.SessionHoursKey); !ok || v != "12" {
|
||||||
|
t.Fatalf("初始库里应是迁移种下的 12,得到 %q(存在=%v)", v, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, body = env.do(client, http.MethodPut, "/api/system/session",
|
||||||
|
map[string]any{"sessionHours": 72}, true)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("写入应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if got := decodeData[sessionSettingsDTO](t, body); got.SessionHours != 72 {
|
||||||
|
t.Fatalf("应回显 72,得到 %+v", got)
|
||||||
|
}
|
||||||
|
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "72" {
|
||||||
|
t.Fatalf("落库应为 72,得到 %q", v)
|
||||||
|
}
|
||||||
|
// 来源标记:有这个键,读取时就不会把值当成迁移占位值。
|
||||||
|
if v, _ := sysSetting(t, env, auth.SessionHoursSourceKey); v != auth.SessionHoursSourcePanel {
|
||||||
|
t.Fatalf("应登记来源 %q,得到 %q", auth.SessionHoursSourcePanel, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重启等价物:新起一个 Service 读同一个库,仍是 72h。
|
||||||
|
restarted := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
|
||||||
|
if restarted.SessionTTL() != 72*time.Hour {
|
||||||
|
t.Fatalf("重启后应仍读 72h,得到 %s", restarted.SessionTTL())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非法值一律 400 + 中文提示,且不落库;读写都是 admin-only。
|
||||||
|
func TestSessionSettingsValidationAndPermission(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := adminClient(t, env)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"缺字段", map[string]any{}},
|
||||||
|
{"过小", map[string]any{"sessionHours": 0}},
|
||||||
|
{"负数", map[string]any{"sessionHours": -3}},
|
||||||
|
{"过大", map[string]any{"sessionHours": 721}},
|
||||||
|
} {
|
||||||
|
status, body := env.do(client, http.MethodPut, "/api/system/session", tc.body, true)
|
||||||
|
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
|
||||||
|
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, body.Error)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body.Error.Message, "登录有效期") {
|
||||||
|
t.Fatalf("%s 的中文提示应说明是登录有效期,得到 %q", tc.name, body.Error.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "12" {
|
||||||
|
t.Fatalf("非法值不该落库,库里是 %q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
recorder, _ := env.client()
|
||||||
|
env.login(recorder, "recorder", "recorder-pass")
|
||||||
|
if status, _ := env.do(recorder, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
|
||||||
|
t.Fatalf("recorder 读登录有效期应 403,得到 %d", status)
|
||||||
|
}
|
||||||
|
if status, _ := env.do(recorder, http.MethodPut, "/api/system/session",
|
||||||
|
map[string]any{"sessionHours": 1}, true); status != http.StatusForbidden {
|
||||||
|
t.Fatalf("recorder 写登录有效期应 403,得到 %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 滑动续期 ----------
|
||||||
|
|
||||||
|
// 会话只要还在被使用就顺延;节流生效时不会每次请求都写 expires_at。
|
||||||
|
func TestSlidingRenewalThrottledOverHTTP(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := adminClient(t, env)
|
||||||
|
|
||||||
|
// 模拟「已经消耗掉大半寿命」:剩 23h58m(不足 24h-1m 的节流窗口)。
|
||||||
|
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(23*time.Hour+58*time.Minute))
|
||||||
|
before := sessionExpiry(t, env, "admin")
|
||||||
|
|
||||||
|
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("请求应成功,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
renewed := sessionExpiry(t, env, "admin")
|
||||||
|
if diff := renewed.Sub(before); diff < time.Minute || diff > 3*time.Minute {
|
||||||
|
t.Fatalf("应把到期时间顺延约 2 分钟(到 now+24h),实际 %s → %s(差 %s)",
|
||||||
|
before.Format(time.DateTime), renewed.Format(time.DateTime), diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 紧接着的多次请求都落在节流窗口内:expires_at 与 last_seen_at 都一动不动
|
||||||
|
// (改动前是每个请求写一次 last_seen_at,现在最多每分钟一次)。
|
||||||
|
seen := sessionLastSeen(t, env, "admin")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
|
||||||
|
t.Fatalf("第 %d 次请求应成功,得到 %d", i, status)
|
||||||
|
}
|
||||||
|
if got := sessionExpiry(t, env, "admin"); !got.Equal(renewed) {
|
||||||
|
t.Fatalf("节流窗口内不该再写 expires_at:%s → %s",
|
||||||
|
renewed.Format(time.DateTime), got.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
if got := sessionLastSeen(t, env, "admin"); !got.Equal(seen) {
|
||||||
|
t.Fatalf("节流窗口内不该再写 last_seen_at:%s → %s",
|
||||||
|
seen.Format(time.DateTime), got.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改设置不动在线的人:旧会话的 expires_at 不变、请求照常成功,下一次使用时按新有效期延长。
|
||||||
|
func TestSessionHoursChangeKeepsOnlineSessions(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
boss := adminClient(t, env)
|
||||||
|
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
|
||||||
|
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
|
||||||
|
t.Fatalf("先设成 1 小时应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
worker, _ := env.client()
|
||||||
|
env.login(worker, "recorder", "recorder-pass")
|
||||||
|
before := sessionExpiry(t, env, "recorder")
|
||||||
|
|
||||||
|
// 管理员把有效期改成 72 小时。
|
||||||
|
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
|
||||||
|
map[string]any{"sessionHours": 72}, true); status != http.StatusOK {
|
||||||
|
t.Fatalf("改成 72 小时应 200,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
if after := sessionExpiry(t, env, "recorder"); !after.Equal(before) {
|
||||||
|
t.Fatalf("改设置不该动已登录会话的 expires_at:%s → %s",
|
||||||
|
before.Format(time.DateTime), after.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧会话仍被认,而且拿到的是新的(更长的)到期时间。
|
||||||
|
status, body := env.do(worker, http.MethodGet, "/api/auth/me", nil, false)
|
||||||
|
if status != http.StatusOK || !body.OK {
|
||||||
|
t.Fatalf("旧会话不该被踢下线,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
me := decodeData[map[string]any](t, body)
|
||||||
|
if me["role"] != "recorder" {
|
||||||
|
t.Fatalf("旧会话应仍是 recorder 身份,得到 %v", me["role"])
|
||||||
|
}
|
||||||
|
if me["sessionExpiresAt"] == nil {
|
||||||
|
t.Fatal("me 应回带当前会话的到期时间")
|
||||||
|
}
|
||||||
|
after := sessionExpiry(t, env, "recorder")
|
||||||
|
if diff := after.Sub(before); diff < 70*time.Hour {
|
||||||
|
t.Fatalf("下一次使用应按新有效期顺延到 ~72h,实际 %s → %s",
|
||||||
|
before.Format(time.DateTime), after.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 到期校验不能被改坏:手工把 expires_at 改到 1 分钟前,写请求必须 401 AUTH_REQUIRED。
|
||||||
|
func TestExpiredSessionReturnsAuthRequired(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := adminClient(t, env)
|
||||||
|
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(-time.Minute))
|
||||||
|
|
||||||
|
status, body := env.do(client, http.MethodPut, "/api/settings",
|
||||||
|
map[string]any{"event": map[string]any{"name": "秋季运动会"}}, true)
|
||||||
|
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
|
||||||
|
t.Fatalf("过期会话写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 安全口径没有被放宽 ----------
|
||||||
|
|
||||||
|
func TestSessionSettingsKeepsSecurityPosture(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
|
||||||
|
// 登录下发的 Cookie 仍带 HttpOnly + SameSite=Lax;CSRF Cookie 仍可被前端读取。
|
||||||
|
resp, err := http.Post(env.server.URL+"/api/auth/login", "application/json",
|
||||||
|
strings.NewReader(`{"username":"admin","password":"admin-pass"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("登录: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var session, csrf *http.Cookie
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
switch c.Name {
|
||||||
|
case auth.CookieSession:
|
||||||
|
session = c
|
||||||
|
case auth.CookieCSRF:
|
||||||
|
csrf = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session == nil || !session.HttpOnly || session.SameSite != http.SameSiteLaxMode || session.Secure {
|
||||||
|
t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax:%+v", session)
|
||||||
|
}
|
||||||
|
if csrf == nil || csrf.HttpOnly {
|
||||||
|
t.Fatalf("CSRF Cookie 应可被前端读取(非 HttpOnly):%+v", csrf)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
// CSRF 双提交仍然拦写:不带 X-CSRF-Token 的写请求一律 403。
|
||||||
|
if status, body := env.do(client, http.MethodPut, "/api/system/session",
|
||||||
|
map[string]any{"sessionHours": 2}, false); status != http.StatusForbidden ||
|
||||||
|
body.Error == nil || body.Error.Code != "FORBIDDEN" {
|
||||||
|
t.Fatalf("缺 CSRF 头的写请求应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// must_change_password 仍然拦写操作。
|
||||||
|
env.createUser("fresh", "fresh-pass", "admin")
|
||||||
|
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE username = 'fresh'`); err != nil {
|
||||||
|
t.Fatalf("置 must_change_password: %v", err)
|
||||||
|
}
|
||||||
|
fresh, _ := env.client()
|
||||||
|
env.login(fresh, "fresh", "fresh-pass")
|
||||||
|
if status, body := env.do(fresh, http.MethodPut, "/api/system/session",
|
||||||
|
map[string]any{"sessionHours": 2}, true); status != http.StatusForbidden ||
|
||||||
|
body.Error == nil || body.Error.Message != "请先修改初始密码" {
|
||||||
|
t.Fatalf("待改密账号的写请求应被拦下,得到 %d %+v", status, body.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录访客读不到登录有效期。
|
||||||
|
guest, _ := env.client()
|
||||||
|
if status, _ := env.do(guest, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
|
||||||
|
t.Fatalf("访客读登录有效期应 403,得到 %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 分批结算的判据是纯函数,这里逐类钉住它的口径:
|
||||||
|
// 「所有批次都录完」= 该组所有 registered 报名项都有可发布的已发布成绩。
|
||||||
|
func TestGroupStatsSettlementJudgement(t *testing.T) {
|
||||||
|
value := 11.58
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
entries []domain.Entry
|
||||||
|
results []domain.Result
|
||||||
|
wantTotal int
|
||||||
|
wantPublished int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "全部发完",
|
||||||
|
entries: []domain.Entry{
|
||||||
|
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||||
|
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||||
|
},
|
||||||
|
results: []domain.Result{
|
||||||
|
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
},
|
||||||
|
wantTotal: 2,
|
||||||
|
wantPublished: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有人弃权(scratched 不该再等它)",
|
||||||
|
entries: []domain.Entry{
|
||||||
|
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||||
|
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||||
|
{ID: 3, Status: domain.EntryStatusScratched},
|
||||||
|
},
|
||||||
|
results: []domain.Result{
|
||||||
|
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
},
|
||||||
|
wantTotal: 2,
|
||||||
|
wantPublished: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有人失格(dq/dns 同样已有现场结论)",
|
||||||
|
entries: []domain.Entry{
|
||||||
|
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||||
|
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||||
|
{ID: 3, Status: domain.EntryStatusDNS},
|
||||||
|
},
|
||||||
|
results: []domain.Result{
|
||||||
|
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
},
|
||||||
|
wantTotal: 2,
|
||||||
|
wantPublished: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有草稿但不该发布(还没跑 / 还没录)",
|
||||||
|
entries: []domain.Entry{
|
||||||
|
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||||
|
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||||
|
},
|
||||||
|
results: []domain.Result{
|
||||||
|
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||||
|
{EntryID: 2, Status: domain.ResultStatusOK, State: domain.ResultStateDraft},
|
||||||
|
},
|
||||||
|
wantTotal: 2,
|
||||||
|
wantPublished: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已发布但空且 ok 的行不算成绩",
|
||||||
|
entries: []domain.Entry{
|
||||||
|
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||||
|
},
|
||||||
|
results: []domain.Result{
|
||||||
|
{EntryID: 1, Status: domain.ResultStatusOK, State: domain.ResultStatePublished},
|
||||||
|
},
|
||||||
|
wantTotal: 1,
|
||||||
|
wantPublished: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DNS 这类没有成绩值的现场结论可以发布",
|
||||||
|
entries: []domain.Entry{
|
||||||
|
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||||
|
},
|
||||||
|
results: []domain.Result{
|
||||||
|
{EntryID: 1, Status: domain.ResultStatusDNS, State: domain.ResultStatePublished},
|
||||||
|
},
|
||||||
|
wantTotal: 1,
|
||||||
|
wantPublished: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "整组弃权:没有要等的人",
|
||||||
|
entries: []domain.Entry{{ID: 1, Status: domain.EntryStatusScratched}},
|
||||||
|
results: nil,
|
||||||
|
wantTotal: 0,
|
||||||
|
wantPublished: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "没有报名项",
|
||||||
|
entries: nil,
|
||||||
|
results: nil,
|
||||||
|
wantTotal: 0,
|
||||||
|
wantPublished: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
total, published := groupStats(tc.entries, tc.results)
|
||||||
|
if total != tc.wantTotal || published != tc.wantPublished {
|
||||||
|
t.Fatalf("groupStats = (%d, %d),期望 (%d, %d)", total, published, tc.wantTotal, tc.wantPublished)
|
||||||
|
}
|
||||||
|
got := published == total
|
||||||
|
want := tc.wantPublished == tc.wantTotal
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("结算判据 = %v,期望 %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupOf 必须只留下该组别的行:成绩表不带组别,靠报名 id 反查时最容易把别组的成绩算进来。
|
||||||
|
func TestGroupOfFiltersByCategory(t *testing.T) {
|
||||||
|
entries := []domain.Entry{
|
||||||
|
{ID: 1, CategoryID: 7},
|
||||||
|
{ID: 2, CategoryID: 7},
|
||||||
|
{ID: 3, CategoryID: 8},
|
||||||
|
}
|
||||||
|
results := []domain.Result{
|
||||||
|
{ID: 11, EntryID: 1},
|
||||||
|
{ID: 12, EntryID: 2},
|
||||||
|
{ID: 13, EntryID: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEntries, groupResults := groupOf(entries, results, 7)
|
||||||
|
if len(groupEntries) != 2 || len(groupResults) != 2 {
|
||||||
|
t.Fatalf("第 7 组应只剩 2 条报名 / 2 条成绩,得到 %d / %d", len(groupEntries), len(groupResults))
|
||||||
|
}
|
||||||
|
for _, r := range groupResults {
|
||||||
|
if r.EntryID == 3 {
|
||||||
|
t.Fatalf("别组的成绩不该被算进来: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -254,7 +254,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
|||||||
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
||||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"})
|
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"})
|
||||||
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
|
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
|
||||||
|
|
||||||
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||||
var rows []map[string]any
|
var rows []map[string]any
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import type { Athlete, AthleteInput, Gender } from '@/shared'
|
import type { Athlete, AthleteInput, Gender } from '@/shared'
|
||||||
import type { AthletePurgeResult } from '@/shared/api'
|
import type { AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
import FormDialog from './FormDialog.vue'
|
import FormDialog from './FormDialog.vue'
|
||||||
import FormField from './FormField.vue'
|
import FormField from './FormField.vue'
|
||||||
|
import { baseDataApi } from './baseDataApi'
|
||||||
import { useBaseData } from './useBaseData'
|
import { useBaseData } from './useBaseData'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
@@ -47,6 +48,205 @@ const purgePending = ref(false)
|
|||||||
/** 删除失败时的说明留在页面上:服务端那句原文(人员已不在等)比一行「操作失败」有用得多。 */
|
/** 删除失败时的说明留在页面上:服务端那句原文(人员已不在等)比一行「操作失败」有用得多。 */
|
||||||
const purgeError = ref<string | null>(null)
|
const purgeError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ── 多选与批量操作 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 勾选的运动员 id。
|
||||||
|
*
|
||||||
|
* 每行一个勾选框,表头是「全选当前筛选结果」——先按单位 / 状态 / 关键字把要处理的人筛出来,
|
||||||
|
* 再一键全选,是现场最高频的用法;如果表头连被筛掉的人一起选,批量彻底删除就可能删掉
|
||||||
|
* 看不见的人,所以勾选范围严格等于当前可见的筛选结果。
|
||||||
|
*/
|
||||||
|
const selectedIds = ref<Set<number>>(new Set())
|
||||||
|
/** 批量操作提交中的标志(预览与真删共用,避免重复点击)。 */
|
||||||
|
const batchPending = ref(false)
|
||||||
|
/** 批量操作的逐条结果常驻页面:谁没被处理、为什么,不能只靠一闪而过的 toast。 */
|
||||||
|
const batchMessage = ref<string | null>(null)
|
||||||
|
/** 发起批量操作时锁定的 id 列表:确认框开着的时候选择不该再变化,提交用的就是这一份。 */
|
||||||
|
const batchTargetIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const batchDisableOpen = ref(false)
|
||||||
|
const batchPurgeOpen = ref(false)
|
||||||
|
/** 彻底删除的预览(dryRun 响应):提交前把「会删掉什么、重算哪些小项」摆给操作员看。 */
|
||||||
|
const bulkPurgePreview = ref<AthleteBatchResult | null>(null)
|
||||||
|
|
||||||
|
const filteredIds = computed(() => filtered.value.map((athlete) => athlete.id))
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
const selectedAthletes = computed(() => data.athletes.value.filter((athlete) => selectedIds.value.has(athlete.id)))
|
||||||
|
const allFilteredSelected = computed(
|
||||||
|
() => filteredIds.value.length > 0 && filteredIds.value.every((id) => selectedIds.value.has(id)),
|
||||||
|
)
|
||||||
|
const someFilteredSelected = computed(
|
||||||
|
() => !allFilteredSelected.value && filteredIds.value.some((id) => selectedIds.value.has(id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 被筛掉 / 被删掉的 id 自动退出选择:批量彻底删除的范围必须始终看得见。
|
||||||
|
watch(filteredIds, (ids) => {
|
||||||
|
if (selectedIds.value.size === 0) return
|
||||||
|
const visible = new Set(ids)
|
||||||
|
const kept = [...selectedIds.value].filter((id) => visible.has(id))
|
||||||
|
if (kept.length !== selectedIds.value.size) selectedIds.value = new Set(kept)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleOne(id: number, checked: boolean): void {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (checked) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowCheck(id: number, event: Event): void {
|
||||||
|
toggleOne(id, (event.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllFiltered(checked: boolean): void {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
for (const id of filteredIds.value) {
|
||||||
|
if (checked) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
}
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeaderCheck(event: Event): void {
|
||||||
|
toggleAllFiltered((event.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交前的名单快照:批量删除之后这些人可能已经不在列表里,名字要提前留住。 */
|
||||||
|
function selectedLabels(): Map<number, string> {
|
||||||
|
return new Map(selectedAthletes.value.map((athlete) => [athlete.id, athlete.name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汇总一次批量动作的逐条结果。
|
||||||
|
*
|
||||||
|
* 有失败就常驻页面并逐条点名(谁、为什么),不能只报「部分成功」——
|
||||||
|
* 现场要拿着这句话去核对到底谁没被处理。
|
||||||
|
*/
|
||||||
|
function reportBatch(verb: string, result: AthleteBatchResult, labels: Map<number, string>): void {
|
||||||
|
const failures = result.items.filter((item) => !item.ok)
|
||||||
|
const done = `已${verb} ${result.succeeded} 人`
|
||||||
|
if (failures.length === 0) {
|
||||||
|
batchMessage.value = null
|
||||||
|
toast.success(`${done}(${result.requested} 人中全部成功)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detail = failures
|
||||||
|
.map((item) => `${labels.get(item.id) ?? `#${item.id}`}(${item.message ?? '未能处理'})`)
|
||||||
|
.join('、')
|
||||||
|
batchMessage.value = `${done},${failures.length} 人未能处理:${detail}`
|
||||||
|
toast.error(`批量${verb}:${failures.length} 人未处理`, batchMessage.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function askBatchDisable(): void {
|
||||||
|
if (selectedCount.value === 0) return
|
||||||
|
batchTargetIds.value = [...selectedIds.value]
|
||||||
|
batchMessage.value = null
|
||||||
|
batchDisableOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchDisable(): Promise<void> {
|
||||||
|
if (batchPending.value) return
|
||||||
|
const labels = selectedLabels()
|
||||||
|
batchPending.value = true
|
||||||
|
try {
|
||||||
|
const result = await baseDataApi.athletes.batch('disable', batchTargetIds.value)
|
||||||
|
reportBatch('停用', result, labels)
|
||||||
|
batchDisableOpen.value = false
|
||||||
|
clearSelection()
|
||||||
|
await data.load(true)
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('批量停用失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
batchPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 先预览再确认:彻底删除不可逆,必须让操作员看到会删掉谁、重算哪些小项。 */
|
||||||
|
async function askBatchPurge(): Promise<void> {
|
||||||
|
if (selectedCount.value === 0) return
|
||||||
|
batchTargetIds.value = [...selectedIds.value]
|
||||||
|
batchMessage.value = null
|
||||||
|
bulkPurgePreview.value = null
|
||||||
|
batchPending.value = true
|
||||||
|
try {
|
||||||
|
bulkPurgePreview.value = await baseDataApi.athletes.batch('purge', batchTargetIds.value, true)
|
||||||
|
batchPurgeOpen.value = true
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('无法预估删除影响', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
batchPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认框里逐行摆出的影响:名单、删除规模、要重算的小项,以及不可逆的提醒。 */
|
||||||
|
const bulkPurgeDetails = computed<string[]>(() => {
|
||||||
|
const preview = bulkPurgePreview.value
|
||||||
|
const people = data.athletes.value.filter((athlete) => batchTargetIds.value.includes(athlete.id))
|
||||||
|
const shown = people
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((athlete) => `${athlete.name}(${data.teamById(athlete.teamId)?.name ?? '未知单位'})`)
|
||||||
|
const lines: string[] = [
|
||||||
|
`将删除 ${people.length} 人:${shown.join('、')}${people.length > shown.length ? ` 等 ${people.length} 人` : ''}`,
|
||||||
|
]
|
||||||
|
if (preview?.purge !== undefined) {
|
||||||
|
const p = preview.purge
|
||||||
|
const parts = [`报名 ${p.deletedEntries} 条`, `成绩 ${p.deletedResults} 条`]
|
||||||
|
if (p.deletedLedger > 0) parts.push(`积分流水 ${p.deletedLedger} 条`)
|
||||||
|
if (p.deletedBreaks > 0) parts.push(`纪录 ${p.deletedBreaks} 条`)
|
||||||
|
lines.push(`合计删除 ${parts.join('、')}`)
|
||||||
|
const events = p.recomputedEvents.map((id) => data.eventById(id)?.name ?? `#${id}`)
|
||||||
|
lines.push(
|
||||||
|
events.length > 0
|
||||||
|
? `会重算 ${events.length} 个小项的排名与积分:${events.join('、')}`
|
||||||
|
: '没有已发布的成绩,不需要重算名次',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const missing = (preview?.items ?? []).filter((item) => !item.ok)
|
||||||
|
if (missing.length > 0) {
|
||||||
|
lines.push(`其中 ${missing.length} 人已不在名单里,提交时会跳过并单独回报`)
|
||||||
|
}
|
||||||
|
if (people.length > 20) {
|
||||||
|
lines.push(`本次涉及 ${people.length} 人,人数较多时重算更久,请确认名单无误`)
|
||||||
|
}
|
||||||
|
lines.push('删除不可恢复;只是想暂时不让他报名,请改用「批量停用」')
|
||||||
|
return lines
|
||||||
|
})
|
||||||
|
|
||||||
|
async function confirmBatchPurge(): Promise<void> {
|
||||||
|
if (batchPending.value) return
|
||||||
|
const labels = new Map(
|
||||||
|
data.athletes.value.filter((athlete) => batchTargetIds.value.includes(athlete.id)).map((a) => [a.id, a.name]),
|
||||||
|
)
|
||||||
|
batchPending.value = true
|
||||||
|
try {
|
||||||
|
const result = await baseDataApi.athletes.batch('purge', batchTargetIds.value)
|
||||||
|
reportBatch('彻底删除', result, labels)
|
||||||
|
const recomputed = result.purge?.recomputedEvents.length ?? 0
|
||||||
|
if (recomputed > 0) {
|
||||||
|
// 名次与积分会随成绩一起变,别只说「已删除」:说清重算了几个小项,操作员才不会
|
||||||
|
// 回头发现榜单变了却找不到原因。
|
||||||
|
const note = `已重算 ${recomputed} 个小项的排名与积分`
|
||||||
|
batchMessage.value = batchMessage.value === null ? note : `${batchMessage.value};${note}`
|
||||||
|
}
|
||||||
|
batchPurgeOpen.value = false
|
||||||
|
clearSelection()
|
||||||
|
await data.load(true)
|
||||||
|
} catch (caught) {
|
||||||
|
// 服务端原文(整批回滚、某个人被引用住了)必须照原样展示。
|
||||||
|
const message = caught instanceof Error ? caught.message : '批量彻底删除失败'
|
||||||
|
batchMessage.value = message
|
||||||
|
toast.error('批量彻底删除失败', message)
|
||||||
|
batchPurgeOpen.value = false
|
||||||
|
} finally {
|
||||||
|
batchPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function emptyForm(): AthleteForm {
|
function emptyForm(): AthleteForm {
|
||||||
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
|
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
|
||||||
}
|
}
|
||||||
@@ -261,10 +461,46 @@ async function confirmPurge(): Promise<void> {
|
|||||||
<MxIcon name="warning" :size="14" /> {{ purgeError }}
|
<MxIcon name="warning" :size="14" /> {{ purgeError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="batchMessage !== null" class="mx-badge mx-badge-error purge-error">
|
||||||
|
<MxIcon name="warning" :size="14" /> {{ batchMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedCount > 0" class="bd-batch-bar">
|
||||||
|
<span class="mx-badge mx-badge-neutral">已选 {{ selectedCount }} 人</span>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">清空选择</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value || batchPending"
|
||||||
|
@click="askBatchDisable"
|
||||||
|
>
|
||||||
|
批量停用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value || batchPending"
|
||||||
|
@click="askBatchPurge"
|
||||||
|
>
|
||||||
|
批量彻底删除
|
||||||
|
</button>
|
||||||
|
<span class="bd-note">勾选范围是当前筛选结果,整批只发一次请求</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mx-table-wrap">
|
<div class="mx-table-wrap">
|
||||||
<table class="mx-table mx-table-compact">
|
<table class="mx-table mx-table-compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="bd-cell-mini">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="allFilteredSelected"
|
||||||
|
:indeterminate="someFilteredSelected"
|
||||||
|
:disabled="!data.canWrite.value || filteredIds.length === 0"
|
||||||
|
title="全选当前筛选结果"
|
||||||
|
@change="onHeaderCheck"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th>姓名</th>
|
<th>姓名</th>
|
||||||
<th class="bd-cell-mini">性别</th>
|
<th class="bd-cell-mini">性别</th>
|
||||||
<th class="bd-cell-mini">年龄</th>
|
<th class="bd-cell-mini">年龄</th>
|
||||||
@@ -278,7 +514,7 @@ async function confirmPurge(): Promise<void> {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="data.loading.value && data.athletes.value.length === 0">
|
<tr v-if="data.loading.value && data.athletes.value.length === 0">
|
||||||
<td colspan="9">
|
<td colspan="10">
|
||||||
<div class="mx-empty bd-empty">
|
<div class="mx-empty bd-empty">
|
||||||
<span class="mx-spinner" />
|
<span class="mx-spinner" />
|
||||||
<div class="mx-empty-title">加载中…</div>
|
<div class="mx-empty-title">加载中…</div>
|
||||||
@@ -287,6 +523,15 @@ async function confirmPurge(): Promise<void> {
|
|||||||
</tr>
|
</tr>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr v-for="athlete in filtered" :key="athlete.id">
|
<tr v-for="athlete in filtered" :key="athlete.id">
|
||||||
|
<td class="bd-cell-mini">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.has(athlete.id)"
|
||||||
|
:disabled="!data.canWrite.value"
|
||||||
|
:aria-label="`选择 ${athlete.name}`"
|
||||||
|
@change="onRowCheck(athlete.id, $event)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>{{ athlete.name }}</td>
|
<td>{{ athlete.name }}</td>
|
||||||
<td>{{ athlete.gender ?? '—' }}</td>
|
<td>{{ athlete.gender ?? '—' }}</td>
|
||||||
<td>{{ athlete.age ?? '—' }}</td>
|
<td>{{ athlete.age ?? '—' }}</td>
|
||||||
@@ -325,7 +570,7 @@ async function confirmPurge(): Promise<void> {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="filtered.length === 0">
|
<tr v-if="filtered.length === 0">
|
||||||
<td colspan="9">
|
<td colspan="10">
|
||||||
<div class="mx-empty bd-empty">
|
<div class="mx-empty bd-empty">
|
||||||
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
|
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
|
||||||
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
|
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
|
||||||
@@ -337,7 +582,7 @@ async function confirmPurge(): Promise<void> {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="bd-note">
|
<p class="bd-note">
|
||||||
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。
|
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。表头勾选框会选中当前筛选结果里的全部人员,批量操作用一次请求完成。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -375,10 +620,10 @@ async function confirmPurge(): Promise<void> {
|
|||||||
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
||||||
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="证件号" hint="证件号与号码布至少填一个,二者都是人员唯一标识">
|
<FormField label="证件号" hint="可选;不参与认人,但不能与别人重号">
|
||||||
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
|
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="号码布" hint="没有身份证信息时可只填号码布">
|
<FormField label="号码布" hint="认人依据;建议每人一条且全库唯一">
|
||||||
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
|
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="状态" wide>
|
<FormField label="状态" wide>
|
||||||
@@ -387,6 +632,33 @@ async function confirmPurge(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="batchDisableOpen"
|
||||||
|
tone="warning"
|
||||||
|
title="批量停用选中的运动员?"
|
||||||
|
:message="`停用 ${batchTargetIds.length} 人后,新报名不再可选他们;历史报名与成绩保留,随时可以再启用。`"
|
||||||
|
:details="[
|
||||||
|
'停用是软删除:报名、成绩、积分流水一条不动,榜单不变',
|
||||||
|
'整批只发一次请求,不会逐个消耗写操作配额',
|
||||||
|
]"
|
||||||
|
confirm-text="批量停用"
|
||||||
|
:pending="batchPending"
|
||||||
|
@confirm="confirmBatchDisable"
|
||||||
|
@cancel="batchDisableOpen = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="batchPurgeOpen"
|
||||||
|
tone="danger"
|
||||||
|
title="批量彻底删除选中的运动员?"
|
||||||
|
:message="`将把这 ${batchTargetIds.length} 人从人员档案中永久删除,不可恢复。`"
|
||||||
|
:details="bulkPurgeDetails"
|
||||||
|
confirm-text="彻底删除"
|
||||||
|
:pending="batchPending"
|
||||||
|
@confirm="confirmBatchPurge"
|
||||||
|
@cancel="batchPurgeOpen = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="confirmOpen"
|
:open="confirmOpen"
|
||||||
tone="warning"
|
tone="warning"
|
||||||
@@ -427,4 +699,14 @@ async function confirmPurge(): Promise<void> {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.bd-batch-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import type { EventCategory, EventCategoryInput, Gender } from '@/shared'
|
import type { EventCategory, EventCategoryInput, Gender } from '@/shared'
|
||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import { baseDataApi } from './baseDataApi'
|
||||||
import { useBaseData } from './useBaseData'
|
import { useBaseData } from './useBaseData'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
import type { BaseDataTabKey } from './tabs'
|
import type { BaseDataTabKey } from './tabs'
|
||||||
@@ -13,6 +14,8 @@ const data = useBaseData()
|
|||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
interface CategoryRow {
|
interface CategoryRow {
|
||||||
|
/** 服务端已有的组别 id;新增的行还没有 id。 */
|
||||||
|
id: number | null
|
||||||
key: number
|
key: number
|
||||||
name: string
|
name: string
|
||||||
// type="number" 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),这里如实标注
|
// type="number" 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),这里如实标注
|
||||||
@@ -39,11 +42,33 @@ const confirmOpen = ref(false)
|
|||||||
|
|
||||||
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
|
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
|
||||||
|
|
||||||
const removedNames = computed(() => {
|
const originalById = computed(() => new Map(original.value.map((category) => [category.id, category])))
|
||||||
const current = new Set(rows.value.map((row) => row.name.trim()).filter((name) => name !== ''))
|
|
||||||
return original.value.filter((category) => !current.has(category.name)).map((category) => category.name)
|
/** 还留在表里的已有组别 id;用来判断这次保存有没有「删掉组别」。 */
|
||||||
|
const keptIds = computed(() => {
|
||||||
|
const ids = new Set<number>()
|
||||||
|
for (const row of rows.value) {
|
||||||
|
if (row.id !== null) ids.add(row.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 本次保存被移除的已有组别(按原始顺序,提示里直接报名字)。 */
|
||||||
|
const removedCategories = computed(() => original.value.filter((category) => !keptIds.value.has(category.id)))
|
||||||
|
|
||||||
|
const removedNames = computed(() => removedCategories.value.map((category) => category.name))
|
||||||
|
|
||||||
|
/** 新增的行(还没有服务端 id)。 */
|
||||||
|
const addedCount = computed(() => rows.value.filter((row) => row.id === null).length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本次保存是否动了组别的「结构」(新增或删除)。
|
||||||
|
*
|
||||||
|
* 结构变了只能走整表替换:组别 id 会重建,挂在旧 id 上的报名就断链了,
|
||||||
|
* 所以服务端在有报名数据时会拒绝。只改字段则走单行就地更新,不受报名数据限制。
|
||||||
|
*/
|
||||||
|
const structuralChange = computed(() => addedCount.value > 0 || removedCategories.value.length > 0)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => data.selectedEventId.value, () => data.categoriesOf(data.selectedEventId.value)],
|
[() => data.selectedEventId.value, () => data.categoriesOf(data.selectedEventId.value)],
|
||||||
() => {
|
() => {
|
||||||
@@ -57,6 +82,7 @@ watch(
|
|||||||
|
|
||||||
function toRow(category: EventCategory): CategoryRow {
|
function toRow(category: EventCategory): CategoryRow {
|
||||||
return {
|
return {
|
||||||
|
id: category.id,
|
||||||
key: (nextKey += 1),
|
key: (nextKey += 1),
|
||||||
name: category.name,
|
name: category.name,
|
||||||
multiplier: String(category.multiplier),
|
multiplier: String(category.multiplier),
|
||||||
@@ -71,6 +97,7 @@ function addRow(): void {
|
|||||||
rows.value = [
|
rows.value = [
|
||||||
...rows.value,
|
...rows.value,
|
||||||
{
|
{
|
||||||
|
id: null,
|
||||||
key: (nextKey += 1),
|
key: (nextKey += 1),
|
||||||
name: '',
|
name: '',
|
||||||
multiplier: '1',
|
multiplier: '1',
|
||||||
@@ -139,15 +166,45 @@ function validate(): boolean {
|
|||||||
return Object.keys(next).length === 0
|
return Object.keys(next).length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInputs(): EventCategoryInput[] {
|
/** 一行 → 请求体;sortOrder 为 0 / 空时按行号兜底,与整表替换时的口径保持一致。 */
|
||||||
return rows.value.map((row, index) => ({
|
function rowInput(row: CategoryRow, index: number): EventCategoryInput {
|
||||||
|
return {
|
||||||
name: row.name.trim(),
|
name: row.name.trim(),
|
||||||
multiplier: Number(row.multiplier),
|
multiplier: Number(row.multiplier),
|
||||||
gender: row.gender === '' ? null : row.gender,
|
gender: row.gender === '' ? null : row.gender,
|
||||||
ageMin: toAge(row.ageMin),
|
ageMin: toAge(row.ageMin),
|
||||||
ageMax: toAge(row.ageMax),
|
ageMax: toAge(row.ageMax),
|
||||||
sortOrder: Number(row.sortOrder) || index + 1,
|
sortOrder: Number(row.sortOrder) || index + 1,
|
||||||
}))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInputs(): EventCategoryInput[] {
|
||||||
|
return rows.value.map((row, index) => rowInput(row, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryDiffers(before: EventCategory, after: EventCategoryInput): boolean {
|
||||||
|
return (
|
||||||
|
before.name !== after.name ||
|
||||||
|
before.multiplier !== after.multiplier ||
|
||||||
|
(before.gender ?? null) !== after.gender ||
|
||||||
|
(before.ageMin ?? null) !== after.ageMin ||
|
||||||
|
(before.ageMax ?? null) !== after.ageMax ||
|
||||||
|
before.sortOrder !== after.sortOrder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已有组别里真正被改动过的那些;没改的行不发请求,也少一次写配额。 */
|
||||||
|
function changedCategories(): { id: number; input: EventCategoryInput }[] {
|
||||||
|
const byId = originalById.value
|
||||||
|
const changes: { id: number; input: EventCategoryInput }[] = []
|
||||||
|
rows.value.forEach((row, index) => {
|
||||||
|
if (row.id === null) return
|
||||||
|
const before = byId.get(row.id)
|
||||||
|
if (before === undefined) return
|
||||||
|
const input = rowInput(row, index)
|
||||||
|
if (categoryDiffers(before, input)) changes.push({ id: row.id, input })
|
||||||
|
})
|
||||||
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestSave(): void {
|
function requestSave(): void {
|
||||||
@@ -156,7 +213,8 @@ function requestSave(): void {
|
|||||||
toast.error('组别校验未通过', '请先修正标红的字段')
|
toast.error('组别校验未通过', '请先修正标红的字段')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (removedNames.value.length > 0) {
|
// 增删组别才需要「整体替换」的二次确认;只改字段是就地更新,直接保存。
|
||||||
|
if (structuralChange.value && removedCategories.value.length > 0) {
|
||||||
confirmOpen.value = true
|
confirmOpen.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,12 +224,24 @@ function requestSave(): void {
|
|||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
const eventId = data.selectedEventId.value
|
const eventId = data.selectedEventId.value
|
||||||
if (eventId === null) return
|
if (eventId === null) return
|
||||||
|
const replace = structuralChange.value
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await data.saveCategories(eventId, buildInputs())
|
if (replace) {
|
||||||
toast.success('组别已保存')
|
await data.saveCategories(eventId, buildInputs())
|
||||||
|
toast.success('组别已保存')
|
||||||
|
} else {
|
||||||
|
const changes = changedCategories()
|
||||||
|
for (const change of changes) {
|
||||||
|
await baseDataApi.events.updateCategory(eventId, change.id, change.input)
|
||||||
|
}
|
||||||
|
if (changes.length > 0) await data.loadEventDetail(eventId, true)
|
||||||
|
toast.success(changes.length === 0 ? '没有需要保存的改动' : `已更新 ${changes.length} 个组别`)
|
||||||
|
}
|
||||||
confirmOpen.value = false
|
confirmOpen.value = false
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
|
// 逐条更新可能只成功了前几条:失败后必须把服务端的真实状态拉回来,别让界面停在假象上。
|
||||||
|
if (!replace) await data.loadEventDetail(eventId, true)
|
||||||
toast.error('保存组别失败', caught instanceof Error ? caught.message : null)
|
toast.error('保存组别失败', caught instanceof Error ? caught.message : null)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -195,7 +265,7 @@ async function save(): Promise<void> {
|
|||||||
:disabled="!data.canWrite.value || selectedEvent === null || saving"
|
:disabled="!data.canWrite.value || selectedEvent === null || saving"
|
||||||
@click="requestSave"
|
@click="requestSave"
|
||||||
>
|
>
|
||||||
{{ saving ? '保存中…' : '保存全部' }}
|
{{ saving ? '保存中…' : '保存' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,8 +281,9 @@ async function save(): Promise<void> {
|
|||||||
<div class="bd-warn">
|
<div class="bd-warn">
|
||||||
<MxIcon name="warning" :size="14" />
|
<MxIcon name="warning" :size="14" />
|
||||||
<span>
|
<span>
|
||||||
保存会整体替换该小项的组别配置(PUT /api/events/{{ selectedEvent.id }}/categories)。
|
已有报名数据的小项:改名 / 改年龄 / 改性别 / 改权重会就地保存,报名与成绩一条不动;
|
||||||
若该项目已有报名数据,服务端会拒绝替换。
|
其中<strong>改权重会立即按新权重重算该项目已发布成绩的积分与榜单</strong>,改名 / 改年龄 / 改性别不会。
|
||||||
|
新增或删除组别会被服务端拒绝——组别一重建,挂在它下面的报名就断链了。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,7 +358,7 @@ async function save(): Promise<void> {
|
|||||||
:open="confirmOpen"
|
:open="confirmOpen"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
title="保存将移除组别?"
|
title="保存将移除组别?"
|
||||||
:message="`本次保存会删除 ${removedNames.length} 个组别,并整体替换该小项的组别配置。`"
|
:message="`本次保存会删除 ${removedCategories.length} 个组别,并整体替换该小项的组别配置。若该项目已有报名数据,服务端会拒绝这次增删——改名 / 改年龄 / 改性别 / 改权重请先撤掉删除操作再保存。`"
|
||||||
:details="removedNames"
|
:details="removedNames"
|
||||||
confirm-text="确认保存"
|
confirm-text="确认保存"
|
||||||
:pending="saving"
|
:pending="saving"
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ async function submitTeam(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
|
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
|
||||||
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
|
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看号码布);种子等字段仍通过下方报名表导入填写。</p>
|
||||||
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -193,8 +193,10 @@ function downloadTemplate(): void {
|
|||||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
|
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
|
||||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||||
证件号与号码布都是人员唯一标识:
|
号码布是人员的唯一标识:先按号码布匹配;号码布为空或没匹配到,再按「姓名 + 单位」找——
|
||||||
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
|
单位内恰好一人且对方还没有号码布就命中并回填;恰好一人但对方已有不同的号码布,按「同名的另一个人」新建档案
|
||||||
|
(结果里会单独列出,不报错);多人则算歧义,列入冲突逐条决定。证件号是可选档案信息,只写入 / 更新、不参与匹配,
|
||||||
|
但一个人的证件号不能与库里另一个人相同,撞号会明确报错并指出占用者。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,11 +264,26 @@ function downloadTemplate(): void {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
|
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
|
||||||
|
|
||||||
|
<div v-if="result.sameNameNewPersons.length > 0" class="same-name">
|
||||||
|
<div class="errors-head">
|
||||||
|
<span class="mx-badge mx-badge-warning">
|
||||||
|
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(号码布不同)
|
||||||
|
</span>
|
||||||
|
<span class="bd-note">这些行已导入成功,仅提醒核对;若号码布填错,请到「运动员」里改回并删除多余档案。</span>
|
||||||
|
</div>
|
||||||
|
<ul class="error-list">
|
||||||
|
<li v-for="(person, index) in result.sameNameNewPersons" :key="index">
|
||||||
|
{{ person.name }}({{ person.teamCode }}):原有号码布 {{ person.existingBib }} → 新号码布 {{ person.newBib }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section v-if="conflicts.length > 0" class="conflicts">
|
<section v-if="conflicts.length > 0" class="conflicts">
|
||||||
<div class="conflicts-head">
|
<div class="conflicts-head">
|
||||||
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
|
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
|
||||||
<span class="bd-note">
|
<span class="bd-note">
|
||||||
同一证件号 / 号码布在库里与报名表里的姓名 / 性别 / 年龄 / 单位 / 号码布不一致,本轮未落库,也不建报名。
|
号码布(或姓名 + 单位)命中了库里已有人员,但姓名 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
|
||||||
|
本轮未落库,也不建报名。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
|
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
|
||||||
@@ -447,6 +464,9 @@ function downloadTemplate(): void {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.same-name {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
.error-list {
|
.error-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } fr
|
|||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
import FormField from './FormField.vue'
|
import FormField from './FormField.vue'
|
||||||
|
import { liveOnPublishOf } from '../system/systemApi'
|
||||||
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
|
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
@@ -79,6 +80,45 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── 分批结算方式:全局设置,同样只写 standings 子对象 ────────────────────
|
||||||
|
const liveOnPublish = ref(true)
|
||||||
|
const liveSaving = ref(false)
|
||||||
|
const liveError = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 库里现在的结算方式;settings 仅管理员可见,非管理员拿到 null。 */
|
||||||
|
const storedLiveOnPublish = computed<boolean | null>(() =>
|
||||||
|
data.settings.value === null ? null : liveOnPublishOf(data.settings.value),
|
||||||
|
)
|
||||||
|
const liveDirty = computed(
|
||||||
|
() => storedLiveOnPublish.value !== null && liveOnPublish.value !== storedLiveOnPublish.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 服务端设置变化(别处保存 / 规则包导入)时回填;本地已改动的勾选不被覆盖。
|
||||||
|
watch(
|
||||||
|
() => data.settings.value,
|
||||||
|
(value) => {
|
||||||
|
if (value === null) return
|
||||||
|
if (!liveDirty.value) liveOnPublish.value = liveOnPublishOf(value)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveLiveOnPublishSetting(): Promise<void> {
|
||||||
|
liveSaving.value = true
|
||||||
|
liveError.value = null
|
||||||
|
try {
|
||||||
|
await data.saveLiveOnPublish(liveOnPublish.value)
|
||||||
|
toast.success(
|
||||||
|
'结算方式已保存',
|
||||||
|
liveOnPublish.value ? '每发布一批就重算名次与榜单' : '等该组全部录完后才更新名次与榜单',
|
||||||
|
)
|
||||||
|
} catch (caught) {
|
||||||
|
liveError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
liveSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveWeight(): Promise<void> {
|
async function saveWeight(): Promise<void> {
|
||||||
const value = parsedWeight.value
|
const value = parsedWeight.value
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
@@ -441,6 +481,30 @@ const totalTop = computed(() => {
|
|||||||
placeholder="1"
|
placeholder="1"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label="分批发布时实时更新榜单"
|
||||||
|
:error="liveError"
|
||||||
|
hint="开(默认):每发布一批成绩就重算名次、各单位总分与奖牌榜。关掉 = 延迟结算:发布只落成绩,等该项目该组所有批次都录完才一起更新名次与榜单;没录完时可在成绩录入页点「立即结算」。"
|
||||||
|
>
|
||||||
|
<div class="settle-row">
|
||||||
|
<label class="bd-check">
|
||||||
|
<input
|
||||||
|
v-model="liveOnPublish"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="!isAdmin || liveSaving"
|
||||||
|
/>
|
||||||
|
发布后立即结算
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || !liveDirty || liveSaving"
|
||||||
|
@click="saveLiveOnPublishSetting"
|
||||||
|
>
|
||||||
|
{{ liveSaving ? '保存中…' : '保存结算方式' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<p class="bd-note">
|
<p class="bd-note">
|
||||||
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
|
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
|
||||||
@@ -512,4 +576,10 @@ const totalTop = computed(() => {
|
|||||||
.rules-table {
|
.rules-table {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
.settle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -18,6 +18,7 @@ import ConfirmDialog from '../ConfirmDialog.vue'
|
|||||||
import FormDialog from './FormDialog.vue'
|
import FormDialog from './FormDialog.vue'
|
||||||
import FormField from './FormField.vue'
|
import FormField from './FormField.vue'
|
||||||
import { useBaseData } from './useBaseData'
|
import { useBaseData } from './useBaseData'
|
||||||
|
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from './eventRowActions'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
import type { BaseDataTabKey } from './tabs'
|
import type { BaseDataTabKey } from './tabs'
|
||||||
|
|
||||||
@@ -26,6 +27,14 @@ const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
|
|||||||
const data = useBaseData()
|
const data = useBaseData()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
/** 双击小项条目:先选中它(两次单击不会抢走最终选中),再跳到「组别」分区。 */
|
||||||
|
function openCategories(eventId: number): void {
|
||||||
|
openEventCategories(eventId, {
|
||||||
|
selectEvent: (id) => void data.selectEvent(id),
|
||||||
|
goTab: (tab) => emit('goTab', tab),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,24}$/
|
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,24}$/
|
||||||
|
|
||||||
const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
|
const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
|
||||||
@@ -523,7 +532,9 @@ async function confirmDeleteEvent(): Promise<void> {
|
|||||||
:key="event.id"
|
:key="event.id"
|
||||||
:class="{ selected: event.id === data.selectedEventId.value }"
|
:class="{ selected: event.id === data.selectedEventId.value }"
|
||||||
class="clickable"
|
class="clickable"
|
||||||
|
:title="EVENT_ROW_DBLCLICK_TITLE"
|
||||||
@click="data.selectEvent(event.id)"
|
@click="data.selectEvent(event.id)"
|
||||||
|
@dblclick="openCategories(event.id)"
|
||||||
>
|
>
|
||||||
<td class="bd-mono">{{ event.code }}</td>
|
<td class="bd-mono">{{ event.code }}</td>
|
||||||
<td>{{ event.name }}</td>
|
<td>{{ event.name }}</td>
|
||||||
@@ -539,7 +550,7 @@ async function confirmDeleteEvent(): Promise<void> {
|
|||||||
<td class="right bd-mono">{{ event.multiplier }}</td>
|
<td class="right bd-mono">{{ event.multiplier }}</td>
|
||||||
<td>{{ labelOf(STATUS_OPTIONS, event.status) }}</td>
|
<td>{{ labelOf(STATUS_OPTIONS, event.status) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="bd-actions">
|
<div class="bd-actions" @dblclick.stop>
|
||||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click.stop="openEditEvent(event)">
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click.stop="openEditEvent(event)">
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
|
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
|
||||||
import type { AthletePurgeResult } from '@/shared/api'
|
import type { AthleteBatchAction, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||||
import type {
|
import type {
|
||||||
Athlete,
|
Athlete,
|
||||||
AthleteInput,
|
AthleteInput,
|
||||||
@@ -123,6 +123,18 @@ export const baseDataApi = {
|
|||||||
() => adminApi.client.events.saveCategories(eventId, inputs),
|
() => adminApi.client.events.saveCategories(eventId, inputs),
|
||||||
() => mockBaseData.saveCategories(eventId, inputs),
|
() => mockBaseData.saveCategories(eventId, inputs),
|
||||||
),
|
),
|
||||||
|
/**
|
||||||
|
* 单个组别的就地更新。
|
||||||
|
*
|
||||||
|
* 与 saveCategories 一样可以降级到示例数据(现场断网时页面的「示例数据」标记已经说明了一切),
|
||||||
|
* 但不让 404 降级:404 是「组别已不在」的真实回答,落到示例数据上会把「没改到」显示成「已更新」。
|
||||||
|
*/
|
||||||
|
updateCategory: (eventId: number, categoryId: number, input: EventCategoryInput): Promise<EventCategory[]> =>
|
||||||
|
call(
|
||||||
|
() => adminApi.client.events.updateCategory(eventId, categoryId, input),
|
||||||
|
() => mockBaseData.updateCategory(eventId, categoryId, input),
|
||||||
|
{ fallbackOn404: false },
|
||||||
|
),
|
||||||
saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise<ScoreChannel[]> =>
|
saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise<ScoreChannel[]> =>
|
||||||
call(
|
call(
|
||||||
() => adminApi.client.events.saveChannels(eventId, inputs),
|
() => adminApi.client.events.saveChannels(eventId, inputs),
|
||||||
@@ -158,6 +170,16 @@ export const baseDataApi = {
|
|||||||
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
|
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
|
||||||
fallbackOn404: false,
|
fallbackOn404: false,
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* 批量停用 / 批量彻底删除:一个请求完成整批,逐条成败在响应里。
|
||||||
|
*
|
||||||
|
* 与 purge 同理不让 404 降级:404 是「这批人已经被别人处理过」的真实回答,
|
||||||
|
* 落回示例数据会把「一个都没删掉」显示成「删掉了」。
|
||||||
|
*/
|
||||||
|
batch: (action: AthleteBatchAction, ids: number[], dryRun = false): Promise<AthleteBatchResult> =>
|
||||||
|
call(() => adminApi.client.athletes.batch(action, ids, dryRun), () => mockBaseData.batchAthletes(action, ids, dryRun), {
|
||||||
|
fallbackOn404: false,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
entries: {
|
entries: {
|
||||||
|
|||||||
@@ -317,19 +317,25 @@ function formatFieldValue(value: string | number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 冲突的决策键:证件号优先,没有证件号时用号码布。
|
* 冲突的决策键:号码布优先,号码布为空再回落证件号,两样都没有时回落姓名 + 单位。
|
||||||
*
|
*
|
||||||
* 与服务端 repo_import.go 的 decisionKey() 是同一个约定,两边必须保持一致;
|
* 号码布才是人员的唯一标识;证件号自「号码布优先匹配」之后不再参与匹配,
|
||||||
* 只有号码布的行证件号为空串,直接拿证件号当键会让多条冲突互相覆盖。
|
* 同一份表里两个不同的人可以共用一个证件号,拿它当键会让两条冲突共用一行决策
|
||||||
|
* (面板 :key 重复、勾选互相串)。
|
||||||
|
*
|
||||||
|
* 这个键只管面板内的分组与勾选:发给服务端的载荷带的是 idCard + bib 两个字段,
|
||||||
|
* 服务端按自己的 decisionKey()(repo_import.go)重算,键本身不出现在载荷里。
|
||||||
*/
|
*/
|
||||||
export function decisionKeyOf(conflict: ImportConflict): string {
|
export function decisionKeyOf(conflict: ImportConflict): string {
|
||||||
return conflict.idCard !== '' ? conflict.idCard : conflict.bib
|
if (conflict.bib !== '') return conflict.bib
|
||||||
|
if (conflict.idCard !== '') return conflict.idCard
|
||||||
|
return `${conflict.incoming.name}·${conflict.incoming.teamCode}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
|
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
|
||||||
export function conflictLabel(conflict: ImportConflict): string {
|
export function conflictLabel(conflict: ImportConflict): string {
|
||||||
const key = decisionKeyOf(conflict)
|
const key = decisionKeyOf(conflict)
|
||||||
return key === '' ? '(无证件号 / 号码布)' : key
|
return key === '' ? '(无号码布 / 证件号 / 姓名)' : key
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 小项列表「双击条目 → 跳到该小项的组别」的动作(纯函数)。
|
||||||
|
*
|
||||||
|
* 抽出来的原因:浏览器在 dblclick 之前必然先派发两次 click,两次都会改选中项,
|
||||||
|
* 「最终选中的到底是不是被双击的那一项」最容易写错;`.vue` 组件跑不进 node --test,
|
||||||
|
* 所以把「先选中 → 再跳分区」的顺序与目标放这里断言。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseDataTabKey } from './tabs'
|
||||||
|
|
||||||
|
/** 行上 title 里的可发现性文案:单击只选中,双击才跳转,不写出来没人会去试。 */
|
||||||
|
export const EVENT_ROW_DBLCLICK_TITLE = '双击查看该小项的组别'
|
||||||
|
|
||||||
|
export interface EventRowActionDeps {
|
||||||
|
/** 选中该小项(useBaseData.selectEvent)。 */
|
||||||
|
selectEvent: (id: number) => void
|
||||||
|
/** 跳转到基础数据页的某个分区。 */
|
||||||
|
goTab: (tab: BaseDataTabKey) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双击小项条目:先选中这一项,再跳到「组别」分区。
|
||||||
|
*
|
||||||
|
* 这里必须自己再选一次:dblclick 之前的两次 click 已经改过选中项(虽然就是同一行,
|
||||||
|
* 但顺序上不能依赖它),补一次同 id 的选中才能保证跳过去的是被双击的那一项。
|
||||||
|
* 两次 click 之间不引入任何延时,单击选中既有的即时手感不变。
|
||||||
|
*/
|
||||||
|
export function openEventCategories(eventId: number, deps: EventRowActionDeps): void {
|
||||||
|
deps.selectEvent(eventId)
|
||||||
|
deps.goTab('categories')
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||||
import type { AthletePurgeResult } from '@/shared/api'
|
import type { AthleteBatchAction, AthleteBatchItem, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||||
import type {
|
import type {
|
||||||
Athlete,
|
Athlete,
|
||||||
AthleteInput,
|
AthleteInput,
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
ImportConflict,
|
ImportConflict,
|
||||||
ImportConflictDecision,
|
ImportConflictDecision,
|
||||||
ImportConflictField,
|
ImportConflictField,
|
||||||
|
SameNameNewPerson,
|
||||||
ScoringProfile,
|
ScoringProfile,
|
||||||
ScoringProfileInput,
|
ScoringProfileInput,
|
||||||
ScoreChannel,
|
ScoreChannel,
|
||||||
@@ -187,6 +188,37 @@ const IMPORT_COLUMNS = MANDATORY_COLUMNS
|
|||||||
|
|
||||||
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冲突决策键:与服务端一致,证件号优先、为空时取号码布。
|
||||||
|
*
|
||||||
|
* 载荷里 idCard 与 bib 两个字段都带,服务端按自己的口径重算键(repo_import.go 的 decisionKey()),
|
||||||
|
* 所以这里必须与服务端同序,否则决策会落到另一条冲突上。
|
||||||
|
*/
|
||||||
|
function importDecisionKey(key: { idCard: string; bib: string }): string {
|
||||||
|
return key.idCard !== '' ? key.idCard : key.bib
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报名表里一行的人员信息(姓名已确定非空),供匹配逻辑使用。 */
|
||||||
|
interface ImportRow {
|
||||||
|
line: number
|
||||||
|
name: string
|
||||||
|
gender: Athlete['gender']
|
||||||
|
age: number | null
|
||||||
|
idCard: string
|
||||||
|
bib: string
|
||||||
|
team: Team
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人员匹配结果:冲突行不落库、等用户决策;error 是可直接回显的行级报错文案。
|
||||||
|
* sameNameNewPerson 只在「号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
|
||||||
|
*/
|
||||||
|
type AthleteMatch =
|
||||||
|
| { kind: 'reused' }
|
||||||
|
| { kind: 'conflict'; conflict: ImportConflict }
|
||||||
|
| { kind: 'created'; sameNameNewPerson?: SameNameNewPerson }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
/** 导出类本身供测试构造隔离实例;页面用下方单例。 */
|
/** 导出类本身供测试构造隔离实例;页面用下方单例。 */
|
||||||
export class MockBaseData {
|
export class MockBaseData {
|
||||||
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
|
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
|
||||||
@@ -365,7 +397,9 @@ export class MockBaseData {
|
|||||||
|
|
||||||
saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] {
|
saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] {
|
||||||
this.getEvent(eventId)
|
this.getEvent(eventId)
|
||||||
if ((this.entryCounts.get(eventId) ?? 0) > 0) throw new Error('该项目已有报名数据,不能整体替换组别')
|
if ((this.entryCounts.get(eventId) ?? 0) > 0) {
|
||||||
|
throw new Error('该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存')
|
||||||
|
}
|
||||||
const names = new Set<string>()
|
const names = new Set<string>()
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const name = input.name.trim()
|
const name = input.name.trim()
|
||||||
@@ -380,6 +414,33 @@ export class MockBaseData {
|
|||||||
return this.getEvent(eventId).categories ?? []
|
return this.getEvent(eventId).categories ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 就地更新单个组别:与真实服务端一致,id 不变、只改字段,所以有报名数据也允许改。
|
||||||
|
*
|
||||||
|
* 增删组别仍走 saveCategories(示例数据里同样会被报名数据挡住)。
|
||||||
|
* 真实服务端在权重变化时会重算已发布成绩的积分与榜单,示例数据没有建模流水与榜单,
|
||||||
|
* 没有可重算的东西——页面照样能拿到更新后的组别列表。
|
||||||
|
*/
|
||||||
|
updateCategory(eventId: number, categoryId: number, input: EventCategoryInput): EventCategory[] {
|
||||||
|
this.getEvent(eventId)
|
||||||
|
const index = this.categories.findIndex((item) => item.id === categoryId && item.eventId === eventId)
|
||||||
|
if (index < 0) throw new Error('组别不存在')
|
||||||
|
const name = input.name.trim()
|
||||||
|
if (name === '') throw new Error('组别名称必填')
|
||||||
|
if (this.categories.some((item) => item.eventId === eventId && item.id !== categoryId && item.name === name)) {
|
||||||
|
throw new Error(`组别名称重复:${name}`)
|
||||||
|
}
|
||||||
|
this.categories[index] = {
|
||||||
|
...input,
|
||||||
|
name,
|
||||||
|
// 服务端口径:权重 <= 0 一律按 1 存。
|
||||||
|
multiplier: input.multiplier > 0 ? input.multiplier : 1,
|
||||||
|
id: categoryId,
|
||||||
|
eventId,
|
||||||
|
}
|
||||||
|
return this.getEvent(eventId).categories ?? []
|
||||||
|
}
|
||||||
|
|
||||||
saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] {
|
saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] {
|
||||||
this.getEvent(eventId)
|
this.getEvent(eventId)
|
||||||
const combinations = new Set<string>()
|
const combinations = new Set<string>()
|
||||||
@@ -498,6 +559,39 @@ export class MockBaseData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量停用 / 批量彻底删除:与真实服务端同形,逐条回报成败。
|
||||||
|
*
|
||||||
|
* 不存在的 id 按 `NOT_FOUND` 单条失败,不能整批失败也不能静默跳过;dryRun 只回报不改数据。
|
||||||
|
* 示例数据没有把报名、成绩与流水建模到具体人员名下(只有每个小项的报名条数),
|
||||||
|
* 所以彻底删除的统计都是 0、也没有小项需要重算——与单条 purgeAthlete 的口径一致。
|
||||||
|
*/
|
||||||
|
batchAthletes(action: AthleteBatchAction, ids: number[], dryRun = false): AthleteBatchResult {
|
||||||
|
const items: AthleteBatchItem[] = ids.map((id) => {
|
||||||
|
if (this.athletes.some((athlete) => athlete.id === id)) return { id, ok: true }
|
||||||
|
return { id, ok: false, code: 'NOT_FOUND', message: '运动员不存在' }
|
||||||
|
})
|
||||||
|
if (!dryRun) {
|
||||||
|
const found = new Set(ids)
|
||||||
|
this.athletes =
|
||||||
|
action === 'disable'
|
||||||
|
? this.athletes.map((athlete) => (found.has(athlete.id) ? { ...athlete, active: false } : athlete))
|
||||||
|
: this.athletes.filter((athlete) => !found.has(athlete.id))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
...(dryRun ? { dryRun: true } : {}),
|
||||||
|
requested: ids.length,
|
||||||
|
succeeded: items.filter((item) => item.ok).length,
|
||||||
|
failed: items.filter((item) => !item.ok).length,
|
||||||
|
items,
|
||||||
|
purge:
|
||||||
|
action === 'purge'
|
||||||
|
? { deletedEntries: 0, deletedResults: 0, deletedLedger: 0, deletedBreaks: 0, recomputedEvents: [] }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 报名导入 ──────────────────────────────────────────────────────────────
|
// ── 报名导入 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async importEntries(file: File): Promise<EntryImportResult> {
|
async importEntries(file: File): Promise<EntryImportResult> {
|
||||||
@@ -506,14 +600,11 @@ export class MockBaseData {
|
|||||||
|
|
||||||
/** 冲突决策重跑:没给决策的键仍视为未决策,继续留在返回的 conflicts 里。 */
|
/** 冲突决策重跑:没给决策的键仍视为未决策,继续留在返回的 conflicts 里。 */
|
||||||
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
|
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
|
||||||
// 与服务端一致:证件号优先,没有证件号时用号码布当决策键。
|
return this.runImport(file, new Map(decisions.map((decision) => [importDecisionKey(decision), decision.action])))
|
||||||
const keyOf = (decision: ImportConflictDecision): string =>
|
|
||||||
decision.idCard !== '' ? decision.idCard : decision.bib
|
|
||||||
return this.runImport(file, new Map(decisions.map((decision) => [keyOf(decision), decision.action])))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
|
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
|
||||||
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [] })
|
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [], sameNameNewPersons: [] })
|
||||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||||
if (extension !== '.csv') return empty([XLSX_HINT])
|
if (extension !== '.csv') return empty([XLSX_HINT])
|
||||||
const rows = parseCsv(await file.text())
|
const rows = parseCsv(await file.text())
|
||||||
@@ -524,8 +615,7 @@ export class MockBaseData {
|
|||||||
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
|
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [], sameNameNewPersons: [] }
|
||||||
const seenIdCards = new Set<string>()
|
|
||||||
const seenBibs = new Set<string>()
|
const seenBibs = new Set<string>()
|
||||||
rows.slice(1).forEach((row, offset) => {
|
rows.slice(1).forEach((row, offset) => {
|
||||||
const line = offset + 2
|
const line = offset + 2
|
||||||
@@ -578,114 +668,171 @@ export class MockBaseData {
|
|||||||
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号或号码布`)
|
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号或号码布`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占证件号 / 号码布名额。
|
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占号码布名额。
|
||||||
const age = parseAge(cell('age'))
|
const age = parseAge(cell('age'))
|
||||||
if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) {
|
if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) {
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
|
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (idCard !== '' && seenIdCards.has(idCard)) {
|
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||||
result.skipped += 1
|
// 证件号只是可选档案信息,同一份表内重复不再拦截(与服务端口径一致)。
|
||||||
result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bib !== '' && seenBibs.has(bib)) {
|
if (bib !== '' && seenBibs.has(bib)) {
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
|
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (idCard !== '') seenIdCards.add(idCard)
|
|
||||||
if (bib !== '') seenBibs.add(bib)
|
if (bib !== '') seenBibs.add(bib)
|
||||||
const rawGender = cell('gender')
|
const rawGender = cell('gender')
|
||||||
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
|
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
|
||||||
// 契约匹配顺序:证件号 → 号码布 → 姓名 + 单位(库里证件号为空)。
|
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team }, decisions)
|
||||||
const matched =
|
if (match.kind === 'conflict') {
|
||||||
(idCard === '' ? undefined : this.athletes.find((athlete) => athlete.idCard === idCard)) ??
|
result.conflicts.push(match.conflict)
|
||||||
(bib === '' ? undefined : this.athletes.find((athlete) => athlete.bib === bib))
|
|
||||||
if (matched !== undefined) {
|
|
||||||
const conflict = this.detectConflict(matched, line, idCard, bib, athleteName, gender, age, team)
|
|
||||||
const key = idCard !== '' ? idCard : bib
|
|
||||||
if (conflict !== null && decisions.get(key) === undefined) {
|
|
||||||
result.conflicts.push(conflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (conflict !== null && decisions.get(key) === 'keep-new') {
|
|
||||||
matched.name = athleteName
|
|
||||||
if (gender !== null) matched.gender = gender
|
|
||||||
if (age !== null) matched.age = age
|
|
||||||
matched.teamId = team.id
|
|
||||||
if (idCard !== '') matched.idCard = idCard
|
|
||||||
if (bib !== '') matched.bib = bib
|
|
||||||
}
|
|
||||||
// 库里没有年龄时按表里的值回填,已有年龄一律不动。
|
|
||||||
if (age !== null && matched.age === null) matched.age = age
|
|
||||||
result.created += 1
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 证件号 / 号码布未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
|
if (match.kind === 'error') {
|
||||||
const legacy = this.athletes.find(
|
result.skipped += 1
|
||||||
(athlete) =>
|
result.errors.push(`第 ${line} 行:${match.message}`)
|
||||||
athlete.name === athleteName &&
|
|
||||||
athlete.teamId === team.id &&
|
|
||||||
(athlete.idCard === null || athlete.idCard === ''),
|
|
||||||
)
|
|
||||||
if (legacy !== undefined) {
|
|
||||||
const conflict = this.detectConflict(legacy, line, idCard, bib, athleteName, gender, age, team)
|
|
||||||
const legacyKey = idCard !== '' ? idCard : bib
|
|
||||||
if (conflict !== null && decisions.get(legacyKey) === undefined) {
|
|
||||||
result.conflicts.push(conflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && bib !== '') legacy.bib = bib
|
|
||||||
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && age !== null) legacy.age = age
|
|
||||||
if (idCard !== '') legacy.idCard = idCard
|
|
||||||
if (bib !== '' && (legacy.bib === null || legacy.bib === '')) legacy.bib = bib
|
|
||||||
// 其余情况只补库中为空的年龄,绝不用空值清掉已有值。
|
|
||||||
if (age !== null && legacy.age === null) legacy.age = age
|
|
||||||
result.created += 1
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.athletes.push({
|
if (match.kind === 'created' && match.sameNameNewPerson !== undefined) {
|
||||||
id: (this.nextAthleteId += 1),
|
result.sameNameNewPersons.push(match.sameNameNewPerson)
|
||||||
name: athleteName,
|
}
|
||||||
gender,
|
|
||||||
teamId: team.id,
|
|
||||||
employeeNo: null,
|
|
||||||
idCard: idCard === '' ? null : idCard,
|
|
||||||
bib: bib === '' ? null : bib,
|
|
||||||
age,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
result.created += 1
|
result.created += 1
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** name / gender / age / teamCode / bib 与库里不一致才算冲突;表里为空的字段视为未指定。 */
|
/**
|
||||||
private detectConflict(
|
* 人员匹配:号码布精确命中 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
|
||||||
athlete: Athlete,
|
*
|
||||||
row: number,
|
* 证件号不参与匹配(只是可选档案信息),只在新建 / 回填 / keep-new 时写入;
|
||||||
idCard: string,
|
* 撞到别人的证件号仍然是行级报错,不允许静默改库或静默跳过。
|
||||||
bib: string,
|
*/
|
||||||
name: string,
|
private matchAthlete(row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
|
||||||
gender: Athlete['gender'],
|
if (row.bib !== '') {
|
||||||
age: number | null,
|
const byBib = this.athletes.find((athlete) => athlete.bib === row.bib)
|
||||||
team: Team,
|
if (byBib !== undefined) return this.reuseOrConflict(byBib, row, decisions)
|
||||||
): ImportConflict | null {
|
}
|
||||||
|
const candidates = this.athletes.filter(
|
||||||
|
(athlete) => athlete.name === row.name && athlete.teamId === row.team.id,
|
||||||
|
)
|
||||||
|
if (candidates.length === 0) return this.createImportedAthlete(row)
|
||||||
|
const first = candidates[0] as Athlete
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
// 同名同单位有多个人:不能替用户挑人,没决策就进面板(与服务端一样以第一条候选为代表)。
|
||||||
|
// 字段全对得上时也拦:这里认不出「是哪一个人」,猜错就会改错档案。
|
||||||
|
if (decisions.has(importDecisionKey(row))) return this.reuseOrConflict(first, row, decisions)
|
||||||
|
return { kind: 'conflict', conflict: this.buildConflict(first, row, this.changedFields(first, row)) }
|
||||||
|
}
|
||||||
|
// 表里也给了号码布、且与库里不同 → 同名的另一个人:新建并报告,不拦截导入。
|
||||||
|
if (first.bib !== null && first.bib !== '' && row.bib !== '' && first.bib !== row.bib) {
|
||||||
|
const created = this.createImportedAthlete(row)
|
||||||
|
if (created.kind === 'created') {
|
||||||
|
created.sameNameNewPerson = {
|
||||||
|
name: row.name,
|
||||||
|
teamCode: row.team.code,
|
||||||
|
newBib: row.bib,
|
||||||
|
existingBib: first.bib,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
return this.reuseOrConflict(first, row, decisions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 命中已有人员:无差异直接复用并回填空字段;有差异时按决策落库,没决策就进冲突面板。 */
|
||||||
|
private reuseOrConflict(ex: Athlete, row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
|
||||||
|
const conflict = this.conflictFor(ex, row)
|
||||||
|
if (conflict === null) {
|
||||||
|
const taken = this.backfillAthlete(ex, row)
|
||||||
|
return taken === null ? { kind: 'reused' } : { kind: 'error', message: taken }
|
||||||
|
}
|
||||||
|
const action = decisions.get(importDecisionKey(conflict))
|
||||||
|
if (action === undefined) return { kind: 'conflict', conflict }
|
||||||
|
// keep-existing:人员字段保持库里的值;keep-new:用表里的字段覆盖(表里没填的保留)。
|
||||||
|
if (action === 'keep-new') {
|
||||||
|
const taken = this.idCardTakenError(row.idCard, ex.id)
|
||||||
|
if (taken !== null) return { kind: 'error', message: taken }
|
||||||
|
ex.name = row.name
|
||||||
|
if (row.gender !== null) ex.gender = row.gender
|
||||||
|
if (row.age !== null) ex.age = row.age
|
||||||
|
ex.teamId = row.team.id
|
||||||
|
if (row.idCard !== '') ex.idCard = row.idCard
|
||||||
|
if (row.bib !== '') ex.bib = row.bib
|
||||||
|
}
|
||||||
|
return { kind: 'reused' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只补库中为空的证件号 / 号码布 / 年龄,已有值不动;证件号撞别人时回报行级错误。 */
|
||||||
|
private backfillAthlete(ex: Athlete, row: ImportRow): string | null {
|
||||||
|
if (row.idCard !== '' && (ex.idCard === null || ex.idCard === '')) {
|
||||||
|
const taken = this.idCardTakenError(row.idCard, ex.id)
|
||||||
|
if (taken !== null) return taken
|
||||||
|
ex.idCard = row.idCard
|
||||||
|
}
|
||||||
|
if (row.bib !== '' && (ex.bib === null || ex.bib === '')) ex.bib = row.bib
|
||||||
|
if (row.age !== null && ex.age === null) ex.age = row.age
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建人员档案;证件号撞到别人时只回报错误,不落库。 */
|
||||||
|
private createImportedAthlete(row: ImportRow): AthleteMatch {
|
||||||
|
const taken = this.idCardTakenError(row.idCard, 0)
|
||||||
|
if (taken !== null) return { kind: 'error', message: taken }
|
||||||
|
this.athletes.push({
|
||||||
|
id: (this.nextAthleteId += 1),
|
||||||
|
name: row.name,
|
||||||
|
gender: row.gender,
|
||||||
|
teamId: row.team.id,
|
||||||
|
employeeNo: null,
|
||||||
|
idCard: row.idCard === '' ? null : row.idCard,
|
||||||
|
bib: row.bib === '' ? null : row.bib,
|
||||||
|
age: row.age,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
return { kind: 'created' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证件号不参与匹配,但仍一人一号:撞到别人时报出占用者是谁。
|
||||||
|
* 文案与服务端 idCardTakenError 一致,现场在降级模式与真实服务端看到的是同一句话。
|
||||||
|
*/
|
||||||
|
private idCardTakenError(idCard: string, excludeId: number): string | null {
|
||||||
|
if (idCard === '') return null
|
||||||
|
const taken = this.athletes.find((athlete) => athlete.idCard === idCard && athlete.id !== excludeId)
|
||||||
|
if (taken === undefined) return null
|
||||||
|
const bib = taken.bib === null || taken.bib === '' ? '未填' : taken.bib
|
||||||
|
return `证件号 ${idCard} 已被另一个人占用(姓名 ${taken.name},号码布 ${bib})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把「库中人员 + 表里这一行」包成冲突对象,没有差异时返回 null。 */
|
||||||
|
private conflictFor(athlete: Athlete, row: ImportRow): ImportConflict | null {
|
||||||
|
const changed = this.changedFields(athlete, row)
|
||||||
|
return changed.length === 0 ? null : this.buildConflict(athlete, row, changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** name / gender / age / teamCode / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
|
||||||
|
private changedFields(athlete: Athlete, row: ImportRow): ImportConflictField[] {
|
||||||
const changed: ImportConflictField[] = []
|
const changed: ImportConflictField[] = []
|
||||||
if (athlete.name !== name) changed.push('name')
|
if (athlete.name !== row.name) changed.push('name')
|
||||||
if (gender !== null && athlete.gender !== gender) changed.push('gender')
|
if (row.gender !== null && athlete.gender !== row.gender) changed.push('gender')
|
||||||
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
|
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
|
||||||
if (age !== null && athlete.age !== null && athlete.age !== age) changed.push('age')
|
if (row.age !== null && athlete.age !== null && athlete.age !== row.age) changed.push('age')
|
||||||
if (athlete.teamId !== team.id) changed.push('teamCode')
|
if (athlete.teamId !== row.team.id) changed.push('teamCode')
|
||||||
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
|
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
|
||||||
if (bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== bib) changed.push('bib')
|
if (row.bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== row.bib) {
|
||||||
if (changed.length === 0) return null
|
changed.push('bib')
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冲突对象;changed 为空也算冲突(同名同单位认不出是哪一个,同样要进面板等决策)。 */
|
||||||
|
private buildConflict(athlete: Athlete, row: ImportRow, changed: ImportConflictField[]): ImportConflict {
|
||||||
return {
|
return {
|
||||||
row,
|
row: row.line,
|
||||||
idCard,
|
idCard: row.idCard,
|
||||||
bib,
|
bib: row.bib,
|
||||||
athleteId: athlete.id,
|
athleteId: athlete.id,
|
||||||
existing: {
|
existing: {
|
||||||
name: athlete.name,
|
name: athlete.name,
|
||||||
@@ -695,7 +842,14 @@ export class MockBaseData {
|
|||||||
employeeNo: athlete.employeeNo,
|
employeeNo: athlete.employeeNo,
|
||||||
bib: athlete.bib,
|
bib: athlete.bib,
|
||||||
},
|
},
|
||||||
incoming: { name, gender, age, teamCode: team.code, employeeNo: null, bib: bib === '' ? null : bib },
|
incoming: {
|
||||||
|
name: row.name,
|
||||||
|
gender: row.gender,
|
||||||
|
age: row.age,
|
||||||
|
teamCode: row.team.code,
|
||||||
|
employeeNo: null,
|
||||||
|
bib: row.bib === '' ? null : row.bib,
|
||||||
|
},
|
||||||
changedFields: changed,
|
changedFields: changed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,23 @@ async function saveIndividualToTeamWeight(weight: number): Promise<EventSettings
|
|||||||
return saved
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存「分批发布时是否实时更新榜单」(standings.live_on_publish,服务端字段 liveOnPublish)。
|
||||||
|
*
|
||||||
|
* 与折算系数一样只提交完整的 standings 子对象:漏传的字段会被服务端保持原值,
|
||||||
|
* 但整体回传能保证前端拿到的设置与服务端口径一致。shared 的 EventSettings 还没带这个字段,
|
||||||
|
* 所以这里按扩展字段拼装;回写 event store 后开关与录入页提示立刻同步。
|
||||||
|
*/
|
||||||
|
async function saveLiveOnPublish(live: boolean): Promise<EventSettings> {
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
const current = eventStore.settings
|
||||||
|
if (current === null) throw new Error('赛事设置未加载,无法保存结算方式')
|
||||||
|
const standings = { ...current.standings, liveOnPublish: live }
|
||||||
|
const saved = await adminApi.client.settings.update({ standings })
|
||||||
|
eventStore.settings = saved
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
export interface BaseDataController {
|
export interface BaseDataController {
|
||||||
teams: Ref<Team[]>
|
teams: Ref<Team[]>
|
||||||
sports: Ref<Sport[]>
|
sports: Ref<Sport[]>
|
||||||
@@ -329,6 +346,8 @@ export interface BaseDataController {
|
|||||||
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
|
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
|
||||||
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
|
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
|
||||||
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
|
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
|
||||||
|
/** 只改「分批发布是否实时更新榜单」;保存后同步 event store。 */
|
||||||
|
saveLiveOnPublish: (live: boolean) => Promise<EventSettings>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBaseData(): BaseDataController {
|
export function useBaseData(): BaseDataController {
|
||||||
@@ -377,5 +396,6 @@ export function useBaseData(): BaseDataController {
|
|||||||
importEntries,
|
importEntries,
|
||||||
resolveImportConflicts,
|
resolveImportConflicts,
|
||||||
saveIndividualToTeamWeight,
|
saveIndividualToTeamWeight,
|
||||||
|
saveLiveOnPublish,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import { describeError, sessionSettingsApi } from './systemApi'
|
||||||
|
import {
|
||||||
|
SESSION_HOURS_FALLBACK,
|
||||||
|
describeSessionLifetime,
|
||||||
|
formatSessionExpiry,
|
||||||
|
validateSessionHours,
|
||||||
|
type SessionHoursBounds,
|
||||||
|
} from './sessionSettings'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录有效期(GET/PUT /api/system/session)。
|
||||||
|
*
|
||||||
|
* 会话寿命是机器级配置:它决定这台机器上所有人的登录能待多久,
|
||||||
|
* 所以放在「系统设置」里,而不是某个赛事的信息页。
|
||||||
|
* admin-only(服务端两个接口都限 admin),非 admin 不挂载本面板。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const input = ref<string>('')
|
||||||
|
const saved = ref<number | null>(null)
|
||||||
|
const bounds = ref<SessionHoursBounds>({
|
||||||
|
minHours: SESSION_HOURS_FALLBACK.minHours,
|
||||||
|
maxHours: SESSION_HOURS_FALLBACK.maxHours,
|
||||||
|
})
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
const saveError = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 当前会话的到期时间优先取 /auth/me(登录时与心跳时都会刷新),拿不到再退回本接口下发的值。 */
|
||||||
|
const expiresAt = computed(() => auth.sessionExpiresAt ?? remoteExpiresAt.value)
|
||||||
|
const remoteExpiresAt = ref<string | null>(null)
|
||||||
|
const expiryText = computed(() => formatSessionExpiry(expiresAt.value))
|
||||||
|
|
||||||
|
const check = computed(() => validateSessionHours(input.value, bounds.value))
|
||||||
|
const checkOk = computed(() => check.value.ok)
|
||||||
|
const checkMessage = computed(() => (check.value.ok ? null : check.value.message))
|
||||||
|
const candidateHours = computed(() => (check.value.ok ? check.value.hours : null))
|
||||||
|
const dirty = computed(() => candidateHours.value !== null && candidateHours.value !== saved.value)
|
||||||
|
const hint = computed(() =>
|
||||||
|
candidateHours.value === null
|
||||||
|
? '登录后能待多久:有效期内继续操作会自动顺延,不用中途重新登录。'
|
||||||
|
: describeSessionLifetime(candidateHours.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const settings = await sessionSettingsApi.load()
|
||||||
|
bounds.value = { minHours: settings.minHours, maxHours: settings.maxHours }
|
||||||
|
saved.value = settings.sessionHours
|
||||||
|
input.value = String(settings.sessionHours)
|
||||||
|
remoteExpiresAt.value = settings.expiresAt
|
||||||
|
loadError.value = null
|
||||||
|
} catch (caught) {
|
||||||
|
loadError.value = describeError(caught)
|
||||||
|
input.value = input.value === '' ? String(SESSION_HOURS_FALLBACK.defaultHours) : input.value
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
const hours = candidateHours.value
|
||||||
|
if (hours === null) {
|
||||||
|
saveError.value = checkMessage.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
saveError.value = null
|
||||||
|
try {
|
||||||
|
const settings = await sessionSettingsApi.save(hours)
|
||||||
|
saved.value = settings.sessionHours
|
||||||
|
input.value = String(settings.sessionHours)
|
||||||
|
remoteExpiresAt.value = settings.expiresAt
|
||||||
|
toast.success('登录有效期已保存', '对之后的新登录生效,已登录的会话不受影响')
|
||||||
|
} catch (caught) {
|
||||||
|
saveError.value = describeError(caught)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">登录有效期</span>
|
||||||
|
<div class="head-actions">
|
||||||
|
<span v-if="dirty" class="mx-badge mx-badge-warning">未保存</span>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="loading || saving || !checkOk || !dirty"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
<MxIcon name="check" :size="14" /> {{ saving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="notice">{{ expiryText }}</p>
|
||||||
|
<p v-if="loadError !== null" class="notice error">读取失败:{{ loadError }}</p>
|
||||||
|
<p v-if="saveError !== null" class="notice error">{{ saveError }}</p>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="session-hours">登录有效期(小时)</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input
|
||||||
|
id="session-hours"
|
||||||
|
v-model="input"
|
||||||
|
class="mx-input"
|
||||||
|
type="number"
|
||||||
|
:min="bounds.minHours"
|
||||||
|
:max="bounds.maxHours"
|
||||||
|
step="1"
|
||||||
|
:disabled="loading || saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="field-hint">
|
||||||
|
范围 {{ bounds.minHours }}~{{ bounds.maxHours }} 小时(最长 30 天),默认
|
||||||
|
{{ SESSION_HOURS_FALLBACK.defaultHours }} 小时。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="mx-label">说明</span>
|
||||||
|
<p class="hint-text" :class="{ invalid: !checkOk }">{{ hint }}</p>
|
||||||
|
<p v-if="checkMessage !== null" class="field-hint error">{{ checkMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
只对之后的新登录生效:已经登录的会话保持各自的到期时间,不会被踢下线;
|
||||||
|
有效期内继续操作会自动顺延到期时间,因此从早用到晚不会中途掉线。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mx-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.field-hint.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
}
|
||||||
|
.hint-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.hint-text.invalid {
|
||||||
|
color: var(--mx-error);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { CurrentUser } from '@/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录有效期(系统级设置)的纯函数:范围校验与到期时间显示。
|
||||||
|
*
|
||||||
|
* 抽成不依赖 Vue 的模块,是为了能在 node --test 下直接测到边界(0、721、小数、空串),
|
||||||
|
* 而不必挂载组件;面板与用户菜单共用同一份格式化逻辑,避免两处显示不一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 服务端 /api/system/session 未就绪时的兜底范围,与 auth.MinSessionHours / MaxSessionHours 一致。 */
|
||||||
|
export const SESSION_HOURS_FALLBACK = { minHours: 1, maxHours: 720, defaultHours: 24 } as const
|
||||||
|
|
||||||
|
export interface SessionHoursBounds {
|
||||||
|
minHours: number
|
||||||
|
maxHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionHoursCheck = { ok: true; hours: number } | { ok: false; message: string }
|
||||||
|
|
||||||
|
/** 校验面板输入的登录有效期(整数小时)。服务端仍会独立校验一遍,这里只是为了当场给出中文提示。 */
|
||||||
|
export function validateSessionHours(raw: string | number, bounds: SessionHoursBounds): SessionHoursCheck {
|
||||||
|
const text = typeof raw === 'number' ? String(raw) : raw.trim()
|
||||||
|
if (text === '') return { ok: false, message: '请填写登录有效期(小时)' }
|
||||||
|
if (!/^\d+$/.test(text)) return { ok: false, message: '登录有效期只能是整数小时' }
|
||||||
|
const hours = Number(text)
|
||||||
|
if (hours < bounds.minHours || hours > bounds.maxHours) {
|
||||||
|
return { ok: false, message: `登录有效期需在 ${bounds.minHours}~${bounds.maxHours} 小时之间` }
|
||||||
|
}
|
||||||
|
return { ok: true, hours }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把小时数说成人话,现场一眼能看出「这是登录后能待多久」。 */
|
||||||
|
export function describeSessionLifetime(hours: number): string {
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) return '登录有效期未设置'
|
||||||
|
const days = hours / 24
|
||||||
|
const dayText = Number.isInteger(days) ? `${days} 天` : `${days.toFixed(1)} 天`
|
||||||
|
return `登录后最长可保持 ${hours} 小时(${dayText})不用重新登录;期间只要继续操作,到期时间会自动顺延。`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 /auth/me 的响应里取当前会话的到期时间。
|
||||||
|
*
|
||||||
|
* CurrentUser 类型定义在 shared/types(本轮不改共享包),字段是附加下发的,
|
||||||
|
* 所以这里按可选字段读,值不合法时一律当「没有」处理。
|
||||||
|
*/
|
||||||
|
export function readSessionExpiresAt(
|
||||||
|
user: (CurrentUser & { sessionExpiresAt?: unknown }) | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const raw = user?.sessionExpiresAt
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return null
|
||||||
|
return Number.isNaN(new Date(raw).getTime()) ? null : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value: number): string {
|
||||||
|
return String(value).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把服务端下发的 RFC3339(UTC)到期时间渲染成本机时区的「当前会话将于 2026-09-12 08:31 过期」。
|
||||||
|
* 服务端只给带时区的时间串,换算成本机时间由前端做——现场看的是自己电脑上的钟。
|
||||||
|
*/
|
||||||
|
export function formatSessionExpiry(iso: string | null | undefined): string {
|
||||||
|
if (iso === null || iso === undefined || iso === '') return '当前会话的到期时间未知'
|
||||||
|
const at = new Date(iso)
|
||||||
|
if (Number.isNaN(at.getTime())) return '当前会话的到期时间未知'
|
||||||
|
const text = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
|
||||||
|
`${pad(at.getHours())}:${pad(at.getMinutes())}`
|
||||||
|
return `当前会话将于 ${text} 过期`
|
||||||
|
}
|
||||||
@@ -879,6 +879,18 @@ export const rulesApi = {
|
|||||||
|
|
||||||
export type EventSettingsPatch = Partial<EventSettings>
|
export type EventSettingsPatch = Partial<EventSettings>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批结算开关的取值。
|
||||||
|
*
|
||||||
|
* shared/types 的 EventSettings 还没收敛这个字段(改共享类型会影响大屏),
|
||||||
|
* 所以在管理端按可选字段读取:旧服务端 / 示例数据不带它时按「实时更新」处理,
|
||||||
|
* 与 Go 侧 ParseEventSettings 的默认值一致。
|
||||||
|
*/
|
||||||
|
export function liveOnPublishOf(settings: EventSettings | null | undefined): boolean {
|
||||||
|
const value = (settings?.standings as { liveOnPublish?: unknown } | undefined)?.liveOnPublish
|
||||||
|
return typeof value === 'boolean' ? value : true
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
async save(patch: EventSettingsPatch): Promise<MockAware<EventSettings>> {
|
async save(patch: EventSettingsPatch): Promise<MockAware<EventSettings>> {
|
||||||
return withMockInfo(
|
return withMockInfo(
|
||||||
@@ -894,6 +906,104 @@ export const settingsApi = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分批结算(GET /api/results/settlement、POST /api/results/settle)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 未结算的 (小项, 组别):延迟结算下已发布成绩还没进榜单的那一组。 */
|
||||||
|
export interface SettlementGroup {
|
||||||
|
eventId: number
|
||||||
|
categoryId: number
|
||||||
|
categoryName: string
|
||||||
|
/** 该出成绩的报名项数(弃权 / 失格不计入)。 */
|
||||||
|
required: number
|
||||||
|
/** 其中已经有可发布成绩的条数。 */
|
||||||
|
published: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettlementStatus {
|
||||||
|
/** false = 延迟结算:发布只落成绩,等整组录完才更新榜单。 */
|
||||||
|
liveOnPublish: boolean
|
||||||
|
groups: SettlementGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettlementStatus(raw: unknown): SettlementStatus {
|
||||||
|
if (!isRecord(raw)) throw new ApiError('PARSE_FAILED', '结算状态格式不符', 0, raw)
|
||||||
|
const groups = Array.isArray(raw.groups) ? raw.groups : []
|
||||||
|
return {
|
||||||
|
liveOnPublish: raw.liveOnPublish !== false,
|
||||||
|
groups: groups.filter(isRecord).map((item) => ({
|
||||||
|
eventId: readNumber(item, 'eventId') ?? 0,
|
||||||
|
categoryId: readNumber(item, 'categoryId') ?? 0,
|
||||||
|
categoryName: readString(item, 'categoryName') ?? '',
|
||||||
|
required: readNumber(item, 'required') ?? 0,
|
||||||
|
published: readNumber(item, 'published') ?? 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settlementApi = {
|
||||||
|
/** 查未结算清单;这是提示用的辅助信息,读失败由调用方决定是否忽略。 */
|
||||||
|
async status(eventId: number, categoryId?: number | null): Promise<SettlementStatus> {
|
||||||
|
const body = await requestJson<unknown>('/results/settlement', {
|
||||||
|
query: { eventId, categoryId: categoryId ?? undefined },
|
||||||
|
})
|
||||||
|
return readSettlementStatus(body)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 立即结算:按当前已发布成绩重算积分流水并刷新榜单(写操作不降级)。 */
|
||||||
|
async settle(eventId: number, categoryId?: number | null): Promise<void> {
|
||||||
|
await requestJson<unknown>('/results/settle', {
|
||||||
|
method: 'POST',
|
||||||
|
json: { eventId, categoryId: categoryId ?? null },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 登录有效期(系统级设置:GET/PUT /api/system/session)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SessionSettings {
|
||||||
|
sessionHours: number
|
||||||
|
defaultSessionHours: number
|
||||||
|
minHours: number
|
||||||
|
maxHours: number
|
||||||
|
/** 当前会话的到期时间(RFC3339 UTC);未登录为 null。 */
|
||||||
|
expiresAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录有效期存在系统库(system.db),不是赛事级 /api/settings:
|
||||||
|
* 换一个赛事项目,这台机器上所有人的会话寿命不该跟着变。
|
||||||
|
*
|
||||||
|
* 读写都不降级示例数据:假的「已保存」会让管理员以为改完就生效了。
|
||||||
|
*/
|
||||||
|
export const sessionSettingsApi = {
|
||||||
|
async load(): Promise<SessionSettings> {
|
||||||
|
return toSessionSettings(await requestJson<unknown>('/system/session'))
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(hours: number): Promise<SessionSettings> {
|
||||||
|
return toSessionSettings(
|
||||||
|
await requestJson<unknown>('/system/session', { method: 'PUT', json: { sessionHours: hours } }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSessionSettings(value: unknown): SessionSettings {
|
||||||
|
if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '登录有效期响应格式不符', 0, value)
|
||||||
|
const sessionHours = readNumber(value, 'sessionHours')
|
||||||
|
if (sessionHours === null) throw new ApiError('PARSE_FAILED', '登录有效期响应缺少 sessionHours', 0, value)
|
||||||
|
return {
|
||||||
|
sessionHours,
|
||||||
|
defaultSessionHours: readNumber(value, 'defaultSessionHours') ?? sessionHours,
|
||||||
|
minHours: readNumber(value, 'minHours') ?? 1,
|
||||||
|
maxHours: readNumber(value, 'maxHours') ?? 720,
|
||||||
|
expiresAt: readString(value, 'expiresAt'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 字体(内置字体 + 上传字体)
|
// 字体(内置字体 + 上传字体)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import MxIcon from '../components/MxIcon.vue'
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
import ToastHost from '../components/ToastHost.vue'
|
import ToastHost from '../components/ToastHost.vue'
|
||||||
import type { IconName } from '../components/icons'
|
import type { IconName } from '../components/icons'
|
||||||
|
import { formatSessionExpiry } from '../components/system/sessionSettings'
|
||||||
import { theme, toggleTheme } from '../theme'
|
import { theme, toggleTheme } from '../theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
@@ -78,6 +79,18 @@ const navGroups = computed<NavGroup[]>(() =>
|
|||||||
const currentTitle = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '管理面板'))
|
const currentTitle = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '管理面板'))
|
||||||
const pendingCount = computed(() => data.events.filter((event) => event.status === 'pending').length)
|
const pendingCount = computed(() => data.events.filter((event) => event.status === 'pending').length)
|
||||||
const themeToggleLabel = computed(() => (theme.value === 'dark' ? '切换到浅色模式' : '切换到深色模式'))
|
const themeToggleLabel = computed(() => (theme.value === 'dark' ? '切换到浅色模式' : '切换到深色模式'))
|
||||||
|
/** 用户菜单里显示当前会话的到期时间,现场自己就能确认「够不够撑到比赛结束」。 */
|
||||||
|
const sessionExpiryText = computed(() =>
|
||||||
|
auth.isGuest ? '' : formatSessionExpiry(auth.sessionExpiresAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话心跳间隔:服务端按「有请求」滑动续期,Cookie 的期限又只在 /auth/me 里跟着同步,
|
||||||
|
* 所以页面开着不动时也要定期问一次。10 分钟远小于最小有效期(1 小时),
|
||||||
|
* 任何一次心跳都能把 Cookie 推回到期时间之后,不会出现「服务端还认、浏览器先丢 Cookie」。
|
||||||
|
*/
|
||||||
|
const SESSION_KEEPALIVE_MS = 10 * 60 * 1000
|
||||||
|
let keepalive: number | null = null
|
||||||
|
|
||||||
const connection = computed(() => {
|
const connection = computed(() => {
|
||||||
if (data.isMock) return { tone: 'warning', text: '示例数据', hint: data.mockLabel ?? '' }
|
if (data.isMock) return { tone: 'warning', text: '示例数据', hint: data.mockLabel ?? '' }
|
||||||
@@ -113,6 +126,17 @@ onMounted(async () => {
|
|||||||
if (auth.user?.mustChangePassword && route.name !== 'password') {
|
if (auth.user?.mustChangePassword && route.name !== 'password') {
|
||||||
void router.push({ name: 'password' })
|
void router.push({ name: 'password' })
|
||||||
}
|
}
|
||||||
|
// 心跳跟着布局的生命周期走;用 setInterval 而不是每个页面各挂一个,避免多份定时器。
|
||||||
|
keepalive = window.setInterval(() => {
|
||||||
|
void auth.refreshSession()
|
||||||
|
}, SESSION_KEEPALIVE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (keepalive !== null) {
|
||||||
|
window.clearInterval(keepalive)
|
||||||
|
keepalive = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -200,7 +224,7 @@ onMounted(async () => {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="who">
|
<span class="who" :title="sessionExpiryText">
|
||||||
<span class="mx-avatar">{{ auth.displayName.slice(0, 1) }}</span>
|
<span class="mx-avatar">{{ auth.displayName.slice(0, 1) }}</span>
|
||||||
<span class="who-text">
|
<span class="who-text">
|
||||||
<strong>{{ auth.displayName }}</strong>
|
<strong>{{ auth.displayName }}</strong>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
parseResultValue,
|
parseResultValue,
|
||||||
type AvgMode,
|
type AvgMode,
|
||||||
type DraftResultInput,
|
type DraftResultInput,
|
||||||
|
type EntryStatus,
|
||||||
type EntryWithResult,
|
type EntryWithResult,
|
||||||
type Event,
|
type Event,
|
||||||
type ResultState,
|
type ResultState,
|
||||||
@@ -26,6 +27,17 @@ import {
|
|||||||
type EntryRowJudgement,
|
type EntryRowJudgement,
|
||||||
type ResultCellHint,
|
type ResultCellHint,
|
||||||
} from './entryGuards'
|
} from './entryGuards'
|
||||||
|
import { entryProgress } from './entryProgress'
|
||||||
|
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
|
||||||
|
import {
|
||||||
|
entryBatchOutcome,
|
||||||
|
entryBatchSizeError,
|
||||||
|
planEntryBatch,
|
||||||
|
submitEntryBatch,
|
||||||
|
type EntryBatchAction,
|
||||||
|
type EntryBatchPlan,
|
||||||
|
type EntryBatchRow,
|
||||||
|
} from './entryBatch'
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEAT_SIZE,
|
DEFAULT_HEAT_SIZE,
|
||||||
MAX_HEAT,
|
MAX_HEAT,
|
||||||
@@ -41,6 +53,7 @@ import {
|
|||||||
import { adminApi } from '../api/client'
|
import { adminApi } from '../api/client'
|
||||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||||
|
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
@@ -58,6 +71,8 @@ interface EditableRow {
|
|||||||
memberNames: string[] | null
|
memberNames: string[] | null
|
||||||
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
|
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
|
||||||
heat: number | null
|
heat: number | null
|
||||||
|
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
|
||||||
|
entryStatus: EntryStatus
|
||||||
resultText: string
|
resultText: string
|
||||||
rank: number | null
|
rank: number | null
|
||||||
status: ResultStatus
|
status: ResultStatus
|
||||||
@@ -105,6 +120,10 @@ const publishing = ref(false)
|
|||||||
const unpublishing = ref(false)
|
const unpublishing = ref(false)
|
||||||
const publishDialog = ref(false)
|
const publishDialog = ref(false)
|
||||||
const unpublishDialog = ref(false)
|
const unpublishDialog = ref(false)
|
||||||
|
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
|
||||||
|
const settlement = ref<SettlementStatus | null>(null)
|
||||||
|
const settling = ref(false)
|
||||||
|
const settleDialog = ref(false)
|
||||||
const focusedEntryId = ref<number | null>(null)
|
const focusedEntryId = ref<number | null>(null)
|
||||||
/** 成绩格专属的聚焦标记:悬浮提示只跟着它走,别让名次 / 状态格也弹成绩提示。 */
|
/** 成绩格专属的聚焦标记:悬浮提示只跟着它走,别让名次 / 状态格也弹成绩提示。 */
|
||||||
const focusedResultId = ref<number | null>(null)
|
const focusedResultId = ref<number | null>(null)
|
||||||
@@ -238,8 +257,17 @@ const splitPlan = computed(() => planHeatSplit(visibleRows.value, heatSize.value
|
|||||||
const scopeWord = computed(() => (selectedHeat.value === 'all' ? '该组' : '当前批次'))
|
const scopeWord = computed(() => (selectedHeat.value === 'all' ? '该组' : '当前批次'))
|
||||||
const heatScoped = computed(() => selectedHeat.value !== 'all')
|
const heatScoped = computed(() => selectedHeat.value !== 'all')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报名项是否参与排名:与服务端 domain.EntryStatus.rankable() 同口径——
|
||||||
|
* 只有 registered 才计分,退赛 / dq / dns 一律 0 分且不占名次。
|
||||||
|
* 本地预览若不按这条过滤,退赛行会继续显示原来的名次与分数,和榜单对不上。
|
||||||
|
*/
|
||||||
|
function rankableRow(row: EditableRow): boolean {
|
||||||
|
return row.entryStatus === 'registered'
|
||||||
|
}
|
||||||
|
|
||||||
function scorableOf(rows: EditableRow[]): ScorableRow[] {
|
function scorableOf(rows: EditableRow[]): ScorableRow[] {
|
||||||
return rows.map((row) => ({
|
return rows.filter(rankableRow).map((row) => ({
|
||||||
entryId: row.entryId,
|
entryId: row.entryId,
|
||||||
teamId: row.teamId,
|
teamId: row.teamId,
|
||||||
athleteId: row.athleteId,
|
athleteId: row.athleteId,
|
||||||
@@ -310,6 +338,22 @@ const allPublished = computed(
|
|||||||
() => visibleRows.value.length > 0 && visibleRows.value.every((row) => row.resultState === 'published'),
|
() => visibleRows.value.length > 0 && visibleRows.value.every((row) => row.resultState === 'published'),
|
||||||
)
|
)
|
||||||
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
|
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前 (小项, 组别) 的未结算状态:非 null 才提示。
|
||||||
|
*
|
||||||
|
* 延迟结算关闭时服务端返回空清单,这条提示天然不出现;读不到状态(示例数据 / 断线)时也不提示,
|
||||||
|
* 辅助信息不该打断现场录入。
|
||||||
|
*/
|
||||||
|
const pendingSettlement = computed<SettlementGroup | null>(() => {
|
||||||
|
if (selectedEventId.value === null || selectedCategoryId.value === null) return null
|
||||||
|
const groups = settlement.value?.groups ?? []
|
||||||
|
return (
|
||||||
|
groups.find(
|
||||||
|
(group) => group.eventId === selectedEventId.value && group.categoryId === selectedCategoryId.value,
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
})
|
||||||
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
||||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
||||||
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
||||||
@@ -456,6 +500,19 @@ const stateCounts = computed(() => {
|
|||||||
return counts
|
return counts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批次录入进度:分母是当前筛选范围的行数(选中某一批就只数这一批),
|
||||||
|
* 「已录」判据与发布防呆 / 行徽标共用 entryProgress → judgeEntryRow,不另写一套解析。
|
||||||
|
*/
|
||||||
|
const entryBatchProgress = computed(() =>
|
||||||
|
entryProgress({
|
||||||
|
rows: categoryRows.value,
|
||||||
|
filter: selectedHeat.value,
|
||||||
|
unit: unit.value,
|
||||||
|
needsRank: manualRank.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
|
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
|
||||||
const pendingRows = computed(() =>
|
const pendingRows = computed(() =>
|
||||||
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'filled'),
|
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'filled'),
|
||||||
@@ -529,7 +586,12 @@ const publishMessage = computed(() => {
|
|||||||
const scope = heatScoped.value
|
const scope = heatScoped.value
|
||||||
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
|
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
|
||||||
: ''
|
: ''
|
||||||
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。发布后立即上屏并重算积分榜。`
|
// 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。
|
||||||
|
const tail =
|
||||||
|
settlement.value?.liveOnPublish === false
|
||||||
|
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。'
|
||||||
|
: '发布后立即上屏并重算积分榜。'
|
||||||
|
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}`
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
||||||
@@ -567,6 +629,7 @@ function toRow(entry: EntryWithResult): EditableRow {
|
|||||||
athleteName: entry.athleteName ?? '',
|
athleteName: entry.athleteName ?? '',
|
||||||
memberNames: entry.memberNames,
|
memberNames: entry.memberNames,
|
||||||
heat: entry.heat,
|
heat: entry.heat,
|
||||||
|
entryStatus: entry.status,
|
||||||
resultText: result?.resultText ?? '',
|
resultText: result?.resultText ?? '',
|
||||||
rank: result?.rank ?? null,
|
rank: result?.rank ?? null,
|
||||||
status: result?.status ?? 'ok',
|
status: result?.status ?? 'ok',
|
||||||
@@ -805,12 +868,16 @@ async function confirmPublish(): Promise<void> {
|
|||||||
categoryId: selectedCategoryId.value ?? undefined,
|
categoryId: selectedCategoryId.value ?? undefined,
|
||||||
})
|
})
|
||||||
publishDialog.value = false
|
publishDialog.value = false
|
||||||
await reload()
|
await reloadAfterSettlementChange()
|
||||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
|
||||||
toast.success(
|
const deferred = settlement.value?.liveOnPublish === false
|
||||||
'发布成功,积分榜已更新',
|
const leftover =
|
||||||
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null,
|
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null
|
||||||
)
|
if (deferred && pendingSettlement.value !== null) {
|
||||||
|
toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`)
|
||||||
|
} else {
|
||||||
|
toast.success('发布成功,积分榜已更新', leftover)
|
||||||
|
}
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -827,8 +894,7 @@ async function confirmUnpublish(): Promise<void> {
|
|||||||
categoryId: selectedCategoryId.value ?? undefined,
|
categoryId: selectedCategoryId.value ?? undefined,
|
||||||
})
|
})
|
||||||
unpublishDialog.value = false
|
unpublishDialog.value = false
|
||||||
await reload()
|
await reloadAfterSettlementChange()
|
||||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
|
||||||
toast.warning('已撤销发布,大屏将回退该组成绩')
|
toast.warning('已撤销发布,大屏将回退该组成绩')
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
|
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
|
||||||
@@ -837,6 +903,49 @@ async function confirmUnpublish(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 分批结算(延迟结算下的「榜单还没反映这批」提示 + 人工兜底) ────────────────
|
||||||
|
|
||||||
|
/** 读当前 (小项, 组别) 的结算状态;失败时按「没有提示」处理,不打断录入。 */
|
||||||
|
async function refreshSettlement(): Promise<void> {
|
||||||
|
const id = selectedEventId.value
|
||||||
|
const categoryId = selectedCategoryId.value
|
||||||
|
if (id === null) {
|
||||||
|
settlement.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await settlementApi.status(id, categoryId)
|
||||||
|
// 请求期间可能已经切了项目 / 组别,旧响应不许覆盖新状态。
|
||||||
|
if (id !== selectedEventId.value || categoryId !== selectedCategoryId.value) return
|
||||||
|
settlement.value = status
|
||||||
|
} catch {
|
||||||
|
settlement.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布 / 撤销 / 结算之后一起刷新:榜单、项目列表与结算状态。 */
|
||||||
|
async function reloadAfterSettlementChange(): Promise<void> {
|
||||||
|
await reload()
|
||||||
|
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||||
|
await refreshSettlement()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即结算:这是「有人永远录不出成绩」时的人工兜底,成功后榜单按已发布部分更新。 */
|
||||||
|
async function confirmSettle(): Promise<void> {
|
||||||
|
if (selectedEventId.value === null) return
|
||||||
|
settling.value = true
|
||||||
|
try {
|
||||||
|
await settlementApi.settle(selectedEventId.value, selectedCategoryId.value ?? undefined)
|
||||||
|
settleDialog.value = false
|
||||||
|
await reloadAfterSettlementChange()
|
||||||
|
toast.success('已按当前已发布成绩结算榜单', '未录完的人仍可继续补录,录完后会再自动结算')
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('立即结算失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
settling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 批次(录入 / 展示分组,不参与算分) ──────────────────────────────────────
|
// ── 批次(录入 / 展示分组,不参与算分) ──────────────────────────────────────
|
||||||
|
|
||||||
/** 批量设置批次的共同前置检查;不通过时给出具体原因。 */
|
/** 批量设置批次的共同前置检查;不通过时给出具体原因。 */
|
||||||
@@ -919,6 +1028,146 @@ function clearSelection(): void {
|
|||||||
selectedEntryIds.value = []
|
selectedEntryIds.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 批量退赛 / 恢复 / 移出报名(作用范围 = 勾选行) ────────────────────────────
|
||||||
|
|
||||||
|
/** 批量操作的确认框状态;计划由 planEntryBatch 先算好,确认框只负责展示。 */
|
||||||
|
const batchPlan = ref<EntryBatchPlan | null>(null)
|
||||||
|
const batchDialog = ref(false)
|
||||||
|
const batchBusy = ref(false)
|
||||||
|
|
||||||
|
/** 勾选行里已退赛的人数:给「批量恢复」按钮的可用性与文案做依据。 */
|
||||||
|
const selectedScratchedCount = computed(
|
||||||
|
() => selectedRows.value.filter((row) => row.entryStatus === 'scratched').length,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 勾选行里非正常参赛的人数(退赛 / dq / dns),决定「批量退赛」是否还有意义。 */
|
||||||
|
const selectedUnrankableCount = computed(
|
||||||
|
() => selectedRows.value.filter((row) => !rankableRow(row)).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 勾选行的报名项状态文案;徽标与确认框共用。 */
|
||||||
|
const ENTRY_STATUS_LABEL: Record<EntryStatus, string> = {
|
||||||
|
registered: '正常参赛',
|
||||||
|
scratched: '已退赛',
|
||||||
|
dq: '取消资格',
|
||||||
|
dns: '未出场',
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryStatusTitle(status: EntryStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'scratched':
|
||||||
|
return '已退赛(弃权):0 分且不占名次,可用「批量恢复」撤回'
|
||||||
|
case 'dq':
|
||||||
|
return '已被判罚取消资格:0 分且不占名次'
|
||||||
|
case 'dns':
|
||||||
|
return '已标记未出场:0 分且不占名次'
|
||||||
|
}
|
||||||
|
return '正常参赛'
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchRows(): EntryBatchRow[] {
|
||||||
|
return selectedRows.value.map((row) => ({
|
||||||
|
entryId: row.entryId,
|
||||||
|
name: rowLabel(row),
|
||||||
|
bib: row.bib,
|
||||||
|
team: row.teamShort || row.teamName,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
entryStatus: row.entryStatus,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量操作的共同前置检查;不通过时给出具体原因。 */
|
||||||
|
function canBatch(): boolean {
|
||||||
|
if (data.isMock) {
|
||||||
|
toast.error('示例数据模式不能批量操作', '后端未就绪,改动不会写入真实赛事')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!canWrite.value) {
|
||||||
|
toast.error('当前不可批量操作', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
|
||||||
|
if (dirty.value) {
|
||||||
|
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点批量按钮:先算清「动谁、跳过谁、重算几个组别」,再让操作员确认。 */
|
||||||
|
function requestBatchAction(action: EntryBatchAction): void {
|
||||||
|
if (!canBatch()) return
|
||||||
|
const rows = batchRows()
|
||||||
|
const sizeError = entryBatchSizeError(rows.length)
|
||||||
|
if (sizeError !== null) {
|
||||||
|
toast.error('批量操作未提交', sizeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const plan = planEntryBatch(action, rows)
|
||||||
|
if (plan.changed === 0) {
|
||||||
|
const first = plan.skipped[0]
|
||||||
|
toast.info(
|
||||||
|
'没有可处理的行',
|
||||||
|
first === undefined ? null : `${first.name}:${first.note}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchPlan.value = plan
|
||||||
|
batchDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBatchAction(): void {
|
||||||
|
batchDialog.value = false
|
||||||
|
batchPlan.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchAction(): Promise<void> {
|
||||||
|
const plan = batchPlan.value
|
||||||
|
if (plan === null || batchBusy.value) return
|
||||||
|
batchBusy.value = true
|
||||||
|
try {
|
||||||
|
const result = await submitEntryBatch({ action: plan.action, ids: plan.ids })
|
||||||
|
batchDialog.value = false
|
||||||
|
batchPlan.value = null
|
||||||
|
// 整表重载:状态、名次与积分都由服务端重算后的口径决定,不能只在本地的行上打补丁。
|
||||||
|
await reload()
|
||||||
|
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||||
|
clearSelection()
|
||||||
|
const failed = result.items.filter((item) => !item.ok)
|
||||||
|
if (failed.length > 0) {
|
||||||
|
// 部分失败必须逐条说清,不能只报一句「批量操作完成」。
|
||||||
|
toast.warning(
|
||||||
|
`批量操作完成(${failed.length} 条未处理)`,
|
||||||
|
failed.map((item) => `${item.id}:${item.error ?? '失败'}`).join(';'),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success(BATCH_SUCCESS_TITLE[plan.action], entryBatchOutcome(result))
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('批量操作失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
batchBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_SUCCESS_TITLE: Record<EntryBatchAction, string> = {
|
||||||
|
scratch: '已批量退赛,积分榜已重算',
|
||||||
|
restore: '已批量恢复,积分榜已重算',
|
||||||
|
remove: '已移出报名,积分榜已重算',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_CONFIRM_TITLE: Record<EntryBatchAction, string> = {
|
||||||
|
scratch: '确认批量退赛?',
|
||||||
|
restore: '确认批量恢复?',
|
||||||
|
remove: '确认移出报名?此操作不可恢复',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
|
||||||
|
scratch: '确认退赛',
|
||||||
|
restore: '确认恢复',
|
||||||
|
remove: '确认移出',
|
||||||
|
}
|
||||||
|
|
||||||
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
|
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
|
||||||
function requestSplit(): void {
|
function requestSplit(): void {
|
||||||
const plan = splitPlan.value
|
const plan = splitPlan.value
|
||||||
@@ -1054,6 +1303,9 @@ watch(
|
|||||||
if (loaded) void pickInitialEvent()
|
if (loaded) void pickInitialEvent()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 换项目 / 换组别后结算状态跟着变;提示是辅助信息,读失败不影响录入。
|
||||||
|
watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1175,6 +1427,43 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
批次录入进度:贴在工具栏正下方、横跨整行,扫一眼就知道这一批还差几条。
|
||||||
|
分母是当前筛选范围的行数,判据与发布防呆同源(entryProgress → judgeEntryRow)。
|
||||||
|
-->
|
||||||
|
<div class="entry-progress" :class="{ 'is-done': entryBatchProgress.done }">
|
||||||
|
<span class="entry-progress-text">{{ entryBatchProgress.text }}</span>
|
||||||
|
<div
|
||||||
|
class="mx-progress mx-progress-lg entry-progress-track"
|
||||||
|
:class="{
|
||||||
|
'mx-progress-success': entryBatchProgress.done,
|
||||||
|
'mx-progress-warning': !entryBatchProgress.done && entryBatchProgress.error > 0,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="mx-progress-bar" :style="{ width: `${entryBatchProgress.percent}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
延迟结算提示:低调地放在进度条下面,只说明「这一组还没结算、榜单不跟批次走」,
|
||||||
|
不挡录入。右侧「立即结算」是人工兜底(比如有人退赛但没标 scratched)。
|
||||||
|
-->
|
||||||
|
<div v-if="pendingSettlement !== null" class="entry-settlement">
|
||||||
|
<MxIcon name="warning" :size="14" />
|
||||||
|
<span class="entry-settlement-text">
|
||||||
|
延迟结算:本组 {{ pendingSettlement.published }}/{{ pendingSettlement.required }} 人已出成绩,
|
||||||
|
名次与榜单等该组全部录完后一起更新(本页预览的名次仍是实时的)。
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canWrite || settling"
|
||||||
|
@click="settleDialog = true"
|
||||||
|
>
|
||||||
|
立即结算
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedRows.length > 0" class="heat-bar">
|
<div v-if="selectedRows.length > 0" class="heat-bar">
|
||||||
<span class="heat-bar-title"><MxIcon name="entry" :size="15" /> 已选 {{ selectedRows.length }} 行</span>
|
<span class="heat-bar-title"><MxIcon name="entry" :size="15" /> 已选 {{ selectedRows.length }} 行</span>
|
||||||
<label class="heat-field">
|
<label class="heat-field">
|
||||||
@@ -1233,6 +1522,52 @@ watch(
|
|||||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">取消选择</button>
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">取消选择</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
报名项批量操作栏:作用范围就是上面勾选的行(= 当前批次筛选范围内的行)。
|
||||||
|
退赛可逆、移出不可逆,所以两者都过确认框,先把「动谁、重算几个组别」说清。
|
||||||
|
dq / dns 是别人的判定,服务端会跳过,这里也把按钮的可用性说白。
|
||||||
|
-->
|
||||||
|
<div v-if="selectedRows.length > 0" class="entry-batch-bar">
|
||||||
|
<span class="entry-batch-title">
|
||||||
|
<MxIcon name="entry" :size="15" /> 批量操作 {{ selectedRows.length }} 行
|
||||||
|
</span>
|
||||||
|
<span class="entry-batch-hint">
|
||||||
|
仅作用于当前筛选范围({{ scopeWord }});{{ selectedUnrankableCount }} 人已非正常参赛
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canWrite || batchBusy || selectedUnrankableCount === selectedRows.length"
|
||||||
|
:title="
|
||||||
|
selectedUnrankableCount === selectedRows.length
|
||||||
|
? '选中的行都已是退赛 / 取消资格 / 未出场,没有可退赛的人'
|
||||||
|
: '标记为退赛(0 分且不占名次),可随时批量恢复'
|
||||||
|
"
|
||||||
|
@click="requestBatchAction('scratch')"
|
||||||
|
>
|
||||||
|
<MxIcon name="close" :size="14" /> 批量退赛
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canWrite || batchBusy || selectedScratchedCount === 0"
|
||||||
|
:title="selectedScratchedCount === 0 ? '选中的行里没有已退赛的人' : `把 ${selectedScratchedCount} 人恢复为正常参赛`"
|
||||||
|
@click="requestBatchAction('restore')"
|
||||||
|
>
|
||||||
|
<MxIcon name="check" :size="14" /> 批量恢复<span v-if="selectedScratchedCount > 0">({{ selectedScratchedCount }})</span>
|
||||||
|
</button>
|
||||||
|
<span class="heat-bar-split"></span>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canWrite || batchBusy"
|
||||||
|
title="从报名名单里物理删除,连同成绩与积分流水一起删除,不可恢复"
|
||||||
|
@click="requestBatchAction('remove')"
|
||||||
|
>
|
||||||
|
<MxIcon name="warning" :size="14" /> 批量移出报名
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="showAvgMode" class="avg-mode-bar">
|
<div v-if="showAvgMode" class="avg-mode-bar">
|
||||||
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
||||||
<label
|
<label
|
||||||
@@ -1273,7 +1608,7 @@ watch(
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="allVisibleSelected"
|
:checked="allVisibleSelected"
|
||||||
:disabled="visibleRows.length === 0"
|
:disabled="visibleRows.length === 0"
|
||||||
title="全选当前可见行"
|
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
|
||||||
@change="toggleAllVisible"
|
@change="toggleAllVisible"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
@@ -1328,7 +1663,8 @@ watch(
|
|||||||
<td>
|
<td>
|
||||||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||||||
|
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
|
||||||
<td class="result-cell">
|
<td class="result-cell">
|
||||||
<input
|
<input
|
||||||
v-model="row.resultText"
|
v-model="row.resultText"
|
||||||
@@ -1373,7 +1709,13 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
|
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="status-cell">
|
||||||
|
<span
|
||||||
|
v-if="row.entryStatus !== 'registered'"
|
||||||
|
class="mx-badge entry-status-badge"
|
||||||
|
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
|
||||||
|
:title="entryStatusTitle(row.entryStatus)"
|
||||||
|
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
|
||||||
<select
|
<select
|
||||||
v-model="row.status"
|
v-model="row.status"
|
||||||
data-cell
|
data-cell
|
||||||
@@ -1503,6 +1845,23 @@ watch(
|
|||||||
@cancel="unpublishDialog = false"
|
@cancel="unpublishDialog = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="settleDialog"
|
||||||
|
tone="warning"
|
||||||
|
title="立即结算该组积分?"
|
||||||
|
:message="`将按本组当前已发布的成绩重算各单位总分并刷新奖牌榜,未录完的人不计入。`"
|
||||||
|
:details="[
|
||||||
|
pendingSettlement === null
|
||||||
|
? '本组已无未结算内容'
|
||||||
|
: `本组已发布 ${pendingSettlement.published}/${pendingSettlement.required} 人`,
|
||||||
|
'结算后继续补录并发布,全组录完时还会再自动结算一次',
|
||||||
|
]"
|
||||||
|
confirm-text="立即结算"
|
||||||
|
:pending="settling"
|
||||||
|
@confirm="confirmSettle"
|
||||||
|
@cancel="settleDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="splitDialog"
|
:open="splitDialog"
|
||||||
tone="warning"
|
tone="warning"
|
||||||
@@ -1532,6 +1891,19 @@ watch(
|
|||||||
@confirm="confirmDiscard"
|
@confirm="confirmDiscard"
|
||||||
@cancel="cancelDiscard"
|
@cancel="cancelDiscard"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 批量操作确认:移出报名不可恢复,用 danger;退赛 / 恢复可逆,用 warning / info。 -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="batchDialog"
|
||||||
|
:tone="batchPlan?.action === 'remove' ? 'danger' : batchPlan?.action === 'restore' ? 'info' : 'warning'"
|
||||||
|
:title="BATCH_CONFIRM_TITLE[batchPlan?.action ?? 'scratch']"
|
||||||
|
:message="batchPlan?.message ?? ''"
|
||||||
|
:details="batchPlan?.details ?? []"
|
||||||
|
:confirm-text="BATCH_CONFIRM_TEXT[batchPlan?.action ?? 'scratch']"
|
||||||
|
:pending="batchBusy"
|
||||||
|
@confirm="confirmBatchAction"
|
||||||
|
@cancel="cancelBatchAction"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1673,6 +2045,46 @@ watch(
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
background: var(--mx-divider);
|
background: var(--mx-divider);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* 报名项批量操作栏:与批次操作条同排出现,勾了行才显示。
|
||||||
|
* 用警示色边框把「这里有不可恢复动作」和批次操作区分开。
|
||||||
|
*/
|
||||||
|
.entry-batch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
border: 1px solid var(--mx-error);
|
||||||
|
background: var(--mx-error-container);
|
||||||
|
}
|
||||||
|
.entry-batch-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mx-error);
|
||||||
|
}
|
||||||
|
.entry-batch-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
/* 状态列同时放报名项徽标(已退赛等)与成绩状态下拉,横排不换行。 */
|
||||||
|
.status-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.status-cell .status-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.entry-status-badge {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
* 批次列只放「第 N 批 / 未分批」这类短文本,窄列即可:
|
* 批次列只放「第 N 批 / 未分批」这类短文本,窄列即可:
|
||||||
* 自动布局下 nowrap + width:1% 会收缩到文字宽度,不会挤坏单位 / 运动员列
|
* 自动布局下 nowrap + width:1% 会收缩到文字宽度,不会挤坏单位 / 运动员列
|
||||||
@@ -1943,6 +2355,55 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* 批次录入进度:横跨整行、文字加粗,是这一屏最显眼的「还差几条」指示;
|
||||||
|
* 进度条本身用 MIUIx 的 mx-progress / mx-progress-success(录完变绿),不引新样式。
|
||||||
|
*/
|
||||||
|
.entry-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--mx-border);
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.entry-progress-text {
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.entry-progress.is-done .entry-progress-text {
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
|
/* 延迟结算提示:中性色 + 细边,属于「知道就好」的信息,不抢进度条与成绩格的注意力。 */
|
||||||
|
.entry-settlement {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px dashed var(--mx-border);
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.entry-settlement-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.entry-progress-track {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
/* 姓名后的号码布:小号、弱化、等宽,补一次号码但不抢姓名的位置。 */
|
||||||
|
.bib-inline {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
.hotkey-hint {
|
.hotkey-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ import { adminApi } from '../api/client'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
import {
|
||||||
|
downloadBaseDataXLSX,
|
||||||
|
downloadEntriesXLSX,
|
||||||
|
importBaseDataXLSX,
|
||||||
|
type BaseDataImportResult,
|
||||||
|
} from '@/shared/basedata'
|
||||||
|
import type { EventCategory } from '@/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出与赛事包(devdocs/07 第 5 节)。
|
* 导出与赛事包(devdocs/07 第 5 节)。
|
||||||
@@ -32,10 +39,11 @@ import { useToastStore } from '../stores/toast'
|
|||||||
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
|
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type TabKey = 'results' | 'project' | 'rules'
|
type TabKey = 'results' | 'basedata' | 'project' | 'rules'
|
||||||
|
|
||||||
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
||||||
{ key: 'results', label: '结果导出' },
|
{ key: 'results', label: '结果导出' },
|
||||||
|
{ key: 'basedata', label: '基础数据' },
|
||||||
{ key: 'project', label: '赛事项目包' },
|
{ key: 'project', label: '赛事项目包' },
|
||||||
{ key: 'rules', label: '规则包' },
|
{ key: 'rules', label: '规则包' },
|
||||||
]
|
]
|
||||||
@@ -119,6 +127,147 @@ const rulesPreview = ref<RulePreview | null>(null)
|
|||||||
const rulesApplyResult = ref<RuleApplyResult | null>(null)
|
const rulesApplyResult = ref<RuleApplyResult | null>(null)
|
||||||
const confirmRulesApply = ref(false)
|
const confirmRulesApply = ref(false)
|
||||||
|
|
||||||
|
// ── 报名名单(xlsx,可按小项 / 组别 / 单位 / 批次筛) ────────────────────────
|
||||||
|
const entryEventId = ref<number | null>(null)
|
||||||
|
const entryCategoryId = ref<number | null>(null)
|
||||||
|
const entryTeamId = ref<number | null>(null)
|
||||||
|
/** 批次用字符串存:空串 = 不筛,交给后端时再转成数字。 */
|
||||||
|
const entryHeat = ref('')
|
||||||
|
const entryCategories = ref<EventCategory[]>([])
|
||||||
|
const entryExporting = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 换小项就把组别重置并重新拉取:组别 id 只在小项内有意义,
|
||||||
|
* 沿用上一个小项的组别会把筛选条件悄悄变成「没有数据」。
|
||||||
|
*/
|
||||||
|
watch(entryEventId, async (id) => {
|
||||||
|
entryCategoryId.value = null
|
||||||
|
entryCategories.value = []
|
||||||
|
if (id === null) return
|
||||||
|
try {
|
||||||
|
entryCategories.value = await adminApi.client.events.categories(id)
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('读取组别失败', describeError(caught))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function exportEntries(): Promise<void> {
|
||||||
|
let heat: number | undefined
|
||||||
|
const rawHeat = entryHeat.value.trim()
|
||||||
|
if (rawHeat !== '') {
|
||||||
|
const parsed = Number(rawHeat)
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
toast.warning('批次需要是正整数(留空表示不筛)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
heat = parsed
|
||||||
|
}
|
||||||
|
entryExporting.value = true
|
||||||
|
try {
|
||||||
|
const file = await downloadEntriesXLSX({
|
||||||
|
eventId: entryEventId.value ?? undefined,
|
||||||
|
categoryId: entryCategoryId.value ?? undefined,
|
||||||
|
teamId: entryTeamId.value ?? undefined,
|
||||||
|
heat,
|
||||||
|
})
|
||||||
|
adminApi.client.exports.save(file.blob, file.filename)
|
||||||
|
toast.success(`已导出 ${file.filename}`)
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('导出失败', describeError(caught))
|
||||||
|
} finally {
|
||||||
|
entryExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础数据包(单位 / 大项 / 小项 / 组别) ──────────────────────────────────
|
||||||
|
const baseDataFile = ref<File | null>(null)
|
||||||
|
const baseDataInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const baseDataExporting = ref(false)
|
||||||
|
const baseDataPreviewing = ref(false)
|
||||||
|
const baseDataApplying = ref(false)
|
||||||
|
const baseDataError = ref<string | null>(null)
|
||||||
|
const baseDataResult = ref<BaseDataImportResult | null>(null)
|
||||||
|
const confirmBaseDataApply = ref(false)
|
||||||
|
|
||||||
|
async function exportBaseData(): Promise<void> {
|
||||||
|
baseDataExporting.value = true
|
||||||
|
baseDataError.value = null
|
||||||
|
try {
|
||||||
|
const file = await downloadBaseDataXLSX()
|
||||||
|
adminApi.client.exports.save(file.blob, file.filename)
|
||||||
|
toast.success('基础数据包已导出', '含 单位 / 大项 / 小项 / 组别 四张工作表,可原样导回')
|
||||||
|
} catch (caught) {
|
||||||
|
baseDataError.value = describeError(caught)
|
||||||
|
} finally {
|
||||||
|
baseDataExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBaseDataFile(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0] ?? null
|
||||||
|
baseDataResult.value = null
|
||||||
|
baseDataError.value = null
|
||||||
|
if (file === null) {
|
||||||
|
baseDataFile.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/\.xlsx$/i.test(file.name)) {
|
||||||
|
baseDataError.value = '基础数据包必须是 .xlsx 文件:单位 / 大项 / 小项 / 组别分在四张工作表里'
|
||||||
|
baseDataFile.value = null
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseDataFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一步:只算差异不写库。用户看清了再点确认写入。 */
|
||||||
|
async function previewBaseData(): Promise<void> {
|
||||||
|
const file = baseDataFile.value
|
||||||
|
if (file === null) {
|
||||||
|
baseDataError.value = '请先选择 .xlsx 基础数据包'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseDataPreviewing.value = true
|
||||||
|
baseDataError.value = null
|
||||||
|
baseDataResult.value = null
|
||||||
|
try {
|
||||||
|
baseDataResult.value = await importBaseDataXLSX(file, { dryRun: true })
|
||||||
|
} catch (caught) {
|
||||||
|
baseDataError.value = describeError(caught)
|
||||||
|
} finally {
|
||||||
|
baseDataPreviewing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBaseDataApply(): void {
|
||||||
|
if (baseDataResult.value === null) return
|
||||||
|
confirmBaseDataApply.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBaseData(): Promise<void> {
|
||||||
|
const file = baseDataFile.value
|
||||||
|
if (file === null) return
|
||||||
|
confirmBaseDataApply.value = false
|
||||||
|
baseDataApplying.value = true
|
||||||
|
baseDataError.value = null
|
||||||
|
try {
|
||||||
|
const result = await importBaseDataXLSX(file, { dryRun: false })
|
||||||
|
baseDataResult.value = result
|
||||||
|
toast.success(
|
||||||
|
'基础数据已写入',
|
||||||
|
`新增 ${result.added} 条 / 更新 ${result.updated} 条` +
|
||||||
|
(result.errorCount > 0 ? ` / ${result.errorCount} 行出错未写入` : ''),
|
||||||
|
)
|
||||||
|
// 导入会新增单位 / 大项 / 小项,页面上的下拉与基础数据页都要跟着刷新。
|
||||||
|
void data.load()
|
||||||
|
} catch (caught) {
|
||||||
|
baseDataError.value = describeError(caught)
|
||||||
|
} finally {
|
||||||
|
baseDataApplying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||||
|
|
||||||
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
|
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
|
||||||
@@ -484,8 +633,203 @@ async function applyRules(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 报名名单:同样是 xlsx 导出,但读的是报名数据(批次、号码布、状态都在这一侧) -->
|
||||||
|
<section v-if="activeTab === 'results'" class="mx-card entries-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">报名名单(xlsx)</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="hint">
|
||||||
|
按小项 / 组别 / 单位 / 批次筛选,留空表示该维度不筛。表里含小项编码与名称、组别、单位、姓名、证件号、号码布、<strong>批次</strong>、性别、年龄、状态与备注;号码布按文本导出,前导零不会丢。
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="entries-event">小项</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<select id="entries-event" v-model="entryEventId" class="mx-input">
|
||||||
|
<option :value="null">全部小项</option>
|
||||||
|
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="entries-category">组别</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<select
|
||||||
|
id="entries-category"
|
||||||
|
v-model="entryCategoryId"
|
||||||
|
class="mx-input"
|
||||||
|
:disabled="entryEventId === null || entryCategories.length === 0"
|
||||||
|
>
|
||||||
|
<option :value="null">全部组别</option>
|
||||||
|
<option v-for="item in entryCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="entries-team">单位</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<select id="entries-team" v-model="entryTeamId" class="mx-input">
|
||||||
|
<option :value="null">全部单位</option>
|
||||||
|
<option v-for="team in data.teams" :key="team.id" :value="team.id">
|
||||||
|
{{ team.name }}({{ team.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="entries-heat">批次</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input
|
||||||
|
id="entries-heat"
|
||||||
|
v-model="entryHeat"
|
||||||
|
class="mx-input"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="全部批次(留空)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary full"
|
||||||
|
type="button"
|
||||||
|
:disabled="entryExporting"
|
||||||
|
@click="exportEntries"
|
||||||
|
>
|
||||||
|
<MxIcon name="export" :size="15" />
|
||||||
|
{{ entryExporting ? '导出中…' : '导出报名名单' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 基础数据包:导出可原样导回;导入按编码 upsert,先预览差异再写入 -->
|
||||||
|
<div v-if="activeTab === 'basedata'" class="basedata-grid">
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">导出基础数据包</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="hint">
|
||||||
|
一个工作簿含四张工作表:单位 / 大项 / 小项 / 组别(组别带所属小项编码)。列名是中文表头,导出的文件可以直接原样导回来,识别到的差异为 0。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary full"
|
||||||
|
type="button"
|
||||||
|
:disabled="baseDataExporting"
|
||||||
|
@click="exportBaseData"
|
||||||
|
>
|
||||||
|
<MxIcon name="export" :size="15" />
|
||||||
|
{{ baseDataExporting ? '导出中…' : '导出 xlsx' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head"><span class="mx-card-title">导入基础数据包</span></div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="!isAdmin" class="notice">导入基础数据会批量写入单位 / 大项 / 小项 / 组别,只有管理员可操作。</p>
|
||||||
|
|
||||||
|
<div class="file-row">
|
||||||
|
<input
|
||||||
|
ref="baseDataInput"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
@change="pickBaseDataFile"
|
||||||
|
/>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" @click="baseDataInput?.click()">
|
||||||
|
<MxIcon name="database" :size="15" /> 选择 .xlsx
|
||||||
|
</button>
|
||||||
|
<span class="file-name mono">{{ baseDataFile?.name ?? '未选择文件' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
按编码 upsert:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;存在就更新、不存在就新建。
|
||||||
|
文件里没写的数据一条都不会删。
|
||||||
|
</p>
|
||||||
|
<p class="hint">导入分两步:先「预览差异」,确认无误后再写入。</p>
|
||||||
|
|
||||||
|
<p v-if="baseDataError !== null" class="notice error">{{ baseDataError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary full"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || baseDataPreviewing || baseDataFile === null"
|
||||||
|
@click="previewBaseData"
|
||||||
|
>
|
||||||
|
{{ baseDataPreviewing ? '比对中…' : '预览差异' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="baseDataResult !== null" class="preview-box">
|
||||||
|
<div class="preview-head">
|
||||||
|
<span class="mx-badge mx-badge-neutral">新增 {{ baseDataResult.added }} 条</span>
|
||||||
|
<span class="mx-badge mx-badge-warning">更新 {{ baseDataResult.updated }} 条</span>
|
||||||
|
<span class="mx-badge" :class="baseDataResult.errorCount > 0 ? 'mx-badge-error' : 'mx-badge-neutral'">
|
||||||
|
错误 {{ baseDataResult.errorCount }} 条
|
||||||
|
</span>
|
||||||
|
<span class="preview-meta">
|
||||||
|
{{ baseDataResult.dryRun ? '预览(未写库)' : '已写入' }}
|
||||||
|
<template v-if="baseDataResult.unchanged > 0">· 无变化 {{ baseDataResult.unchanged }} 条</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="baseDataResult.errors.length > 0" class="error-list">
|
||||||
|
<div v-for="(message, index) in baseDataResult.errors" :key="index" class="error-line">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="baseDataResult.changes.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">没有差异</div>
|
||||||
|
<div class="mx-empty-desc">文件内容与库里一致,无需写入</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>工作表</th>
|
||||||
|
<th>行</th>
|
||||||
|
<th>变化</th>
|
||||||
|
<th>对象</th>
|
||||||
|
<th>字段差异</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in baseDataResult.changes" :key="`${row.sheet}-${row.line}-${index}`">
|
||||||
|
<td>{{ row.sheet }}</td>
|
||||||
|
<td class="mono">第 {{ row.line }} 行</td>
|
||||||
|
<td>
|
||||||
|
<span class="mx-badge" :class="row.action === 'added' ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||||
|
{{ row.action === 'added' ? '新增' : '更新' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.key }}</td>
|
||||||
|
<td class="detail">
|
||||||
|
<span v-for="f in row.fields" :key="f.field" class="field-diff">
|
||||||
|
{{ f.field }}:{{ f.from === '' ? '(空)' : f.from }} → {{ f.to === '' ? '(空)' : f.to }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="baseDataResult.dryRun"
|
||||||
|
class="mx-btn mx-btn-danger full"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || baseDataApplying"
|
||||||
|
@click="requestBaseDataApply"
|
||||||
|
>
|
||||||
|
{{ baseDataApplying ? '写入中…' : '确认写入' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 赛事项目包 -->
|
<!-- 赛事项目包 -->
|
||||||
<div v-else-if="activeTab === 'project'" class="project-grid">
|
<div v-if="activeTab === 'project'" class="project-grid">
|
||||||
<section class="mx-card wide">
|
<section class="mx-card wide">
|
||||||
<div class="mx-card-head">
|
<div class="mx-card-head">
|
||||||
<span class="mx-card-title">赛事项目列表</span>
|
<span class="mx-card-title">赛事项目列表</span>
|
||||||
@@ -676,7 +1020,7 @@ async function applyRules(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 规则包 -->
|
<!-- 规则包 -->
|
||||||
<div v-else class="rules-grid">
|
<div v-if="activeTab === 'rules'" class="rules-grid">
|
||||||
<section class="mx-card">
|
<section class="mx-card">
|
||||||
<div class="mx-card-head">
|
<div class="mx-card-head">
|
||||||
<span class="mx-card-title">导出规则包</span>
|
<span class="mx-card-title">导出规则包</span>
|
||||||
@@ -815,6 +1159,24 @@ async function applyRules(): Promise<void> {
|
|||||||
@cancel="confirmRulesApply = false"
|
@cancel="confirmRulesApply = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmBaseDataApply"
|
||||||
|
tone="danger"
|
||||||
|
title="写入基础数据?"
|
||||||
|
:message="`将按文件内容新增 ${baseDataResult?.added ?? 0} 条、更新 ${baseDataResult?.updated ?? 0} 条单位 / 大项 / 小项 / 组别。`"
|
||||||
|
:details="[
|
||||||
|
'按编码 upsert:存在就更新、不存在就新建',
|
||||||
|
'文件里没写的既有数据不会被删除',
|
||||||
|
baseDataResult !== null && baseDataResult.errorCount > 0
|
||||||
|
? `${baseDataResult.errorCount} 行有错误,这些行会被跳过,其余行照常写入`
|
||||||
|
: '本次预览没有行级错误',
|
||||||
|
]"
|
||||||
|
confirm-text="确认写入"
|
||||||
|
:pending="baseDataApplying"
|
||||||
|
@confirm="applyBaseData"
|
||||||
|
@cancel="confirmBaseDataApply = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<p class="footer-note">当前数据更新于 {{ formatDateTime(data.lastRefreshAt) }}</p>
|
<p class="footer-note">当前数据更新于 {{ formatDateTime(data.lastRefreshAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -879,6 +1241,33 @@ async function applyRules(): Promise<void> {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
/* 报名名单排在结果导出卡片下方,同样占满整行。 */
|
||||||
|
.entries-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
/* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */
|
||||||
|
.basedata-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.error-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
.error-line {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-error);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.field-diff {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
|
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
|
||||||
.project-grid > .wide {
|
.project-grid > .wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -993,7 +1382,8 @@ async function applyRules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
@media (max-width: 1279px) {
|
@media (max-width: 1279px) {
|
||||||
.project-grid,
|
.project-grid,
|
||||||
.rules-grid {
|
.rules-grid,
|
||||||
|
.basedata-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
|
|||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import { adminApi } from '../api/client'
|
import { adminApi } from '../api/client'
|
||||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||||
|
import { liveOnPublishOf } from '../components/system/systemApi'
|
||||||
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
@@ -72,6 +73,11 @@ const returnTarget = ref<DraftGroup | null>(null)
|
|||||||
const returnedKeys = ref<string[]>([])
|
const returnedKeys = ref<string[]>([])
|
||||||
|
|
||||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||||
|
/**
|
||||||
|
* 延迟结算:发布只落成绩,榜单等该组全部录完才更新。
|
||||||
|
* 这里用的是快照里的设置;读不到(非 admin 且快照未到)时按默认「实时结算」处理。
|
||||||
|
*/
|
||||||
|
const deferredSettlement = computed(() => !liveOnPublishOf(data.settings))
|
||||||
/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */
|
/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */
|
||||||
const weight = computed(() => {
|
const weight = computed(() => {
|
||||||
const w = data.settings?.standings.individualToTeamWeight ?? 1
|
const w = data.settings?.standings.individualToTeamWeight ?? 1
|
||||||
@@ -352,6 +358,20 @@ function requestPublish(group: DraftGroup): void {
|
|||||||
publishTarget.value = group
|
publishTarget.value = group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */
|
||||||
|
const publishMessage = computed(() => {
|
||||||
|
const group = publishTarget.value
|
||||||
|
if (group === null) return ''
|
||||||
|
const scope =
|
||||||
|
skippedCount(group) > 0
|
||||||
|
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
|
||||||
|
: `本次将发布该组全部 ${publishableCount(group)} 条。`
|
||||||
|
const tail = deferredSettlement.value
|
||||||
|
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
|
||||||
|
: '发布后立即上屏并重算积分榜。'
|
||||||
|
return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}`
|
||||||
|
})
|
||||||
|
|
||||||
async function confirmPublish(): Promise<void> {
|
async function confirmPublish(): Promise<void> {
|
||||||
const group = publishTarget.value
|
const group = publishTarget.value
|
||||||
if (group === null) return
|
if (group === null) return
|
||||||
@@ -361,7 +381,11 @@ async function confirmPublish(): Promise<void> {
|
|||||||
publishTarget.value = null
|
publishTarget.value = null
|
||||||
await load()
|
await load()
|
||||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||||
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
if (deferredSettlement.value) {
|
||||||
|
toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`)
|
||||||
|
} else {
|
||||||
|
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||||||
|
}
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -633,13 +657,7 @@ watch(
|
|||||||
:open="publishTarget !== null"
|
:open="publishTarget !== null"
|
||||||
tone="warning"
|
tone="warning"
|
||||||
title="确认发布该组成绩?"
|
title="确认发布该组成绩?"
|
||||||
:message="
|
:message="publishMessage"
|
||||||
publishTarget === null
|
|
||||||
? ''
|
|
||||||
: skippedCount(publishTarget) > 0
|
|
||||||
? `本组还有 ${skippedCount(publishTarget)} 条没有成绩,本次将发布已填的 ${publishableCount(publishTarget)} 条;其余保持未发布,补录后再发布即可。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
|
||||||
: `本次将发布该组全部 ${publishableCount(publishTarget)} 条。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
|
||||||
"
|
|
||||||
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
||||||
confirm-text="确认发布"
|
confirm-text="确认发布"
|
||||||
:pending="publishing"
|
:pending="publishing"
|
||||||
|
|||||||
@@ -4,20 +4,23 @@ import PageHeader from '../components/PageHeader.vue'
|
|||||||
import UsersPanel from '../components/system/UsersPanel.vue'
|
import UsersPanel from '../components/system/UsersPanel.vue'
|
||||||
import BackupPanel from '../components/system/BackupPanel.vue'
|
import BackupPanel from '../components/system/BackupPanel.vue'
|
||||||
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
|
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
|
||||||
|
import SessionPanel from '../components/system/SessionPanel.vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统页(devdocs/07 第 1 节)。
|
* 系统页(devdocs/07 第 1 节)。
|
||||||
*
|
*
|
||||||
* 三块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
|
* 四块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
|
||||||
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
|
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type TabKey = 'users' | 'backup' | 'event'
|
type TabKey = 'users' | 'session' | 'backup' | 'event'
|
||||||
|
|
||||||
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
||||||
{ key: 'users', label: '用户管理' },
|
{ key: 'users', label: '用户管理' },
|
||||||
|
// 登录有效期是机器级配置(system.db),与赛事信息那种随赛事包走的配置分开摆。
|
||||||
|
{ key: 'session', label: '登录有效期' },
|
||||||
{ key: 'backup', label: '备份' },
|
{ key: 'backup', label: '备份' },
|
||||||
{ key: 'event', label: '赛事信息' },
|
{ key: 'event', label: '赛事信息' },
|
||||||
]
|
]
|
||||||
@@ -42,7 +45,7 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="系统" subtitle="用户管理 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
|
<PageHeader title="系统" subtitle="用户管理 / 登录有效期 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
|
||||||
|
|
||||||
<div class="system-toolbar">
|
<div class="system-toolbar">
|
||||||
<div class="mx-tabs">
|
<div class="mx-tabs">
|
||||||
@@ -70,6 +73,7 @@ watch(
|
|||||||
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
|
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
|
||||||
|
|
||||||
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
|
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
|
||||||
|
<SessionPanel v-else-if="isAdmin && activeTab === 'session'" />
|
||||||
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
|
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
|
||||||
<EventInfoPanel v-else />
|
<EventInfoPanel v-else />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { ApiError } from '@/shared'
|
||||||
|
import type { ApiErrorCode, EntryStatus } from '@/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报名项批量操作(退赛 / 恢复 / 移出报名)的前端逻辑。
|
||||||
|
*
|
||||||
|
* 网络层直接 fetch 而不是走 adminApi:client.ts 的接口表与 shared/api.ts 归口在共享层,
|
||||||
|
* 本功能只服务录入页,先把请求构造与确认文案收在这一个模块里,便于单测;
|
||||||
|
* CSRF 与 X-Request-Id 的补法与 shared/api.ts 的写请求保持一致,否则写操作会被 403 挡回。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 与 server/internal/httpapi/entries_batch.go 的 maxEntryBatch 一致。 */
|
||||||
|
export const MAX_ENTRY_BATCH = 500
|
||||||
|
|
||||||
|
export const ENTRY_BATCH_PATH = '/api/entries/batch'
|
||||||
|
|
||||||
|
/** 确认框里逐条列出「将被删的人」时最多列几行,超出的只报数。 */
|
||||||
|
const MAX_LISTED_PEOPLE = 20
|
||||||
|
|
||||||
|
export type EntryBatchAction = 'scratch' | 'restore' | 'remove'
|
||||||
|
|
||||||
|
export interface EntryBatchPayload {
|
||||||
|
action: EntryBatchAction
|
||||||
|
ids: number[]
|
||||||
|
reason?: string
|
||||||
|
requestId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryBatchItem {
|
||||||
|
id: number
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
skipped?: boolean
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryBatchSummary {
|
||||||
|
ok: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
changed: number
|
||||||
|
removedEntries: number
|
||||||
|
removedResults: number
|
||||||
|
removedLedger: number
|
||||||
|
removedBreaks: number
|
||||||
|
recomputedEvents: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryBatchResult {
|
||||||
|
items: EntryBatchItem[]
|
||||||
|
summary: EntryBatchSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量操作作用于的勾选行;字段只取确认文案与跳过判定需要的部分。 */
|
||||||
|
export interface EntryBatchRow {
|
||||||
|
entryId: number
|
||||||
|
/** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */
|
||||||
|
name: string
|
||||||
|
bib: string | null
|
||||||
|
team: string
|
||||||
|
categoryId: number
|
||||||
|
entryStatus: EntryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryBatchPlan {
|
||||||
|
action: EntryBatchAction
|
||||||
|
/** 去重后的报名项 id(服务端同样会去重,这里先去掉重复勾选)。 */
|
||||||
|
ids: number[]
|
||||||
|
/** 真正会被改动的行数。 */
|
||||||
|
changed: number
|
||||||
|
/** 会被跳过的行及其原因(dq / dns 是别人的判定,不覆盖)。 */
|
||||||
|
skipped: { name: string; note: string }[]
|
||||||
|
/** 会重算的组别数量:重算按 (小项, 组别) 走,本页只有当前小项。 */
|
||||||
|
scopeCount: number
|
||||||
|
message: string
|
||||||
|
details: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去重并丢掉非法 id(0 / 负数 / 非整数),保持勾选顺序。
|
||||||
|
* 与服务端 dedupeIDs 同一口径:重复勾选不该变成两条请求明细。
|
||||||
|
*/
|
||||||
|
export function dedupeEntryIds(ids: number[]): number[] {
|
||||||
|
const out: number[] = []
|
||||||
|
const seen = new Set<number>()
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!Number.isInteger(id) || id <= 0 || seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
out.push(id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量条数校验;通过返回 null,否则返回可直接上屏的中文提示。 */
|
||||||
|
export function entryBatchSizeError(count: number): string | null {
|
||||||
|
if (count === 0) return '请先勾选要操作的报名项'
|
||||||
|
if (count > MAX_ENTRY_BATCH) {
|
||||||
|
return `一次最多处理 ${MAX_ENTRY_BATCH} 条报名项,请分批提交`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该行在当前动作下会不会被改动;不改动时返回跳过原因(与服务端文案一致)。 */
|
||||||
|
export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus): string | null {
|
||||||
|
if (action === 'remove') return null
|
||||||
|
if (action === 'scratch' && status === 'registered') return null
|
||||||
|
if (action === 'restore' && status === 'scratched') return null
|
||||||
|
switch (status) {
|
||||||
|
case 'scratched':
|
||||||
|
return '已是退赛状态,跳过'
|
||||||
|
case 'registered':
|
||||||
|
return '当前正常参赛,无需恢复'
|
||||||
|
case 'dq':
|
||||||
|
return '已被判罚取消资格(dq),跳过,不覆盖原判定'
|
||||||
|
case 'dns':
|
||||||
|
return '已标记未出场(dns),跳过,不覆盖原判定'
|
||||||
|
}
|
||||||
|
return `当前状态为「${status}」,跳过`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行在确认框里的身份行:姓名|号码布|单位。 */
|
||||||
|
function personLine(row: EntryBatchRow): string {
|
||||||
|
return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一次批量操作整理成确认框能直接用的内容:
|
||||||
|
* 前端先算清「动谁、跳过谁、重算几个组别」,不让操作员点完才知道。
|
||||||
|
*/
|
||||||
|
export function planEntryBatch(action: EntryBatchAction, rows: EntryBatchRow[]): EntryBatchPlan {
|
||||||
|
const ids = dedupeEntryIds(rows.map((row) => row.entryId))
|
||||||
|
// 重复勾选不该在「动几个人」里数两遍:按 entryId 去重后再统计。
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const unique = rows.filter((row) => {
|
||||||
|
if (seen.has(row.entryId)) return false
|
||||||
|
seen.add(row.entryId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const scopes = new Set(unique.map((row) => row.categoryId))
|
||||||
|
const changedRows: EntryBatchRow[] = []
|
||||||
|
const skipped: { name: string; note: string }[] = []
|
||||||
|
for (const row of unique) {
|
||||||
|
const note = entryBatchSkipNote(action, row.entryStatus)
|
||||||
|
if (note === null) changedRows.push(row)
|
||||||
|
else skipped.push({ name: row.name || '(未署名)', note })
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = changedRows.length
|
||||||
|
const scopeCount = scopes.size
|
||||||
|
const details: string[] = []
|
||||||
|
if (action === 'remove') {
|
||||||
|
details.push(`将删除以下 ${changed} 人(物理删除,不可恢复):`)
|
||||||
|
for (const row of changedRows.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${personLine(row)}`)
|
||||||
|
if (changedRows.length > MAX_LISTED_PEOPLE) {
|
||||||
|
details.push(`…还有 ${changedRows.length - MAX_LISTED_PEOPLE} 人未列出`)
|
||||||
|
}
|
||||||
|
} else if (skipped.length > 0) {
|
||||||
|
details.push(`以下 ${skipped.length} 人会被跳过(不覆盖已有判定):`)
|
||||||
|
for (const item of skipped.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${item.name}:${item.note}`)
|
||||||
|
if (skipped.length > MAX_LISTED_PEOPLE) {
|
||||||
|
details.push(`…还有 ${skipped.length - MAX_LISTED_PEOPLE} 人未列出`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = ''
|
||||||
|
if (action === 'scratch') {
|
||||||
|
message = `将把选中的 ${changed} 人标记为退赛(0 分且不占名次),随后按新名次重算积分榜。退赛随时可以用「批量恢复」撤回。`
|
||||||
|
} else if (action === 'restore') {
|
||||||
|
message = `将把选中的 ${changed} 人恢复为正常参赛,随后按新名次重算积分榜。`
|
||||||
|
} else {
|
||||||
|
message = `将从报名名单中物理删除选中的 ${changed} 人,成绩与积分流水一并删除,不可恢复。`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本页只操作当前小项;重算按 (小项, 组别) 走,所以组别数就是这里的规模。
|
||||||
|
if (scopeCount > 0) {
|
||||||
|
details.push(`会重算 1 个小项(当前项目)、${scopeCount} 个组别的名次与积分。`)
|
||||||
|
}
|
||||||
|
if (action === 'remove' && changed > MAX_LISTED_PEOPLE) {
|
||||||
|
message += `这会重算 1 个小项(当前项目)、${scopeCount} 个组别,别人的名次会变。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action, ids, changed, skipped, scopeCount, message, details }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量操作成功后的 toast 副标题:逐条成败 + 删除规模 + 重算范围。 */
|
||||||
|
export function entryBatchOutcome(result: EntryBatchResult): string {
|
||||||
|
const { summary } = result
|
||||||
|
const parts: string[] = []
|
||||||
|
parts.push(`成功 ${summary.ok} 条${summary.skipped > 0 ? `(跳过 ${summary.skipped} 条)` : ''}`)
|
||||||
|
if (summary.failed > 0) parts.push(`失败 ${summary.failed} 条`)
|
||||||
|
if (summary.removedEntries > 0) parts.push(`删除报名 ${summary.removedEntries} 条`)
|
||||||
|
if (summary.removedResults > 0) parts.push(`删除成绩 ${summary.removedResults} 条`)
|
||||||
|
if (summary.removedLedger > 0) parts.push(`删除流水 ${summary.removedLedger} 条`)
|
||||||
|
if (summary.recomputedEvents.length > 0) parts.push(`重算 ${summary.recomputedEvents.length} 个小项`)
|
||||||
|
return parts.join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCsrfToken(): string | null {
|
||||||
|
if (typeof document === 'undefined') return null
|
||||||
|
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
|
||||||
|
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRequestId(): string {
|
||||||
|
const cryptoApi: Crypto | undefined = globalThis.crypto
|
||||||
|
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
|
||||||
|
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_CODES: readonly string[] = [
|
||||||
|
'AUTH_REQUIRED',
|
||||||
|
'AUTH_FAILED',
|
||||||
|
'FORBIDDEN',
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'CONFLICT',
|
||||||
|
'STATE_INVALID',
|
||||||
|
'RATE_LIMITED',
|
||||||
|
'INTERNAL',
|
||||||
|
'NETWORK',
|
||||||
|
'TIMEOUT',
|
||||||
|
'PARSE_FAILED',
|
||||||
|
]
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
|
||||||
|
const safe: ApiErrorCode = typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
|
||||||
|
return new ApiError(safe, message, status, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交批量操作。写操作不降级到示例数据:示例数据模式下的「成功」会让操作员
|
||||||
|
* 以为已经退赛 / 删干净了,页面必须先看 data.isMock 再决定是否调用这里。
|
||||||
|
*/
|
||||||
|
export async function submitEntryBatch(payload: EntryBatchPayload): Promise<EntryBatchResult> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'X-Request-Id': defaultRequestId(),
|
||||||
|
}
|
||||||
|
const token = readCsrfToken()
|
||||||
|
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(ENTRY_BATCH_PATH, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ ...payload, requestId: payload.requestId ?? defaultRequestId() }),
|
||||||
|
})
|
||||||
|
} catch (caught) {
|
||||||
|
throw apiError('NETWORK', '无法连接服务端,批量操作没有生效', 0, caught)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await response.json()
|
||||||
|
} catch (caught) {
|
||||||
|
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
|
||||||
|
}
|
||||||
|
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
|
||||||
|
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
|
||||||
|
}
|
||||||
|
if (body['ok'] !== true) {
|
||||||
|
const error = isRecord(body['error']) ? body['error'] : {}
|
||||||
|
const message = typeof error['message'] === 'string' ? error['message'] : `批量操作失败(HTTP ${response.status})`
|
||||||
|
throw apiError(error['code'], message, response.status, error['detail'])
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw apiError('INTERNAL', `批量操作失败(HTTP ${response.status})`, response.status, body)
|
||||||
|
}
|
||||||
|
return body['data'] as EntryBatchResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 批次录入进度(纯函数):当前筛选范围内的「已录 / 总数」、可视化百分比与完成态。
|
||||||
|
*
|
||||||
|
* 抽出来的原因有两条:
|
||||||
|
* 一是「已录」的判据必须与发布防呆、行徽标完全同源,所以这里直接调用
|
||||||
|
* entryGuards 的 judgeEntryRow,绝不另写一套成绩解析——否则会出现
|
||||||
|
* 「进度条说录完了、点发布却说缺 8 条」这种自相矛盾的提示;
|
||||||
|
* 二是分母必须是**当前筛选范围**的行数(选中某一批时只数这一批),
|
||||||
|
* 这条容易写错也最难在界面上核对,需要能被 node --test 直接断言。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { heatLabel, filterRowsByHeat, type HeatFilter } from './entryHeat'
|
||||||
|
import { judgeEntryRow } from './entryGuards'
|
||||||
|
|
||||||
|
/** 只要求进度需要的字段,便于表格行与测试夹具共用。 */
|
||||||
|
export interface ProgressRow {
|
||||||
|
/** 批次号(null = 未分批);只影响分组,不参与算分。 */
|
||||||
|
heat: number | null
|
||||||
|
resultText: string
|
||||||
|
rank: number | null
|
||||||
|
/** 成绩状态(ok / dns / dq / dnf / withdrawn)。 */
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryProgressInput {
|
||||||
|
/** 组别下的全部行(未做批次筛选)。 */
|
||||||
|
rows: readonly ProgressRow[]
|
||||||
|
filter: HeatFilter
|
||||||
|
unit: string
|
||||||
|
/** 该项目是否以手工名次为成绩(manual / compare=none),与发布口径一致。 */
|
||||||
|
needsRank: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryProgress {
|
||||||
|
total: number
|
||||||
|
filled: number
|
||||||
|
missing: number
|
||||||
|
/** 写错的行数:既不计入「已录」,也不阻塞进度条显示,但要在文案里点出来。 */
|
||||||
|
error: number
|
||||||
|
/** 是否全部录完(有错的行不算已录,所以有错时永远不是完成态)。 */
|
||||||
|
done: boolean
|
||||||
|
/** 进度条宽度百分比(0-100,四舍五入)。 */
|
||||||
|
percent: number
|
||||||
|
/** 作用域文案:全部 / 第 N 批 / 未分批。 */
|
||||||
|
scope: string
|
||||||
|
/** 可直接展示的完整文案,如「第 2 批 已录 5/8 · 3 条有错」。 */
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进度的作用域称谓,与批次下拉的选项文案同源。 */
|
||||||
|
export function progressScopeLabel(filter: HeatFilter): string {
|
||||||
|
return filter === 'all' ? '全部' : heatLabel(filter === 'none' ? null : filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前筛选范围内的录入进度。
|
||||||
|
*
|
||||||
|
* 分母 = filterRowsByHeat 后的行数;分子只算 judgeEntryRow 判为 filled 的行,
|
||||||
|
* 未录(missing)与有错(error)都不算,有错时额外在文案里报数。
|
||||||
|
*/
|
||||||
|
export function entryProgress(input: EntryProgressInput): EntryProgress {
|
||||||
|
const rows = filterRowsByHeat(input.rows, input.filter)
|
||||||
|
let filled = 0
|
||||||
|
let missing = 0
|
||||||
|
let error = 0
|
||||||
|
|
||||||
|
for (const [index, row] of rows.entries()) {
|
||||||
|
// 行标签只进 blocker / warning 文案;这里只取 state,给行号即可定位。
|
||||||
|
const judged = judgeEntryRow({
|
||||||
|
label: `第 ${index + 1} 行`,
|
||||||
|
resultText: row.resultText,
|
||||||
|
unit: input.unit,
|
||||||
|
status: row.status,
|
||||||
|
rank: row.rank,
|
||||||
|
needsRank: input.needsRank,
|
||||||
|
})
|
||||||
|
if (judged.state === 'filled') filled += 1
|
||||||
|
else if (judged.state === 'error') error += 1
|
||||||
|
else missing += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = rows.length
|
||||||
|
const done = total > 0 && filled === total
|
||||||
|
const scope = progressScopeLabel(input.filter)
|
||||||
|
const head = done ? `${scope} 已录完 ${filled}/${total}` : `${scope} 已录 ${filled}/${total}`
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
filled,
|
||||||
|
missing,
|
||||||
|
error,
|
||||||
|
done,
|
||||||
|
percent: total === 0 ? 0 : Math.round((filled / total) * 100),
|
||||||
|
scope,
|
||||||
|
// 有错的行会被发布防呆拦下,进度里必须显形,否则「已录 5/8」看不出差在哪。
|
||||||
|
text: error > 0 ? `${head} · ${error} 条有错` : head,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 录入表格「运动员 / 队」单元格的展示片段(纯函数)。
|
||||||
|
*
|
||||||
|
* 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳,
|
||||||
|
* 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」
|
||||||
|
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EntryNameLike {
|
||||||
|
athleteName: string
|
||||||
|
/** 团体 / 接力行的队员名单;个人行为 null。 */
|
||||||
|
memberNames: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。 */
|
||||||
|
export function athleteCellName(row: EntryNameLike): string {
|
||||||
|
return row.athleteName || row.memberNames?.join('、') || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 紧跟姓名后的号码布文本;没有号码布(null / 空串 / 只有空白)时返回空串。
|
||||||
|
*
|
||||||
|
* 返回空串让模板用 v-if 直接不渲染这个 span:号码布为空的报名项很多,
|
||||||
|
* 渲染一个空节点会留出一段空白把表格列推歪。号码布按原样去掉首尾空白展示。
|
||||||
|
*/
|
||||||
|
export function inlineBibLabel(bib: string | null | undefined): string {
|
||||||
|
return bib === null || bib === undefined ? '' : bib.trim()
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { CurrentUser } from '@/shared'
|
import type { CurrentUser } from '@/shared'
|
||||||
import { adminApi, mockMode, setUnauthorizedHandler } from '../api/client'
|
import { adminApi, mockMode, setUnauthorizedHandler } from '../api/client'
|
||||||
|
import { readSessionExpiresAt } from '../components/system/sessionSettings'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话状态。
|
* 会话状态。
|
||||||
@@ -13,6 +14,8 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
const user = ref<CurrentUser | null>(null)
|
const user = ref<CurrentUser | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
/** 当前会话的到期时间(RFC3339 UTC);由 /auth/me 下发,用户菜单与系统设置页据此显示。 */
|
||||||
|
const sessionExpiresAt = ref<string | null>(null)
|
||||||
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
|
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
|
|
||||||
@@ -32,10 +35,13 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
user.value = await adminApi.me()
|
const me = await adminApi.me()
|
||||||
|
user.value = me
|
||||||
|
sessionExpiresAt.value = readSessionExpiresAt(me)
|
||||||
} catch {
|
} catch {
|
||||||
// 未登录 / 后端未就绪都走只读访客,不把用户挡在门外。
|
// 未登录 / 后端未就绪都走只读访客,不把用户挡在门外。
|
||||||
user.value = null
|
user.value = null
|
||||||
|
sessionExpiresAt.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -45,7 +51,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
user.value = await adminApi.login(username, password)
|
const me = await adminApi.login(username, password)
|
||||||
|
user.value = me
|
||||||
|
sessionExpiresAt.value = readSessionExpiresAt(me)
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
return true
|
return true
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
@@ -56,6 +64,26 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话心跳:重新问一次 /auth/me。
|
||||||
|
*
|
||||||
|
* 服务端的滑动续期靠「有请求」触发,而 Cookie 的期限又只在 /auth/me 里跟着续期同步,
|
||||||
|
* 所以打开着页面不动的管理端要定期打一次。失败(断网、已登出)一律静默:
|
||||||
|
* 心跳不能把只读访客踢出界面,也不能覆盖别的请求刚拿到的身份。
|
||||||
|
*/
|
||||||
|
async function refreshSession(): Promise<void> {
|
||||||
|
if (isGuest.value) return
|
||||||
|
try {
|
||||||
|
const me = await adminApi.me()
|
||||||
|
const expiresAt = readSessionExpiresAt(me)
|
||||||
|
if (expiresAt === null) return
|
||||||
|
user.value = me
|
||||||
|
sessionExpiresAt.value = expiresAt
|
||||||
|
} catch {
|
||||||
|
// 静默:下一次心跳再试。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await adminApi.logout()
|
await adminApi.logout()
|
||||||
@@ -63,10 +91,12 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
// 登出失败也要清本地态,否则用户会卡在已登录界面。
|
// 登出失败也要清本地态,否则用户会卡在已登录界面。
|
||||||
}
|
}
|
||||||
user.value = null
|
user.value = null
|
||||||
|
sessionExpiresAt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
user.value = null
|
user.value = null
|
||||||
|
sessionExpiresAt.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -78,9 +108,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
readOnly,
|
readOnly,
|
||||||
displayName,
|
displayName,
|
||||||
roleLabel,
|
roleLabel,
|
||||||
|
sessionExpiresAt,
|
||||||
mockMode,
|
mockMode,
|
||||||
load,
|
load,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
refreshSession,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运动员批量动作的前端契约。
|
||||||
|
*
|
||||||
|
* 这是绕开写限流的正解:逐个 DELETE 是「一人一条请求」,现场清理几十个人就会撞上配额;
|
||||||
|
* 因此这里钉住三件事——一个请求完成整批、逐条结果按顺序保留、缺失的逐条失败不能变成整批成功。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url?: string
|
||||||
|
method?: string
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [] } })
|
||||||
|
let lastRequest: Captured = {}
|
||||||
|
|
||||||
|
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||||
|
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||||
|
lastRequest = {
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? 'GET',
|
||||||
|
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||||
|
}
|
||||||
|
return reply()
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
function respondWith(response: () => Response): void {
|
||||||
|
lastRequest = {}
|
||||||
|
reply = response
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
|
||||||
|
function requestBody(): Record<string, unknown> {
|
||||||
|
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
|
||||||
|
delete parsed['requestId']
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const { adminApi } = await import('../api/client')
|
||||||
|
|
||||||
|
function item(id: number, ok = true, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return { id, ok, ...extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('批量停用是一个请求:POST /api/athletes/batch,body 带 action 与 ids', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: { action: 'disable', items: [item(1), item(2)] } }))
|
||||||
|
|
||||||
|
await adminApi.client.athletes.batch('disable', [1, 2])
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, '/api/athletes/batch')
|
||||||
|
assert.equal(lastRequest.method, 'POST')
|
||||||
|
assert.deepEqual(requestBody(), { action: 'disable', ids: [1, 2] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dryRun 才显式带 dryRun: true,普通请求不带(老服务端不能被静默降级成预览)', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', dryRun: true, items: [item(1)] } }))
|
||||||
|
|
||||||
|
const preview = await adminApi.client.athletes.batch('purge', [1], true)
|
||||||
|
assert.deepEqual(requestBody(), { action: 'purge', ids: [1], dryRun: true })
|
||||||
|
assert.equal(preview.dryRun, true)
|
||||||
|
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', items: [item(1)] } }))
|
||||||
|
await adminApi.client.athletes.batch('purge', [1])
|
||||||
|
assert.deepEqual(requestBody(), { action: 'purge', ids: [1] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('逐条成败与合计按 items 还原,purge 统计原样带出', async () => {
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
action: 'purge',
|
||||||
|
requested: 3,
|
||||||
|
succeeded: 2,
|
||||||
|
failed: 1,
|
||||||
|
items: [item(1), item(2), item(99, false, { code: 'NOT_FOUND', message: '运动员不存在' })],
|
||||||
|
purge: {
|
||||||
|
deletedEntries: 2,
|
||||||
|
deletedResults: 2,
|
||||||
|
deletedLedger: 3,
|
||||||
|
deletedBreaks: 0,
|
||||||
|
recomputedEvents: [5],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await adminApi.client.athletes.batch('purge', [1, 2, 99])
|
||||||
|
|
||||||
|
assert.equal(result.requested, 3)
|
||||||
|
assert.equal(result.succeeded, 2)
|
||||||
|
assert.equal(result.failed, 1)
|
||||||
|
assert.deepEqual(result.items[2], { id: 99, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
|
||||||
|
assert.deepEqual(result.purge, {
|
||||||
|
deletedEntries: 2,
|
||||||
|
deletedResults: 2,
|
||||||
|
deletedLedger: 3,
|
||||||
|
deletedBreaks: 0,
|
||||||
|
recomputedEvents: [5],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('逐条结果缺失时按失败处理:删不掉不能报成删掉了', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge' } }))
|
||||||
|
|
||||||
|
const result = await adminApi.client.athletes.batch('purge', [1, 2])
|
||||||
|
|
||||||
|
assert.deepEqual(result.items, [])
|
||||||
|
assert.equal(result.succeeded, 0)
|
||||||
|
assert.equal(result.failed, 0)
|
||||||
|
assert.equal(result.purge, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('响应里没有 ok: true 的条目按失败处理,不猜成成功', async () => {
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({ ok: true, data: { action: 'disable', items: [{ id: 1 }, { id: 2, ok: true }] } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await adminApi.client.athletes.batch('disable', [1, 2])
|
||||||
|
|
||||||
|
assert.equal(result.succeeded, 1)
|
||||||
|
assert.equal(result.failed, 1)
|
||||||
|
assert.equal(result.items[0]?.ok, false)
|
||||||
|
assert.equal(result.items[1]?.ok, true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { ApiError } from '@/shared'
|
||||||
|
import {
|
||||||
|
downloadBaseDataXLSX,
|
||||||
|
downloadEntriesXLSX,
|
||||||
|
entriesExportPath,
|
||||||
|
importBaseDataXLSX,
|
||||||
|
} from '@/shared/basedata'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报名名单导出与基础数据包导入的前端契约:路径、筛选参数、dryRun 标志、解析出的差异。
|
||||||
|
*
|
||||||
|
* 路径或参数写错时现场拿到的是 404 或「筛了却导出全量」;dryRun 标志丢了则会
|
||||||
|
* 让「预览差异」直接写库。这里用 stub fetch 钉住真正发出去的请求。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url?: string
|
||||||
|
method?: string
|
||||||
|
body?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function xlsxResponse(): Response {
|
||||||
|
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply: () => Response = () => xlsxResponse()
|
||||||
|
let lastRequest: Captured = {}
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||||
|
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
|
||||||
|
return reply()
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
function respondWith(next: () => Response): void {
|
||||||
|
lastRequest = {}
|
||||||
|
reply = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function formValue(body: unknown, key: string): FormDataEntryValue | null {
|
||||||
|
assert.ok(body instanceof FormData, '上传必须是 multipart FormData')
|
||||||
|
return body.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('报名名单导出路径按筛选条件拼 query,留空的维度不出现', () => {
|
||||||
|
assert.equal(entriesExportPath(), '/export/entries.xlsx')
|
||||||
|
assert.equal(entriesExportPath({ eventId: 3 }), '/export/entries.xlsx?eventId=3')
|
||||||
|
assert.equal(
|
||||||
|
entriesExportPath({ eventId: 3, categoryId: 9, teamId: 1, heat: 2 }),
|
||||||
|
'/export/entries.xlsx?eventId=3&categoryId=9&teamId=1&heat=2',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导出报名名单走 GET /api/export/entries.xlsx,文件名是「报名名单.xlsx」', async () => {
|
||||||
|
respondWith(() => xlsxResponse())
|
||||||
|
|
||||||
|
const result = await downloadEntriesXLSX({ eventId: 3, heat: 2 })
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, '/api/export/entries.xlsx?eventId=3&heat=2')
|
||||||
|
assert.equal(lastRequest.method, 'GET')
|
||||||
|
assert.equal(result.filename, '报名名单.xlsx')
|
||||||
|
assert.equal(result.blob.size, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导出基础数据包走 GET /api/export/basedata.xlsx,文件名是「基础数据包.xlsx」', async () => {
|
||||||
|
respondWith(() => xlsxResponse())
|
||||||
|
|
||||||
|
const result = await downloadBaseDataXLSX()
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, '/api/export/basedata.xlsx')
|
||||||
|
assert.equal(result.filename, '基础数据包.xlsx')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('预览差异带 dryRun=1,结果里的三类计数与逐行差异都解析出来', async () => {
|
||||||
|
respondWith(() => jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
dryRun: true,
|
||||||
|
added: 1,
|
||||||
|
updated: 2,
|
||||||
|
unchanged: 5,
|
||||||
|
errorCount: 1,
|
||||||
|
errors: ['单位 第 3 行:单位编码必填'],
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
sheet: '单位',
|
||||||
|
line: 2,
|
||||||
|
action: 'updated',
|
||||||
|
key: 'T1',
|
||||||
|
fields: [{ field: '单位名称', from: '第一分公司', to: '第一分公司(改)' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const file = new File(['x'], 'basedata.xlsx')
|
||||||
|
const result = await importBaseDataXLSX(file, { dryRun: true })
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, '/api/export/basedata/import')
|
||||||
|
assert.equal(lastRequest.method, 'POST')
|
||||||
|
assert.equal(formValue(lastRequest.body, 'dryRun'), '1')
|
||||||
|
assert.equal(formValue(lastRequest.body, 'file'), file)
|
||||||
|
assert.equal(result.dryRun, true)
|
||||||
|
assert.equal(result.added, 1)
|
||||||
|
assert.equal(result.updated, 2)
|
||||||
|
assert.equal(result.unchanged, 5)
|
||||||
|
assert.equal(result.errorCount, 1)
|
||||||
|
assert.deepEqual(result.errors, ['单位 第 3 行:单位编码必填'])
|
||||||
|
assert.equal(result.changes.length, 1)
|
||||||
|
assert.equal(result.changes[0].sheet, '单位')
|
||||||
|
assert.equal(result.changes[0].line, 2)
|
||||||
|
assert.equal(result.changes[0].fields[0].field, '单位名称')
|
||||||
|
assert.equal(result.changes[0].fields[0].from, '第一分公司')
|
||||||
|
assert.equal(result.changes[0].fields[0].to, '第一分公司(改)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('确认写入不带 dryRun,避免预览之后又走一次只读分支', async () => {
|
||||||
|
respondWith(() => jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: { dryRun: false, added: 3, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await importBaseDataXLSX(new File(['x'], 'basedata.xlsx'), { dryRun: false })
|
||||||
|
|
||||||
|
assert.equal(formValue(lastRequest.body, 'dryRun'), null)
|
||||||
|
assert.equal(result.dryRun, false)
|
||||||
|
assert.equal(result.added, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('服务端 400 的行级错误原样抛出,页面能拿到那句中文', async () => {
|
||||||
|
respondWith(() => jsonResponse({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'VALIDATION_FAILED', message: '导出链接参数不对' },
|
||||||
|
}, 400))
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => downloadEntriesXLSX({ heat: 0 }),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.status, 400)
|
||||||
|
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||||
|
assert.equal(error.message, '导出链接参数不对')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入 403 / 500 原样抛出,不降级成示例数据', async () => {
|
||||||
|
respondWith(() => jsonResponse({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'FORBIDDEN', message: '只有管理员可以导入基础数据' },
|
||||||
|
}, 403))
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.code, 'FORBIDDEN')
|
||||||
|
assert.equal(error.message, '只有管理员可以导入基础数据')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('响应不是 {ok,data} 结构时报解析错误,而不是假装导入成功', async () => {
|
||||||
|
respondWith(() => jsonResponse({ hello: 'world' }))
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.code, 'INTERNAL')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('连不上服务端时报 NETWORK,页面据此区分「后端未就绪」', async () => {
|
||||||
|
respondWith(() => {
|
||||||
|
throw new TypeError('fetch failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => downloadEntriesXLSX(),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.code, 'NETWORK')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组别「就地更新」的前端契约:方法 / 路径 / 请求体,以及响应是否按组别列表回传。
|
||||||
|
*
|
||||||
|
* 这条路径是有报名数据的小项唯一能改名的通道,路径拼错(比如漏掉 categoryId)
|
||||||
|
* 会静默走回整表替换的 409,现场只会看到「已有报名数据不能增删组别」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url?: string
|
||||||
|
method?: string
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply: () => Response = () => jsonResponse({ ok: true, data: [] })
|
||||||
|
let lastRequest: Captured = {}
|
||||||
|
|
||||||
|
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||||
|
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||||
|
lastRequest = {
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? 'GET',
|
||||||
|
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||||
|
}
|
||||||
|
return reply()
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
function respondWith(response: () => Response): void {
|
||||||
|
lastRequest = {}
|
||||||
|
reply = response
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
|
||||||
|
function requestBody(): Record<string, unknown> {
|
||||||
|
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
|
||||||
|
delete parsed['requestId']
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const { adminApi } = await import('../api/client')
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: '男子甲组',
|
||||||
|
multiplier: 2,
|
||||||
|
gender: '男' as const,
|
||||||
|
ageMin: 18,
|
||||||
|
ageMax: 35,
|
||||||
|
sortOrder: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
test('就地更新走 PUT /api/events/{id}/categories/{categoryId},请求体是单个组别', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: [] }))
|
||||||
|
|
||||||
|
await adminApi.client.events.updateCategory(7, 12, input)
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, '/api/events/7/categories/12')
|
||||||
|
assert.equal(lastRequest.method, 'PUT')
|
||||||
|
assert.deepEqual(requestBody(), input)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('响应按组别列表回传,直接拿到更新后的名字', async () => {
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ id: 12, eventId: 7, name: '男子甲组', multiplier: 2, gender: '男', ageMin: 18, ageMax: 35, sortOrder: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories = await adminApi.client.events.updateCategory(7, 12, input)
|
||||||
|
|
||||||
|
assert.equal(categories.length, 1)
|
||||||
|
assert.equal(categories[0]?.name, '男子甲组')
|
||||||
|
assert.equal(categories[0]?.multiplier, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('服务端 409 的原文要能被页面拿到(提示里那句话是现场唯一的线索)', async () => {
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '组别名称重复:男子' } }, 409),
|
||||||
|
)
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => adminApi.client.events.updateCategory(7, 12, input),
|
||||||
|
(error: unknown) => (error as { message?: string }).message === '组别名称重复:男子',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
|
||||||
|
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||||
|
const { mockBaseData } = await import('../components/baseData/mockBaseData')
|
||||||
|
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
|
||||||
|
respondWith(() => {
|
||||||
|
throw new TypeError('Failed to fetch')
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = mockBaseData.listEvents()[0]
|
||||||
|
assert.ok(event !== undefined)
|
||||||
|
const category = (mockBaseData.getEvent(event.id).categories ?? [])[0]
|
||||||
|
assert.ok(category !== undefined)
|
||||||
|
const categories = await baseDataApi.events.updateCategory(event.id, category.id, {
|
||||||
|
...input,
|
||||||
|
name: '降级改名',
|
||||||
|
})
|
||||||
|
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
|
||||||
|
assert.equal(baseDataFallback.value, true)
|
||||||
|
|
||||||
|
const athlete = mockBaseData.listAthletes()[0]
|
||||||
|
assert.ok(athlete !== undefined)
|
||||||
|
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
|
||||||
|
assert.equal(result.succeeded, 1)
|
||||||
|
assert.equal(result.failed, 0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { ApiError } from '@/shared'
|
||||||
|
import { ENTRY_BATCH_PATH, submitEntryBatch } from '../pages/entryBatch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报名项批量操作的请求契约:方法 / 路径 / 请求体字段必须与服务端
|
||||||
|
* internal/httpapi/entries_batch.go 的 entryBatchPayload 一致;
|
||||||
|
* 写请求漏掉 X-CSRF-Token 会被中间件 403,现场只会看到「删不掉」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url?: string
|
||||||
|
method?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: unknown
|
||||||
|
credentials?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastRequest: Captured = {}
|
||||||
|
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySummary() {
|
||||||
|
return {
|
||||||
|
ok: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
changed: 0,
|
||||||
|
removedEntries: 0,
|
||||||
|
removedResults: 0,
|
||||||
|
removedLedger: 0,
|
||||||
|
removedBreaks: 0,
|
||||||
|
recomputedEvents: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||||
|
lastRequest = {
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? 'GET',
|
||||||
|
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||||
|
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||||
|
credentials: init?.credentials,
|
||||||
|
}
|
||||||
|
return reply()
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
function respondWith(response: () => Response): void {
|
||||||
|
lastRequest = {}
|
||||||
|
reply = response
|
||||||
|
}
|
||||||
|
|
||||||
|
test('批量操作走 POST /api/entries/batch,body 带 action + ids', async () => {
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [{ id: 1, ok: true }],
|
||||||
|
summary: { ...emptySummary(), ok: 1, changed: 1, recomputedEvents: [3] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await submitEntryBatch({ action: 'scratch', ids: [1, 1, 2] })
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, ENTRY_BATCH_PATH)
|
||||||
|
assert.equal(lastRequest.method, 'POST')
|
||||||
|
assert.equal(lastRequest.credentials, 'include')
|
||||||
|
assert.equal((lastRequest.body as { action: string }).action, 'scratch')
|
||||||
|
assert.deepEqual((lastRequest.body as { ids: number[] }).ids, [1, 1, 2])
|
||||||
|
// 写请求必须有 requestId(幂等)与 JSON 内容类型。
|
||||||
|
assert.equal(typeof (lastRequest.body as { requestId?: unknown }).requestId, 'string')
|
||||||
|
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
|
||||||
|
assert.deepEqual(result.summary.recomputedEvents, [3])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已登录写请求必须带 CSRF:漏掉就是 403,退赛 / 移出都不会生效。
|
||||||
|
test('带 csrf_token Cookie 时补上 X-CSRF-Token', async () => {
|
||||||
|
const original = (globalThis as { document?: unknown }).document
|
||||||
|
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-123; b=2' }
|
||||||
|
try {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||||||
|
await submitEntryBatch({ action: 'restore', ids: [2] })
|
||||||
|
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-123')
|
||||||
|
} finally {
|
||||||
|
if (original === undefined) delete (globalThis as { document?: unknown }).document
|
||||||
|
else (globalThis as { document?: unknown }).document = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有 document(非浏览器环境)时不带 CSRF,也不报错', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||||||
|
await submitEntryBatch({ action: 'remove', ids: [3] })
|
||||||
|
assert.equal(lastRequest.headers?.['X-CSRF-Token'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 服务端的中文原文要原样传给页面;批量里的逐条失败也在 data 里,不能被当成请求失败。
|
||||||
|
test('服务端报错时原样抛出 code 与中文 message', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '一次最多处理 500 条报名项,请分批提交' } }, 400))
|
||||||
|
await assert.rejects(
|
||||||
|
() => submitEntryBatch({ action: 'remove', ids: [1] }),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||||
|
assert.match(error.message, /分批提交/)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('逐条失败(ok:false)仍算成功响应,由页面逐条提示', async () => {
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: 1, ok: true },
|
||||||
|
{ id: 999999, ok: false, error: '报名项不存在' },
|
||||||
|
],
|
||||||
|
summary: { ...emptySummary(), ok: 1, failed: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const result = await submitEntryBatch({ action: 'remove', ids: [1, 999999] })
|
||||||
|
assert.equal(result.summary.failed, 1)
|
||||||
|
assert.equal(result.items[1]?.error, '报名项不存在')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('网络不可达时报「没有生效」,不能让操作员以为已经删掉了', async () => {
|
||||||
|
respondWith(() => {
|
||||||
|
throw new TypeError('fetch failed')
|
||||||
|
})
|
||||||
|
await assert.rejects(
|
||||||
|
() => submitEntryBatch({ action: 'remove', ids: [1] }),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.code, 'NETWORK')
|
||||||
|
assert.match(error.message, /没有生效/)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
MAX_ENTRY_BATCH,
|
||||||
|
dedupeEntryIds,
|
||||||
|
entryBatchOutcome,
|
||||||
|
entryBatchSizeError,
|
||||||
|
entryBatchSkipNote,
|
||||||
|
planEntryBatch,
|
||||||
|
type EntryBatchRow,
|
||||||
|
} from '../pages/entryBatch'
|
||||||
|
|
||||||
|
function row(overrides: Partial<EntryBatchRow> & { entryId: number }): EntryBatchRow {
|
||||||
|
return {
|
||||||
|
name: '张三',
|
||||||
|
bib: '1042',
|
||||||
|
team: '第一分公司',
|
||||||
|
categoryId: 7,
|
||||||
|
entryStatus: 'registered',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dedupeEntryIds:去重、丢非法 id,保持勾选顺序', () => {
|
||||||
|
assert.deepEqual(dedupeEntryIds([3, 1, 3, 0, -2, 1, 2.5, 2]), [3, 1, 2])
|
||||||
|
assert.deepEqual(dedupeEntryIds([]), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entryBatchSizeError:空选与超上限都给出中文提示', () => {
|
||||||
|
assert.equal(entryBatchSizeError(0), '请先勾选要操作的报名项')
|
||||||
|
assert.equal(entryBatchSizeError(3), null)
|
||||||
|
assert.equal(entryBatchSizeError(MAX_ENTRY_BATCH), null)
|
||||||
|
assert.match(entryBatchSizeError(MAX_ENTRY_BATCH + 1) ?? '', /分批提交/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// dq / dns 是别人的判定:退赛不能覆盖,跳过时必须说清原因。
|
||||||
|
test('entryBatchSkipNote:只动 registered / scratched,其余跳过并说明', () => {
|
||||||
|
assert.equal(entryBatchSkipNote('scratch', 'registered'), null)
|
||||||
|
assert.equal(entryBatchSkipNote('scratch', 'scratched'), '已是退赛状态,跳过')
|
||||||
|
assert.equal(entryBatchSkipNote('scratch', 'dq'), '已被判罚取消资格(dq),跳过,不覆盖原判定')
|
||||||
|
assert.equal(entryBatchSkipNote('scratch', 'dns'), '已标记未出场(dns),跳过,不覆盖原判定')
|
||||||
|
assert.equal(entryBatchSkipNote('restore', 'scratched'), null)
|
||||||
|
assert.equal(entryBatchSkipNote('restore', 'registered'), '当前正常参赛,无需恢复')
|
||||||
|
// 移出报名不看状态:报名项本身就是要被物理删除的。
|
||||||
|
assert.equal(entryBatchSkipNote('remove', 'dq'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('planEntryBatch:退赛只改 registered,跳过的人在确认框里列出来', () => {
|
||||||
|
const plan = planEntryBatch('scratch', [
|
||||||
|
row({ entryId: 1 }),
|
||||||
|
row({ entryId: 2, entryStatus: 'dq', name: '李四' }),
|
||||||
|
row({ entryId: 1 }),
|
||||||
|
])
|
||||||
|
// 请求仍带上全部勾选 id:跳过与否由服务端逐条回报,前端不替它过滤。
|
||||||
|
assert.deepEqual(plan.ids, [1, 2])
|
||||||
|
assert.equal(plan.changed, 1)
|
||||||
|
assert.equal(plan.scopeCount, 1)
|
||||||
|
assert.deepEqual(plan.skipped, [{ name: '李四', note: '已被判罚取消资格(dq),跳过,不覆盖原判定' }])
|
||||||
|
assert.ok(plan.details.some((line) => line.includes('李四')))
|
||||||
|
assert.match(plan.message, /0 分且不占名次/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('planEntryBatch:恢复只改已退赛的人', () => {
|
||||||
|
const plan = planEntryBatch('restore', [
|
||||||
|
row({ entryId: 1, entryStatus: 'scratched' }),
|
||||||
|
row({ entryId: 2, entryStatus: 'registered', name: '王五' }),
|
||||||
|
])
|
||||||
|
assert.deepEqual(plan.ids, [1, 2])
|
||||||
|
assert.equal(plan.changed, 1)
|
||||||
|
assert.deepEqual(plan.skipped, [{ name: '王五', note: '当前正常参赛,无需恢复' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移出报名必须让操作员看到「删谁」与「重算几个组别」,规模大时还要明说别人的名次会变。
|
||||||
|
test('planEntryBatch:移出报名列出被删的人与重算范围', () => {
|
||||||
|
const plan = planEntryBatch('remove', [
|
||||||
|
row({ entryId: 1, name: '张三', bib: '1042', team: '第一分公司' }),
|
||||||
|
row({ entryId: 2, name: '李四', bib: null, team: '第二分公司', categoryId: 8 }),
|
||||||
|
])
|
||||||
|
assert.deepEqual(plan.ids, [1, 2])
|
||||||
|
assert.equal(plan.changed, 2)
|
||||||
|
assert.equal(plan.scopeCount, 2)
|
||||||
|
assert.deepEqual(plan.skipped, [])
|
||||||
|
assert.ok(plan.details.some((line) => line.includes('张三|号码 1042|第一分公司')))
|
||||||
|
assert.ok(plan.details.some((line) => line.includes('李四|号码 —|第二分公司')))
|
||||||
|
assert.ok(plan.details.some((line) => line.includes('会重算 1 个小项(当前项目)、2 个组别')))
|
||||||
|
assert.match(plan.message, /不可恢复/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('planEntryBatch:超过 20 人时只列前 20 并追加「别人的名次会变」', () => {
|
||||||
|
const rows = Array.from({ length: 25 }, (_, index) => row({ entryId: index + 1, name: `选手${index + 1}` }))
|
||||||
|
const plan = planEntryBatch('remove', rows)
|
||||||
|
assert.equal(plan.changed, 25)
|
||||||
|
assert.ok(plan.details.some((line) => line.includes('还有 5 人未列出')))
|
||||||
|
assert.match(plan.message, /别人的名次会变/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entryBatchOutcome:汇总逐条成败与删除规模', () => {
|
||||||
|
const text = entryBatchOutcome({
|
||||||
|
items: [],
|
||||||
|
summary: {
|
||||||
|
ok: 2,
|
||||||
|
failed: 1,
|
||||||
|
skipped: 1,
|
||||||
|
changed: 1,
|
||||||
|
removedEntries: 1,
|
||||||
|
removedResults: 1,
|
||||||
|
removedLedger: 2,
|
||||||
|
removedBreaks: 0,
|
||||||
|
recomputedEvents: [3],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.match(text, /成功 2 条(跳过 1 条)/)
|
||||||
|
assert.match(text, /失败 1 条/)
|
||||||
|
assert.match(text, /删除报名 1 条/)
|
||||||
|
assert.match(text, /删除流水 2 条/)
|
||||||
|
assert.match(text, /重算 1 个小项/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { entryProgress, progressScopeLabel, type ProgressRow } from '../pages/entryProgress'
|
||||||
|
import { judgeEntryRow } from '../pages/entryGuards'
|
||||||
|
|
||||||
|
function row(heat: number | null, resultText: string, over: Partial<ProgressRow> = {}): ProgressRow {
|
||||||
|
return { heat, resultText, rank: null, status: 'ok', ...over }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('分母是当前筛选范围:选中某一批时只数这一批', () => {
|
||||||
|
const rows = [row(1, '66'), row(1, ''), row(2, '70.1'), row(2, '71.2')]
|
||||||
|
|
||||||
|
const heat2 = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
|
||||||
|
assert.equal(heat2.total, 2)
|
||||||
|
assert.equal(heat2.filled, 2)
|
||||||
|
assert.equal(heat2.done, true)
|
||||||
|
assert.equal(heat2.text, '第 2 批 已录完 2/2')
|
||||||
|
assert.equal(heat2.percent, 100)
|
||||||
|
|
||||||
|
const heat1 = entryProgress({ rows, filter: 1, unit: 's', needsRank: false })
|
||||||
|
assert.equal(heat1.total, 2)
|
||||||
|
assert.equal(heat1.filled, 1)
|
||||||
|
assert.equal(heat1.missing, 1)
|
||||||
|
assert.equal(heat1.text, '第 1 批 已录 1/2')
|
||||||
|
|
||||||
|
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
|
||||||
|
assert.equal(all.total, 4)
|
||||||
|
assert.equal(all.filled, 3)
|
||||||
|
assert.equal(all.text, '全部 已录 3/4')
|
||||||
|
assert.equal(all.percent, 75)
|
||||||
|
assert.equal(all.done, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('筛选「未分批」只数没有批次号的行', () => {
|
||||||
|
const rows = [row(null, '66'), row(null, ''), row(1, '70')]
|
||||||
|
const progress = entryProgress({ rows, filter: 'none', unit: 's', needsRank: false })
|
||||||
|
assert.equal(progress.total, 2)
|
||||||
|
assert.equal(progress.filled, 1)
|
||||||
|
assert.equal(progress.text, '未分批 已录 1/2')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关键约束:进度条的「已录」必须与发布防呆 / 行徽标完全同源,否则会出现
|
||||||
|
// 「进度条说录完了、发布说缺 8 条」。这里直接拿 judgeEntryRow 的结果对账。
|
||||||
|
test('已录判据与 entryGuards 的 judgeEntryRow 同源(逐行对账)', () => {
|
||||||
|
const rows = [
|
||||||
|
row(1, '66.33'),
|
||||||
|
row(1, ''),
|
||||||
|
row(1, '1:70'),
|
||||||
|
row(1, '', { status: 'dns' }),
|
||||||
|
row(1, '0'),
|
||||||
|
row(1, '', { rank: 1 }),
|
||||||
|
row(1, ' '),
|
||||||
|
]
|
||||||
|
for (const needsRank of [false, true]) {
|
||||||
|
const expected = rows.filter((item, index) => {
|
||||||
|
const judged = judgeEntryRow({
|
||||||
|
label: `第 ${index + 1} 行`,
|
||||||
|
resultText: item.resultText,
|
||||||
|
unit: 's',
|
||||||
|
status: item.status,
|
||||||
|
rank: item.rank,
|
||||||
|
needsRank,
|
||||||
|
})
|
||||||
|
return judged.state === 'filled'
|
||||||
|
}).length
|
||||||
|
const progress = entryProgress({ rows, filter: 'all', unit: 's', needsRank })
|
||||||
|
assert.equal(progress.filled, expected, `needsRank=${needsRank}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS 这类现场结果没有数值也是「已录」,手动名次项目只填名次也算「已录」。
|
||||||
|
const progress = entryProgress({ rows: [row(1, '', { status: 'dns' })], filter: 'all', unit: 's', needsRank: false })
|
||||||
|
assert.equal(progress.filled, 1)
|
||||||
|
const rankOnly = entryProgress({ rows: [row(1, '', { rank: 1 })], filter: 'all', unit: 's', needsRank: true })
|
||||||
|
assert.equal(rankOnly.filled, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('有错的行不计入已录,并在文案里报数', () => {
|
||||||
|
const rows = [
|
||||||
|
row(2, '70.1'),
|
||||||
|
row(2, '70.2'),
|
||||||
|
row(2, '70.3'),
|
||||||
|
row(2, '70.4'),
|
||||||
|
row(2, '70.5'),
|
||||||
|
row(2, '1:70'),
|
||||||
|
row(2, '约1分06秒'),
|
||||||
|
row(2, '快点跑'),
|
||||||
|
]
|
||||||
|
const progress = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
|
||||||
|
assert.equal(progress.total, 8)
|
||||||
|
assert.equal(progress.filled, 5)
|
||||||
|
assert.equal(progress.error, 3)
|
||||||
|
assert.equal(progress.missing, 0)
|
||||||
|
assert.equal(progress.done, false, '有错时永远不是完成态')
|
||||||
|
assert.equal(progress.text, '第 2 批 已录 5/8 · 3 条有错')
|
||||||
|
assert.equal(progress.percent, 63)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('全部录完是完成态:文案改成已录完、进度条满格、变绿由 done 驱动', () => {
|
||||||
|
const rows = [row(3, '70.1'), row(3, '', { status: 'dq' }), row(3, '1:06')]
|
||||||
|
const progress = entryProgress({ rows, filter: 3, unit: 's', needsRank: false })
|
||||||
|
assert.equal(progress.done, true)
|
||||||
|
assert.equal(progress.filled, 3)
|
||||||
|
assert.ok(progress.text.includes('已录完'), progress.text)
|
||||||
|
assert.equal(progress.percent, 100)
|
||||||
|
|
||||||
|
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
|
||||||
|
assert.equal(all.text, '全部 已录完 3/3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('筛选范围内没有行时不算完成,也不会出现 NaN 百分比', () => {
|
||||||
|
const progress = entryProgress({ rows: [row(1, '66')], filter: 'none', unit: 's', needsRank: false })
|
||||||
|
assert.equal(progress.total, 0)
|
||||||
|
assert.equal(progress.percent, 0)
|
||||||
|
assert.equal(progress.done, false)
|
||||||
|
assert.equal(progress.text, '未分批 已录 0/0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('作用域称谓:全部 / 第 N 批 / 未分批', () => {
|
||||||
|
assert.equal(progressScopeLabel('all'), '全部')
|
||||||
|
assert.equal(progressScopeLabel(2), '第 2 批')
|
||||||
|
assert.equal(progressScopeLabel('none'), '未分批')
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { athleteCellName, inlineBibLabel } from '../pages/entryRowDisplay'
|
||||||
|
|
||||||
|
// 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。
|
||||||
|
test('有号码布:原样展示(去掉首尾空白)', () => {
|
||||||
|
assert.equal(inlineBibLabel('1042'), '1042')
|
||||||
|
assert.equal(inlineBibLabel(' 1042 '), '1042')
|
||||||
|
assert.equal(inlineBibLabel('A-07'), 'A-07')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 号码布为空时模板必须不渲染那个 span,否则这一列会被一段空白推歪。
|
||||||
|
test('没有号码布(null / 空串 / 只有空白):返回空串,不占位', () => {
|
||||||
|
assert.equal(inlineBibLabel(null), '')
|
||||||
|
assert.equal(inlineBibLabel(undefined), '')
|
||||||
|
assert.equal(inlineBibLabel(''), '')
|
||||||
|
assert.equal(inlineBibLabel(' '), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时占位', () => {
|
||||||
|
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
|
||||||
|
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
|
||||||
|
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
|
||||||
|
assert.equal(athleteCellName({ athleteName: '', memberNames: [] }), '—')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模板契约:号码布必须紧跟在姓名后面、且带 v-if;团体行(athleteName 为空)也要显示。
|
||||||
|
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => {
|
||||||
|
const cell = ENTRY_SOURCE.match(/<td>\{\{ athleteCellName\(row\) \}\}([\s\S]*?)<\/td>/)?.[1]
|
||||||
|
assert.ok(cell !== undefined, '找不到姓名单元格')
|
||||||
|
assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
|
||||||
|
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('原来的「号码」列保留,没有被这次改动删掉', () => {
|
||||||
|
assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from '../components/baseData/eventRowActions'
|
||||||
|
import type { BaseDataTabKey } from '../components/baseData/tabs'
|
||||||
|
|
||||||
|
// 双击小项条目 → 跳到该小项的组别列表。
|
||||||
|
// 浏览器在 dblclick 前必然先派发两次 click,这里模拟「先点过别的行、再双击目标行」,
|
||||||
|
// 确认最终选中项是被双击的那一项,而不是上一次单击留下的那一项。
|
||||||
|
test('双击:最终选中被双击的那一项,并跳到 categories', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
let selected: number | null = null
|
||||||
|
const deps = {
|
||||||
|
selectEvent: (id: number) => {
|
||||||
|
selected = id
|
||||||
|
calls.push(`select:${id}`)
|
||||||
|
},
|
||||||
|
goTab: (tab: BaseDataTabKey) => {
|
||||||
|
calls.push(`goTab:${tab}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.selectEvent(1)
|
||||||
|
openEventCategories(7, deps)
|
||||||
|
|
||||||
|
assert.equal(selected, 7)
|
||||||
|
assert.deepEqual(calls.slice(-2), ['select:7', 'goTab:categories'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 必须先选中再跳分区:反过来会在分区切换(可能重建列表)之后再改选中,跳过去的小项就不对。
|
||||||
|
test('双击:先选中、后跳转,顺序不能反', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
openEventCategories(3, {
|
||||||
|
selectEvent: (id) => calls.push(`select:${id}`),
|
||||||
|
goTab: (tab) => calls.push(`goTab:${tab}`),
|
||||||
|
})
|
||||||
|
assert.deepEqual(calls, ['select:3', 'goTab:categories'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模板契约:条目要绑 @dblclick,且 title 写明可发现性提示;
|
||||||
|
// 行内的小按钮(编辑 / 删除)用 @dblclick.stop 挡住,双击按钮不会误跳转。
|
||||||
|
const SECTION_SOURCE = readFileSync(
|
||||||
|
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
test('小项条目绑定双击跳转与 title 提示', () => {
|
||||||
|
assert.match(SECTION_SOURCE, /@dblclick="openCategories\(event\.id\)"/)
|
||||||
|
assert.match(SECTION_SOURCE, /:title="EVENT_ROW_DBLCLICK_TITLE"/)
|
||||||
|
assert.equal(EVENT_ROW_DBLCLICK_TITLE, '双击查看该小项的组别')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('条目里的小按钮不会被双击误触发跳转', () => {
|
||||||
|
assert.match(SECTION_SOURCE, /<div class="bd-actions" @dblclick\.stop>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 单击仍然只做选中:不能为了双击而给单击加延时(会让人以为界面卡了)。
|
||||||
|
test('单击没有被加延时,仍是直接选中', () => {
|
||||||
|
assert.match(SECTION_SOURCE, /@click="data\.selectEvent\(event\.id\)"/)
|
||||||
|
assert.ok(!/setTimeout/.test(SECTION_SOURCE), '双击实现不应引入延时')
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import { createApiClient } from '@/shared'
|
|||||||
import {
|
import {
|
||||||
REQUIRED_COLUMNS,
|
REQUIRED_COLUMNS,
|
||||||
conflictDiffs,
|
conflictDiffs,
|
||||||
|
decisionKeyOf,
|
||||||
decisionsPayload,
|
decisionsPayload,
|
||||||
pruneDecisions,
|
pruneDecisions,
|
||||||
recordDecisions,
|
recordDecisions,
|
||||||
@@ -119,6 +120,29 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
|
||||||
|
const shared = '110101199001010001'
|
||||||
|
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
|
||||||
|
const first = conflict({ row: 2, idCard: shared, bib: '101' })
|
||||||
|
const second = conflict({ row: 3, idCard: shared, bib: '102' })
|
||||||
|
assert.equal(decisionKeyOf(first), '101')
|
||||||
|
assert.equal(decisionKeyOf(second), '102')
|
||||||
|
|
||||||
|
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
|
||||||
|
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
|
||||||
|
assert.deepEqual(decisions, { 101: 'keep-new' })
|
||||||
|
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
|
||||||
|
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
|
||||||
|
{ idCard: shared, bib: '101', action: 'keep-new' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
|
||||||
|
const idCard = '110101199001010002'
|
||||||
|
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
|
||||||
|
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
|
||||||
|
})
|
||||||
|
|
||||||
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
|
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
|
||||||
const mock = new MockBaseData()
|
const mock = new MockBaseData()
|
||||||
const before = mock.listAthletes().length
|
const before = mock.listAthletes().length
|
||||||
@@ -137,57 +161,87 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
|
|||||||
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
|
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', async () => {
|
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
|
||||||
const mock = new MockBaseData()
|
const mock = new MockBaseData()
|
||||||
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
||||||
assert.equal(missing.skipped, 1)
|
assert.equal(missing.skipped, 1)
|
||||||
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
|
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
|
||||||
|
|
||||||
|
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
|
||||||
|
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
|
||||||
|
const before = mock.listAthletes().length
|
||||||
const duplicated = await mock.importEntries(
|
const duplicated = await mock.importEntries(
|
||||||
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
|
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
|
||||||
)
|
)
|
||||||
assert.equal(duplicated.created, 1, '第一行仍导入')
|
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
|
||||||
assert.equal(duplicated.skipped, 1)
|
assert.equal(duplicated.created, 2, '两行都落库')
|
||||||
assert.match(duplicated.errors[0] ?? '', /重复出现/)
|
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('导入:同一证件号但单位不同记为冲突且不落库,决策后按所选一侧处理', async () => {
|
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
|
||||||
const idCard = '110101199001010004'
|
const idCard = '110101199001010004'
|
||||||
const mock = new MockBaseData()
|
const mock = new MockBaseData()
|
||||||
await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
|
||||||
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
const first = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||||
|
assert.equal(first.created, 1)
|
||||||
|
assert.deepEqual(first.conflicts, [])
|
||||||
|
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
|
||||||
|
|
||||||
const conflicted = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
|
||||||
|
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||||
|
assert.equal(again.created, 1)
|
||||||
|
assert.deepEqual(again.conflicts, [])
|
||||||
|
|
||||||
|
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
|
||||||
|
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||||
|
assert.equal(taken.created, 0)
|
||||||
|
assert.equal(taken.skipped, 1)
|
||||||
|
assert.deepEqual(taken.conflicts, [])
|
||||||
|
assert.equal(
|
||||||
|
taken.errors[0],
|
||||||
|
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
|
||||||
|
'与服务端 idCardTakenError 同一句中文文案',
|
||||||
|
)
|
||||||
|
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入:号码布命中同一人但单位不同记为冲突,决策后按所选一侧处理', async () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
// 种子数据里 JG 的「张伟」号码布为 101;这一行按号码布命中他,但单位写成 F2。
|
||||||
|
const conflicted = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
|
||||||
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
||||||
assert.equal(conflicted.conflicts.length, 1)
|
assert.equal(conflicted.conflicts.length, 1)
|
||||||
assert.deepEqual(conflicted.conflicts[0], {
|
const [conflict] = conflicted.conflicts
|
||||||
row: 2,
|
assert.equal(conflict?.idCard, '')
|
||||||
idCard,
|
assert.equal(conflict?.bib, '101')
|
||||||
bib: '',
|
assert.deepEqual(conflict?.changedFields, ['teamCode'])
|
||||||
athleteId,
|
|
||||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
|
|
||||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
|
|
||||||
changedFields: ['teamCode'],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
|
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
|
||||||
const again = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
const again = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
|
||||||
assert.equal(again.conflicts.length, 1)
|
assert.equal(again.conflicts.length, 1)
|
||||||
|
|
||||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
// 证件号为空时决策键就是号码布(与服务端 decisionKey() 同序)。
|
||||||
{ idCard, bib: '', action: 'keep-existing' },
|
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
|
||||||
|
{ idCard: '', bib: '101', action: 'keep-existing' },
|
||||||
])
|
])
|
||||||
assert.equal(keptExisting.conflicts.length, 0)
|
assert.equal(keptExisting.conflicts.length, 0)
|
||||||
assert.equal(keptExisting.created, 1)
|
assert.equal(keptExisting.created, 1)
|
||||||
const afterExisting = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
assert.equal(
|
||||||
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
|
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
|
||||||
|
1,
|
||||||
|
'保留已有数据时人员单位不变(JG 的 id 为 1)',
|
||||||
|
)
|
||||||
|
|
||||||
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
|
||||||
{ idCard, bib: '', action: 'keep-new' },
|
{ idCard: '', bib: '101', action: 'keep-new' },
|
||||||
])
|
])
|
||||||
assert.equal(keptNew.conflicts.length, 0)
|
assert.equal(keptNew.conflicts.length, 0)
|
||||||
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
assert.equal(
|
||||||
assert.equal(afterNew?.teamId, 3, '使用新数据时人员单位更新为 F2 的 id')
|
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
|
||||||
|
3,
|
||||||
|
'使用新数据时人员单位更新为 F2 的 id',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
|
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
|
||||||
@@ -256,34 +310,43 @@ test('导入:姓名 + 单位命中且证件号为空时回填证件号并保
|
|||||||
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
|
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
|
test('导入:号码布不同 → 按「同名的另一个人」新建并报告', async () => {
|
||||||
const idCard = '110101199001010007'
|
|
||||||
const mock = new MockBaseData()
|
const mock = new MockBaseData()
|
||||||
// JG 的「张伟」证件号为空、号码布为 101,先用姓名 + 单位回填证件号。
|
// 种子数据里 JG 的「张伟」号码布为 101;这一行给了别的号码布 → 同名的另一个人。
|
||||||
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
|
const before = mock.listAthletes().length
|
||||||
const athleteId = mock.listAthletes().find((item) => item.idCard === idCard)?.id
|
const result = await mock.importEntries(csv([row('张伟', '110101199001010007', 'JG', '男', '999')]))
|
||||||
assert.ok(athleteId !== undefined)
|
|
||||||
|
|
||||||
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
|
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
|
||||||
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
|
||||||
|
assert.deepEqual(result.sameNameNewPersons, [
|
||||||
|
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
|
||||||
|
])
|
||||||
|
assert.equal(mock.listAthletes().length, before + 1)
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
|
||||||
|
// 库里那个人一个字段都没被改:同名的另一个人是新档案。
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.idCard, null)
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
|
||||||
|
// 先造出第二个「张伟」(都在 JG):号码布不同 → 同名的另一个人。
|
||||||
|
await mock.importEntries(csv([row('张伟', '', 'JG', '男', '999')]))
|
||||||
|
|
||||||
|
// 第三行只给了号码布 777,两个候选都对不上号码布:认不出是哪一个人,不落库。
|
||||||
|
const conflicted = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '777')]))
|
||||||
|
assert.equal(conflicted.created, 0)
|
||||||
assert.equal(conflicted.conflicts.length, 1)
|
assert.equal(conflicted.conflicts.length, 1)
|
||||||
const [conflict] = conflicted.conflicts
|
assert.equal(conflicted.conflicts[0]?.athleteId, firstId, '以第一条候选为代表')
|
||||||
assert.equal(conflict?.idCard, idCard)
|
|
||||||
assert.equal(conflict?.existing.bib, '101')
|
|
||||||
assert.equal(conflict?.incoming.bib, '999')
|
|
||||||
assert.deepEqual(conflict?.changedFields, ['bib'])
|
|
||||||
|
|
||||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
const decided = await mock.resolveImportConflicts(csv([row('张伟', '', 'JG', '男', '777')]), [
|
||||||
{ idCard, bib: '999', action: 'keep-existing' },
|
{ idCard: '', bib: '777', action: 'keep-new' },
|
||||||
])
|
])
|
||||||
assert.equal(keptExisting.created, 1)
|
assert.equal(decided.created, 1)
|
||||||
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '101')
|
assert.equal(decided.conflicts.length, 0)
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.id === firstId)?.bib, '777')
|
||||||
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.bib, '999', '另一个候选不被动')
|
||||||
{ idCard, bib: '999', action: 'keep-new' },
|
|
||||||
])
|
|
||||||
assert.equal(keptNew.created, 1)
|
|
||||||
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '999')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('导入:同一文件里号码布重复按行报错', async () => {
|
test('导入:同一文件里号码布重复按行报错', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例数据模式(服务端连不上)下,组别就地更新与运动员批量的降级行为。
|
||||||
|
*
|
||||||
|
* 这一层不接上的话,页面上这两个按钮在示例数据里会直接抛「无法连接服务端」——
|
||||||
|
* 而示例数据存在的意义就是让每个分区都能走通完整交互。
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('示例数据的组别就地更新保留组别 id,不重建', () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const event = mock.listEvents()[0]
|
||||||
|
assert.ok(event !== undefined)
|
||||||
|
const before = mock.getEvent(event.id).categories ?? []
|
||||||
|
const target = before[0]
|
||||||
|
assert.ok(target !== undefined)
|
||||||
|
|
||||||
|
const after = mock.updateCategory(event.id, target.id, {
|
||||||
|
name: '男子甲组',
|
||||||
|
multiplier: 2,
|
||||||
|
gender: '男',
|
||||||
|
ageMin: 18,
|
||||||
|
ageMax: 35,
|
||||||
|
sortOrder: target.sortOrder,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = after.find((category) => category.id === target.id)
|
||||||
|
assert.ok(updated !== undefined)
|
||||||
|
assert.equal(updated.name, '男子甲组')
|
||||||
|
assert.equal(updated.multiplier, 2)
|
||||||
|
// 其余组别一条不少:就地更新不是整表替换。
|
||||||
|
assert.equal(after.length, before.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('示例数据的组别就地更新:重名与不存在给与服务端一致的文案', () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
// 用新建的小项拿两个组别:种子事件各自只有一个组别,没法构造重名。
|
||||||
|
const seed = mock.listEvents()[0]
|
||||||
|
assert.ok(seed !== undefined)
|
||||||
|
const fresh = mock.createEvent({
|
||||||
|
sportId: seed.sportId,
|
||||||
|
code: 'DUP_CHECK',
|
||||||
|
name: '重名检查',
|
||||||
|
eventType: seed.eventType,
|
||||||
|
resultMode: seed.resultMode,
|
||||||
|
resultType: seed.resultType,
|
||||||
|
compare: seed.compare,
|
||||||
|
recordEligible: seed.recordEligible,
|
||||||
|
scored: seed.scored,
|
||||||
|
multiplier: seed.multiplier,
|
||||||
|
status: seed.status,
|
||||||
|
scheduledAt: null,
|
||||||
|
venue: null,
|
||||||
|
sortOrder: 99,
|
||||||
|
remark: null,
|
||||||
|
avgMode: seed.avgMode,
|
||||||
|
})
|
||||||
|
const categories = mock.saveCategories(fresh.id, [
|
||||||
|
{ name: '男子', multiplier: 1, gender: '男', ageMin: null, ageMax: null, sortOrder: 1 },
|
||||||
|
{ name: '女子', multiplier: 1, gender: '女', ageMin: null, ageMax: null, sortOrder: 2 },
|
||||||
|
])
|
||||||
|
const [first, second] = categories
|
||||||
|
assert.ok(first !== undefined && second !== undefined)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
mock.updateCategory(fresh.id, second.id, {
|
||||||
|
name: first.name,
|
||||||
|
multiplier: 1,
|
||||||
|
gender: null,
|
||||||
|
ageMin: null,
|
||||||
|
ageMax: null,
|
||||||
|
sortOrder: second.sortOrder,
|
||||||
|
}),
|
||||||
|
/组别名称重复/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
mock.updateCategory(fresh.id, 999999, {
|
||||||
|
name: '男子',
|
||||||
|
multiplier: 1,
|
||||||
|
gender: null,
|
||||||
|
ageMin: null,
|
||||||
|
ageMax: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
}),
|
||||||
|
/组别不存在/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('示例数据有报名数据时仍拒绝增删组别,但文案说清是「不能增删」', () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const catsOf = (eventId: number) => mock.getEvent(eventId).categories ?? []
|
||||||
|
|
||||||
|
// 找一个小项:它在示例数据里带报名条数(增删会被拒),改名仍然放行。
|
||||||
|
const blocked = mock.listEvents().filter((event) => {
|
||||||
|
const categories = catsOf(event.id)
|
||||||
|
if (categories.length === 0) return false
|
||||||
|
try {
|
||||||
|
mock.saveCategories(event.id, categories.map((category) => ({ ...category })))
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
assert.match(String(error), /不能新增或删除组别/)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.ok(blocked.length > 0, '示例数据里应有带报名条数的小项')
|
||||||
|
|
||||||
|
const event = blocked[0]
|
||||||
|
assert.ok(event !== undefined)
|
||||||
|
const first = catsOf(event.id)[0]
|
||||||
|
assert.ok(first !== undefined)
|
||||||
|
const after = mock.updateCategory(event.id, first.id, {
|
||||||
|
name: '改个名字',
|
||||||
|
multiplier: 1,
|
||||||
|
gender: first.gender,
|
||||||
|
ageMin: null,
|
||||||
|
ageMax: null,
|
||||||
|
sortOrder: first.sortOrder,
|
||||||
|
})
|
||||||
|
assert.equal(after.find((category) => category.id === first.id)?.name, '改个名字')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('示例数据批量停用:逐条回报,缺失 id 不整批失败,数据真被停用', () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const athletes = mock.listAthletes()
|
||||||
|
const [a, b] = athletes
|
||||||
|
assert.ok(a !== undefined && b !== undefined)
|
||||||
|
|
||||||
|
const result = mock.batchAthletes('disable', [a.id, b.id, 999999])
|
||||||
|
|
||||||
|
assert.equal(result.action, 'disable')
|
||||||
|
assert.equal(result.requested, 3)
|
||||||
|
assert.equal(result.succeeded, 2)
|
||||||
|
assert.equal(result.failed, 1)
|
||||||
|
assert.deepEqual(result.items[2], { id: 999999, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
|
||||||
|
assert.equal(result.purge, undefined)
|
||||||
|
|
||||||
|
const after = mock.listAthletes()
|
||||||
|
assert.equal(after.find((athlete) => athlete.id === a.id)?.active, false)
|
||||||
|
assert.equal(after.length, athletes.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('示例数据批量彻底删除:dryRun 只回报不改数据,真删才把人移出名单', () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const athletes = mock.listAthletes()
|
||||||
|
const target = athletes[0]
|
||||||
|
assert.ok(target !== undefined)
|
||||||
|
|
||||||
|
const preview = mock.batchAthletes('purge', [target.id], true)
|
||||||
|
assert.equal(preview.dryRun, true)
|
||||||
|
assert.equal(preview.succeeded, 1)
|
||||||
|
assert.deepEqual(preview.purge, {
|
||||||
|
deletedEntries: 0,
|
||||||
|
deletedResults: 0,
|
||||||
|
deletedLedger: 0,
|
||||||
|
deletedBreaks: 0,
|
||||||
|
recomputedEvents: [],
|
||||||
|
})
|
||||||
|
assert.equal(mock.listAthletes().length, athletes.length)
|
||||||
|
|
||||||
|
const real = mock.batchAthletes('purge', [target.id])
|
||||||
|
assert.equal(real.succeeded, 1)
|
||||||
|
assert.equal(mock.listAthletes().length, athletes.length - 1)
|
||||||
|
assert.equal(mock.listAthletes().some((athlete) => athlete.id === target.id), false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录有效期(系统级设置)的契约:
|
||||||
|
* 前端校验必须与服务端一致(1~720 整数小时),到期时间按本机时区渲染,
|
||||||
|
* 会话心跳要真的打到 GET /api/auth/me(服务端的滑动续期与 Cookie 同步都靠它)。
|
||||||
|
*
|
||||||
|
* adminApi 的 http 实例在模块初始化时就把 globalThis.fetch 绑死了,所以这里在文件顶部
|
||||||
|
* 装一个按 URL 分发的假 fetch,各用例只改 reply;用例内部再动态 import 被测模块。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url: string
|
||||||
|
method: string
|
||||||
|
body: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const captured: Captured = { url: '', method: '', body: null }
|
||||||
|
let reply: (url: string, method: string) => unknown = () => ({ ok: true, data: null })
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input)
|
||||||
|
const method = init?.method ?? 'GET'
|
||||||
|
captured.url = url
|
||||||
|
captured.method = method
|
||||||
|
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
|
||||||
|
const payload = reply(url, method)
|
||||||
|
if (payload instanceof Response) return payload
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
/** systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。 */
|
||||||
|
;(globalThis as { window?: unknown }).window = {
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: number) => clearTimeout(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ME_DATA = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
displayName: '裁判长',
|
||||||
|
role: 'admin',
|
||||||
|
mustChangePassword: false,
|
||||||
|
lastLoginAt: null,
|
||||||
|
sessionExpiresAt: '2026-09-12T00:31:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOUNDS = { minHours: 1, maxHours: 720 }
|
||||||
|
|
||||||
|
test('validateSessionHours 只接受 1~720 的整数小时', async () => {
|
||||||
|
const { validateSessionHours } = await import('../components/system/sessionSettings')
|
||||||
|
|
||||||
|
for (const [raw, want] of [
|
||||||
|
['1', 1],
|
||||||
|
['24', 24],
|
||||||
|
['720', 720],
|
||||||
|
[' 48 ', 48],
|
||||||
|
[72, 72],
|
||||||
|
] as const) {
|
||||||
|
const got = validateSessionHours(raw, BOUNDS)
|
||||||
|
assert.equal(got.ok, true, `应接受 ${String(raw)}`)
|
||||||
|
assert.equal(got.ok ? got.hours : 0, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of ['', ' ', '0', '721', '-1', '24.5', 'abc']) {
|
||||||
|
const got = validateSessionHours(raw, BOUNDS)
|
||||||
|
assert.equal(got.ok, false, `应拒绝 ${JSON.stringify(raw)}`)
|
||||||
|
if (!got.ok) assert.match(got.message, /登录有效期/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describeSessionLifetime 用小时与天数说明「登录后能待多久」', async () => {
|
||||||
|
const { describeSessionLifetime } = await import('../components/system/sessionSettings')
|
||||||
|
|
||||||
|
const day = describeSessionLifetime(24)
|
||||||
|
assert.match(day, /24 小时/)
|
||||||
|
assert.match(day, /1 天/)
|
||||||
|
assert.match(day, /顺延/)
|
||||||
|
|
||||||
|
assert.match(describeSessionLifetime(36), /1\.5 天/)
|
||||||
|
assert.match(describeSessionLifetime(0), /未设置/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readSessionExpiresAt 只认带时区的合法时间串', async () => {
|
||||||
|
const { readSessionExpiresAt } = await import('../components/system/sessionSettings')
|
||||||
|
|
||||||
|
assert.equal(readSessionExpiresAt(ME_DATA), '2026-09-12T00:31:00Z')
|
||||||
|
// 老服务端不下发这个字段、访客、以及字段被写坏的情况,都要当成「没有」。
|
||||||
|
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: undefined }), null)
|
||||||
|
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: '不是时间' }), null)
|
||||||
|
assert.equal(readSessionExpiresAt(null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formatSessionExpiry 渲染成本机时间的「当前会话将于 … 过期」', async () => {
|
||||||
|
const { formatSessionExpiry } = await import('../components/system/sessionSettings')
|
||||||
|
|
||||||
|
assert.match(formatSessionExpiry('2026-09-12T00:31:00Z'), /^当前会话将于 \d{4}-\d{2}-\d{2} \d{2}:\d{2} 过期$/)
|
||||||
|
assert.equal(formatSessionExpiry(null), '当前会话的到期时间未知')
|
||||||
|
assert.equal(formatSessionExpiry(''), '当前会话的到期时间未知')
|
||||||
|
assert.equal(formatSessionExpiry('2026-13-45'), '当前会话的到期时间未知')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('会话心跳重新拉 /api/auth/me 并刷新到期时间', async () => {
|
||||||
|
reply = () => ({ ok: true, data: ME_DATA })
|
||||||
|
const { useAuthStore } = await import('../stores/auth')
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const auth = useAuthStore()
|
||||||
|
await auth.load()
|
||||||
|
assert.equal(captured.url, '/api/auth/me')
|
||||||
|
assert.equal(auth.sessionExpiresAt, '2026-09-12T00:31:00Z')
|
||||||
|
|
||||||
|
// 服务端续期后下发新的到期时间,心跳要跟上(用户菜单与设置页显示的就是它)。
|
||||||
|
captured.url = ''
|
||||||
|
reply = () => ({ ok: true, data: { ...ME_DATA, sessionExpiresAt: '2026-09-13T00:31:00Z' } })
|
||||||
|
await auth.refreshSession()
|
||||||
|
assert.equal(captured.url, '/api/auth/me')
|
||||||
|
assert.equal(auth.sessionExpiresAt, '2026-09-13T00:31:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('访客不发起心跳', async () => {
|
||||||
|
reply = () => ({ ok: true, data: { ...ME_DATA, role: 'guest', sessionExpiresAt: null } })
|
||||||
|
const { useAuthStore } = await import('../stores/auth')
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const auth = useAuthStore()
|
||||||
|
await auth.load()
|
||||||
|
assert.equal(auth.isGuest, true)
|
||||||
|
assert.equal(auth.sessionExpiresAt, null)
|
||||||
|
|
||||||
|
captured.url = ''
|
||||||
|
await auth.refreshSession()
|
||||||
|
assert.equal(captured.url, '', '访客不该发起心跳')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sessionSettingsApi 走 /api/system/session 读写登录有效期', async () => {
|
||||||
|
reply = () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { sessionHours: 72, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: null },
|
||||||
|
})
|
||||||
|
const { sessionSettingsApi } = await import('../components/system/systemApi')
|
||||||
|
|
||||||
|
const loaded = await sessionSettingsApi.load()
|
||||||
|
assert.equal(captured.url, '/api/system/session')
|
||||||
|
assert.equal(captured.method, 'GET')
|
||||||
|
assert.equal(loaded.sessionHours, 72)
|
||||||
|
assert.equal(loaded.minHours, 1)
|
||||||
|
assert.equal(loaded.maxHours, 720)
|
||||||
|
|
||||||
|
reply = () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { sessionHours: 1, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: '2026-09-11T12:00:00Z' },
|
||||||
|
})
|
||||||
|
const saved = await sessionSettingsApi.save(1)
|
||||||
|
assert.equal(captured.url, '/api/system/session')
|
||||||
|
assert.equal(captured.method, 'PUT')
|
||||||
|
assert.equal(captured.body?.sessionHours, 1)
|
||||||
|
// 写请求带幂等用的 requestId(服务端据此防重复提交)。
|
||||||
|
assert.equal(typeof captured.body?.requestId, 'string')
|
||||||
|
assert.equal(saved.sessionHours, 1)
|
||||||
|
assert.equal(saved.expiresAt, '2026-09-11T12:00:00Z')
|
||||||
|
})
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import type { EventSettings } from '@/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批结算的前端契约:设置读取的默认值、未结算清单的路径与字段名、立即结算的请求体。
|
||||||
|
*
|
||||||
|
* 路径或字段名写错时现场看到的是「提示不出现 / 结算按错组生效」这类静默故障,
|
||||||
|
* 所以这里用 stub fetch 钉住真正发出去的请求。
|
||||||
|
*
|
||||||
|
* ⚠️ 共享 ApiClient 在模块初始化时就把 globalThis.fetch 绑死了,所以这里:
|
||||||
|
* 1. 不在文件顶部静态导入任何会构造 client 的模块(本文件的 import 都是类型或无副作用模块);
|
||||||
|
* 2. 桩在模块体里先装好,再用动态 import 拿被测模块;
|
||||||
|
* 3. 桩的响应由每个用例改的 responder 决定,多个用例共用一个桩。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url: string
|
||||||
|
method: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const captured: Captured = { url: '', method: '', headers: {}, body: null }
|
||||||
|
let responder: (request: Captured) => Response = () =>
|
||||||
|
new Response(JSON.stringify({ ok: true, data: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||||
|
captured.url = String(input)
|
||||||
|
captured.method = init?.method ?? 'GET'
|
||||||
|
captured.headers = (init?.headers ?? {}) as Record<string, string>
|
||||||
|
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
|
||||||
|
return responder(captured)
|
||||||
|
}) as typeof fetch
|
||||||
|
// systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。
|
||||||
|
;(globalThis as { window?: unknown }).window = {
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: number) => clearTimeout(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS = {
|
||||||
|
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
|
||||||
|
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
|
||||||
|
standings: {
|
||||||
|
individualToTeamWeight: 1,
|
||||||
|
rankingMode: 'points',
|
||||||
|
showIndividualBoard: true,
|
||||||
|
showIndividualMedals: true,
|
||||||
|
},
|
||||||
|
defaultProfileId: null,
|
||||||
|
requireApproval: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
test('liveOnPublishOf:缺字段 / 旧服务端一律按「实时结算」处理', async () => {
|
||||||
|
const { liveOnPublishOf } = await import('../components/system/systemApi')
|
||||||
|
|
||||||
|
assert.equal(liveOnPublishOf(null), true)
|
||||||
|
assert.equal(liveOnPublishOf(undefined), true)
|
||||||
|
assert.equal(liveOnPublishOf(SETTINGS as unknown as EventSettings), true)
|
||||||
|
assert.equal(
|
||||||
|
liveOnPublishOf({
|
||||||
|
...SETTINGS,
|
||||||
|
standings: { ...SETTINGS.standings, liveOnPublish: true },
|
||||||
|
} as unknown as EventSettings),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
liveOnPublishOf({
|
||||||
|
...SETTINGS,
|
||||||
|
standings: { ...SETTINGS.standings, liveOnPublish: false },
|
||||||
|
} as unknown as EventSettings),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settlementApi.status 读 GET /api/results/settlement 并归一未结算清单', async () => {
|
||||||
|
const { settlementApi } = await import('../components/system/systemApi')
|
||||||
|
responder = () =>
|
||||||
|
jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
liveOnPublish: false,
|
||||||
|
groups: [
|
||||||
|
{ eventId: 3, categoryId: 7, categoryName: '男子甲组', required: 8, published: 4 },
|
||||||
|
{ eventId: 3, categoryId: 8, categoryName: '女子甲组', required: 6, published: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = await settlementApi.status(3, 7)
|
||||||
|
|
||||||
|
assert.equal(captured.url, '/api/results/settlement?eventId=3&categoryId=7')
|
||||||
|
assert.equal(captured.method, 'GET')
|
||||||
|
assert.equal(status.liveOnPublish, false)
|
||||||
|
assert.deepEqual(status.groups[0], {
|
||||||
|
eventId: 3,
|
||||||
|
categoryId: 7,
|
||||||
|
categoryName: '男子甲组',
|
||||||
|
required: 8,
|
||||||
|
published: 4,
|
||||||
|
})
|
||||||
|
assert.equal(status.groups.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settlementApi.status 不带组别时只带 eventId', async () => {
|
||||||
|
const { settlementApi } = await import('../components/system/systemApi')
|
||||||
|
responder = () => jsonResponse({ ok: true, data: { liveOnPublish: true, groups: [] } })
|
||||||
|
|
||||||
|
const status = await settlementApi.status(9)
|
||||||
|
|
||||||
|
assert.equal(captured.url, '/api/results/settlement?eventId=9')
|
||||||
|
assert.equal(status.liveOnPublish, true)
|
||||||
|
assert.deepEqual(status.groups, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settlementApi.settle 发 POST /api/results/settle 并带 requestId', async () => {
|
||||||
|
const { settlementApi } = await import('../components/system/systemApi')
|
||||||
|
responder = () => jsonResponse({ ok: true, data: [] })
|
||||||
|
|
||||||
|
await settlementApi.settle(3, 7)
|
||||||
|
|
||||||
|
assert.equal(captured.url, '/api/results/settle')
|
||||||
|
assert.equal(captured.method, 'POST')
|
||||||
|
assert.equal(captured.body?.eventId, 3)
|
||||||
|
assert.equal(captured.body?.categoryId, 7)
|
||||||
|
// 服务端按 requestId 幂等:不带就会被当成重复提交。
|
||||||
|
assert.equal(typeof captured.headers['X-Request-Id'], 'string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('保存结算方式 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
|
||||||
|
responder = () =>
|
||||||
|
jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
data: { ...SETTINGS, standings: { ...SETTINGS.standings, liveOnPublish: false } },
|
||||||
|
})
|
||||||
|
const { liveOnPublishOf } = await import('../components/system/systemApi')
|
||||||
|
const { useEventStore } = await import('../stores/event')
|
||||||
|
const { useBaseData } = await import('../components/baseData/useBaseData')
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const event = useEventStore()
|
||||||
|
event.settings = { ...SETTINGS } as unknown as EventSettings
|
||||||
|
const data = useBaseData()
|
||||||
|
|
||||||
|
const saved = await data.saveLiveOnPublish(false)
|
||||||
|
|
||||||
|
assert.equal(captured.url, '/api/settings')
|
||||||
|
assert.equal(captured.method, 'PUT')
|
||||||
|
const standings = captured.body?.standings as Record<string, unknown>
|
||||||
|
assert.equal(standings.liveOnPublish, false)
|
||||||
|
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
|
||||||
|
assert.equal(standings.individualToTeamWeight, 1)
|
||||||
|
assert.equal(standings.rankingMode, 'points')
|
||||||
|
assert.equal(standings.showIndividualBoard, true)
|
||||||
|
|
||||||
|
assert.equal(liveOnPublishOf(saved), false)
|
||||||
|
// 录入页提示直接读 store 里的设置,不回写就等于「保存成功但提示没变」。
|
||||||
|
assert.equal(liveOnPublishOf(event.settings), false)
|
||||||
|
assert.equal(liveOnPublishOf(data.settings.value), false)
|
||||||
|
})
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { fitLiveRowFontPx, liveResultColumnPx } from '../../fitFont'
|
import { fitLiveRowFontPx, liveResultColumnPx } from '../../fitFont'
|
||||||
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
|
import {
|
||||||
|
LIVE_REFRESH_INTERVAL_MS,
|
||||||
|
fallbackLiveSources,
|
||||||
|
liveBlockRowCount,
|
||||||
|
liveBoardLayout,
|
||||||
|
liveRowBlocks,
|
||||||
|
pickLiveColumns,
|
||||||
|
} from '../../live'
|
||||||
import type { LiveEventSource } from '../../live'
|
import type { LiveEventSource } from '../../live'
|
||||||
import { displayName, displayResult } from '../../results'
|
import { displayName, displayResult } from '../../results'
|
||||||
import { useScoreboard } from '../../useScoreboard'
|
import { useScoreboard } from '../../useScoreboard'
|
||||||
@@ -47,18 +54,28 @@ const rowCount = computed(() =>
|
|||||||
boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0),
|
boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版式由「几个项目 + 一栏几行」决定(见 live.ts):一个项目且行数多时,一栏拆成左右两块,
|
||||||
|
* 让字号不再被高度压到 34px。两块的行数相同,所以字号取一块的行数。
|
||||||
|
*/
|
||||||
|
const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.value))
|
||||||
|
|
||||||
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
|
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
|
||||||
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
|
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
|
||||||
const resultTexts = computed(() =>
|
const resultTexts = computed(() =>
|
||||||
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
||||||
)
|
)
|
||||||
const rowFontPx = computed(() =>
|
const rowFontPx = computed(() =>
|
||||||
fitLiveRowFontPx(rowCount.value, boards.value.length, resultTexts.value),
|
fitLiveRowFontPx(
|
||||||
|
liveBlockRowCount(rowCount.value, layout.value),
|
||||||
|
layout.value,
|
||||||
|
resultTexts.value,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const boardStyle = computed(() => ({
|
const boardStyle = computed(() => ({
|
||||||
'--row-font': `${rowFontPx.value}px`,
|
'--row-font': `${rowFontPx.value}px`,
|
||||||
'--rs-width': `${liveResultColumnPx(resultTexts.value, boards.value.length, rowFontPx.value)}px`,
|
'--rs-width': `${liveResultColumnPx(resultTexts.value, layout.value, rowFontPx.value)}px`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
||||||
@@ -91,26 +108,28 @@ onUnmounted(() => {
|
|||||||
<span v-if="board.category" class="cat">{{ board.category }}</span>
|
<span v-if="board.category" class="cat">{{ board.category }}</span>
|
||||||
<span v-if="board.hiddenGroups > 0" class="more">另有 {{ board.hiddenGroups }} 组</span>
|
<span v-if="board.hiddenGroups > 0" class="more">另有 {{ board.hiddenGroups }} 组</span>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="board.rows.length > 0" class="rows">
|
<div v-if="board.rows.length > 0" class="rows" :class="{ split: layout === 'split' }">
|
||||||
<div class="row head">
|
<div v-for="(block, index) in liveRowBlocks(board.rows, layout)" :key="index" class="blk">
|
||||||
<span class="rk">名次</span>
|
<div class="row head">
|
||||||
<span class="nm">运动员 / 队</span>
|
<span class="rk">名次</span>
|
||||||
<span class="team">单位</span>
|
<span class="nm">运动员 / 队</span>
|
||||||
<span class="rs">成绩</span>
|
<span class="team">单位</span>
|
||||||
</div>
|
<span class="rs">成绩</span>
|
||||||
<div
|
</div>
|
||||||
v-for="row in board.rows"
|
<div
|
||||||
:key="row.entryId"
|
v-for="row in block"
|
||||||
class="row"
|
:key="row.entryId"
|
||||||
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
|
class="row"
|
||||||
>
|
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
|
||||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
>
|
||||||
<span class="nm">{{ displayName(row) }}</span>
|
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||||
<span class="team">
|
<span class="nm">{{ displayName(row) }}</span>
|
||||||
<TeamLogo :path="row.logoPath" />
|
<span class="team">
|
||||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
<TeamLogo :path="row.logoPath" />
|
||||||
</span>
|
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||||
<span class="rs">{{ displayResult(row) }}</span>
|
</span>
|
||||||
|
<span class="rs">{{ displayResult(row) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
|
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
|
||||||
@@ -139,9 +158,15 @@ onUnmounted(() => {
|
|||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* 只有一个项目时不必铺满 1920(一行字太长反而难读),居中留白与分项成绩页一致。 */
|
/* 只有一个项目时不再把卡片收窄居中。
|
||||||
|
*
|
||||||
|
* 收窄的旧理由是「一行字太长反而难读」,但那条说的是连续文字;这张是一张表——名次、单位、
|
||||||
|
* 成绩都各占一列、位置由列宽固定,视线是沿行走的,行宽不会影响读法。1920 的屏上收窄
|
||||||
|
* 1180 等于左右各留 370px 空白,而且收窄也换不来更大的字(字号由高度决定,与宽度无关):
|
||||||
|
* 那是纯粹浪费。铺满后左右只剩页框自带的 78px 边距,和顶栏、轮播条对齐。
|
||||||
|
*/
|
||||||
.boards-1 .board {
|
.boards-1 .board {
|
||||||
max-width: 1180px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
.board-head {
|
.board-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -176,6 +201,22 @@ h3 {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
/* 一块 = 表头 + 它的名次行。单栏版式下这一块从顶部开始排:表头要紧贴上面那条项目名,
|
||||||
|
行高封顶省下来的高度留在面板底部(行数是会长的,空在下面比空在中间像回事)。 */
|
||||||
|
.blk {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* 行数多到一张表压不出字(≥7 行)时,一栏拆成左右两块并排:半屏换来的高度直接变成字号。 */
|
||||||
|
.boards-1 .rows.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-content: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0 26px;
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* 成绩列宽由内容算(--rs-width,见 fitFont.ts):写死列宽时 56px 字号的 `1:09.09`
|
/* 成绩列宽由内容算(--rs-width,见 fitFont.ts):写死列宽时 56px 字号的 `1:09.09`
|
||||||
@@ -191,6 +232,20 @@ h3 {
|
|||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
animation: row-in 0.45s ease-out both;
|
animation: row-in 0.45s ease-out both;
|
||||||
}
|
}
|
||||||
|
/* 单栏满宽:名次列留足两位数(96px 字号下 `10` 的墨迹约 134px),运动员与单位各占一半弹性宽度。
|
||||||
|
行高再封顶 3.2 倍字号——行数少时(尤其只有 1 行)不把行拉满整屏高:96px 的字铺在 460px 高的行里
|
||||||
|
四周全是空的,封顶后 1 行约占可用高度的七成,余下的高度留在面板底部。
|
||||||
|
这两条只加在单栏版式上:两个项目时每栏的行数可能悬殊,行高拉伸与否维持原样。 */
|
||||||
|
.boards-1 .row {
|
||||||
|
grid-template-columns: 170px minmax(0, 1fr) minmax(0, 1fr) var(--rs-width, 220px);
|
||||||
|
gap: 18px;
|
||||||
|
max-height: calc(var(--row-font, 34px) * 3.2);
|
||||||
|
}
|
||||||
|
/* 拆成两块后每块只剩半屏宽:名次列窄一档但必须装得下两位数(56px 字号下 `10` 约 78px),
|
||||||
|
运动员列分到的宽度比单位多一半,4 个汉字的名字才不会被省略号截掉(实测 4 字名 @56px 约 224px)。 */
|
||||||
|
.boards-1 .rows.split .row {
|
||||||
|
grid-template-columns: 88px minmax(0, 1.5fr) minmax(0, 1fr) var(--rs-width, 150px);
|
||||||
|
}
|
||||||
.boards-2 .row {
|
.boards-2 .row {
|
||||||
/* 两栏时名次 / 单位也窄一档,成绩列同样跟着内容走。 */
|
/* 两栏时名次 / 单位也窄一档,成绩列同样跟着内容走。 */
|
||||||
grid-template-columns: 60px minmax(0, 1fr) 190px var(--rs-width, 150px);
|
grid-template-columns: 60px minmax(0, 1fr) 190px var(--rs-width, 150px);
|
||||||
|
|||||||
+55
-22
@@ -18,11 +18,19 @@ const EMPTY_ROW_FONT_PX = 48
|
|||||||
*
|
*
|
||||||
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
|
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
|
||||||
* 比例留出 padding 与行距,保证文字始终装得下。
|
* 比例留出 padding 与行距,保证文字始终装得下。
|
||||||
|
*
|
||||||
|
* capPx 用于实时赛况「单栏满宽」那种一栏占满整屏的版式:栏宽不是限制时,
|
||||||
|
* 半屏版式的 56px 观感上限会白白浪费掉高度换来的字号空间。
|
||||||
*/
|
*/
|
||||||
export function fitRowFontPx(count: number, availablePx: number, ratio = 0.62): number {
|
export function fitRowFontPx(
|
||||||
|
count: number,
|
||||||
|
availablePx: number,
|
||||||
|
ratio = 0.62,
|
||||||
|
capPx = MAX_ROW_FONT_PX,
|
||||||
|
): number {
|
||||||
if (count <= 0) return EMPTY_ROW_FONT_PX
|
if (count <= 0) return EMPTY_ROW_FONT_PX
|
||||||
const perRow = availablePx / count
|
const perRow = availablePx / count
|
||||||
return Math.floor(Math.min(MAX_ROW_FONT_PX, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
|
return Math.floor(Math.min(capPx, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||||
@@ -30,13 +38,37 @@ export function fitRowFont(count: number, availablePx: number, ratio = 0.62): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实时赛况一屏分几栏 → 行高比例。
|
* 实时赛况的三种版式,与 LivePage.vue 的网格一一对应。
|
||||||
*
|
*
|
||||||
* 与 LivePage.vue 的 .boards-1 / .boards-2 网格同源:两栏时每栏只有半屏宽,
|
* - single:单项目一栏,且行数少到可以铺满整屏一张表(行号少,字号吃得下满宽);
|
||||||
* 行高(也就是字号)要再收一档。
|
* - split :单项目一栏,但行数多到「一张表」只能压出很小的字——把名次拆成左右两块并排,
|
||||||
|
* 半屏换来的高度直接变成字号(10 行:34px → 56px);
|
||||||
|
* - duo :两个项目左右各一栏(每栏半屏,宽高都紧张)。
|
||||||
*/
|
*/
|
||||||
export function liveRowFontRatio(columns: number): number {
|
export type LiveLayout = 'single' | 'split' | 'duo'
|
||||||
return columns > 1 ? 0.48 : 0.62
|
|
||||||
|
interface LiveLayoutSpec {
|
||||||
|
/** 行高比例:可用高度 ÷ 行数 × 比例,就是字号。 */
|
||||||
|
ratio: number
|
||||||
|
/** 字号上限。 */
|
||||||
|
capPx: number
|
||||||
|
/** 成绩列宽下限。 */
|
||||||
|
resultFloorPx: number
|
||||||
|
/** 成绩列宽预算:成绩列最多占这么宽,再宽就得靠收字号解决。 */
|
||||||
|
resultBudgetPx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各版式的四个数字。
|
||||||
|
*
|
||||||
|
* duo 沿用改前的一整套(0.48 / 56 / 150 / 240),两栏裁切刚修好,别动它。
|
||||||
|
* split 的行高比例回到 0.62:拆两块之后每块只有半屏宽、却是整屏高,行数也减半
|
||||||
|
* (最多 5 行),限制来自宽度而不是高度。
|
||||||
|
*/
|
||||||
|
const LIVE_LAYOUT_SPECS: Record<LiveLayout, LiveLayoutSpec> = {
|
||||||
|
single: { ratio: 0.62, capPx: 96, resultFloorPx: 220, resultBudgetPx: 440 },
|
||||||
|
split: { ratio: 0.62, capPx: MAX_ROW_FONT_PX, resultFloorPx: 150, resultBudgetPx: 300 },
|
||||||
|
duo: { ratio: 0.48, capPx: MAX_ROW_FONT_PX, resultFloorPx: 150, resultBudgetPx: 240 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,19 +77,19 @@ export function liveRowFontRatio(columns: number): number {
|
|||||||
* 保留下限是为了「短成绩时观感不变」:`59.99` 这类文本本来就装得下,列宽不该因为
|
* 保留下限是为了「短成绩时观感不变」:`59.99` 这类文本本来就装得下,列宽不该因为
|
||||||
* 改成按文本算就抖一下。
|
* 改成按文本算就抖一下。
|
||||||
*/
|
*/
|
||||||
export function liveResultColumnFloorPx(columns: number): number {
|
export function liveResultColumnFloorPx(layout: LiveLayout): number {
|
||||||
return columns > 1 ? 150 : 220
|
return LIVE_LAYOUT_SPECS[layout].resultFloorPx
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成绩列宽度预算(px):成绩列最多占这么宽,再宽就得靠收字号解决。
|
* 成绩列宽度预算(px)。半屏版式(split / duo)一行可用宽约 815px,扣掉名次、单位后剩 541px
|
||||||
*
|
* 由「运动员 + 成绩」分:预算 240–300px 给成绩,运动员列还能留 240px 以上(56px 字号下约 4 个
|
||||||
* 两栏时一行可用宽约 815px,扣掉名次 60 / 单位 190 / 间距 24 后剩 541px 由「运动员 + 成绩」
|
* 汉字);名字本来就是可以省略号截断的次要信息,末位成绩被裁才是不能接受的
|
||||||
* 分。预算 240px 给成绩,运动员列还能留 300px(56px 字号下约 5 个汉字);名字本来就是
|
* (`1:09.0` 会被读成另一个成绩)。满宽版式(single)一行有 1700px 可用,预算给到 440px,
|
||||||
* 可以省略号截断的次要信息,末位成绩被裁才是不能接受的(`1:09.0` 会被读成另一个成绩)。
|
* 连 `64:21.50` 这种长成绩都不用收字号。
|
||||||
*/
|
*/
|
||||||
export function liveResultColumnBudgetPx(columns: number): number {
|
export function liveResultColumnBudgetPx(layout: LiveLayout): number {
|
||||||
return columns > 1 ? 240 : 340
|
return LIVE_LAYOUT_SPECS[layout].resultBudgetPx
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 估宽时额外留的像素:抹掉 ceil 与亚像素误差,避免差 1px 又把末位数字裁掉。 */
|
/** 估宽时额外留的像素:抹掉 ceil 与亚像素误差,避免差 1px 又把末位数字裁掉。 */
|
||||||
@@ -95,18 +127,19 @@ function widestResultEm(results: readonly string[]): number {
|
|||||||
*
|
*
|
||||||
* 真机缺陷:两栏时成绩列写死 150px,而 `1:09.09` 在 56px 字号下墨迹 201px,末位数字被外层
|
* 真机缺陷:两栏时成绩列写死 150px,而 `1:09.09` 在 56px 字号下墨迹 201px,末位数字被外层
|
||||||
* overflow:hidden 裁掉;1–6 行时字号都大于 41px(150px 列的容限),于是全中招。这里把
|
* overflow:hidden 裁掉;1–6 行时字号都大于 41px(150px 列的容限),于是全中招。这里把
|
||||||
* 「装不下就收字号」当兜底:典型成绩(4.2em)在 240px 预算下仍能取满 56px,靠加宽成绩列解决;
|
* 「装不下就收字号」当兜底:典型成绩(4.2em)在预算内仍能取满该版式的上限,靠加宽成绩列解决;
|
||||||
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
|
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
|
||||||
*/
|
*/
|
||||||
export function fitLiveRowFontPx(
|
export function fitLiveRowFontPx(
|
||||||
rowCount: number,
|
rowCount: number,
|
||||||
columns: number,
|
layout: LiveLayout,
|
||||||
results: readonly string[],
|
results: readonly string[],
|
||||||
): number {
|
): number {
|
||||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, liveRowFontRatio(columns))
|
const spec = LIVE_LAYOUT_SPECS[layout]
|
||||||
|
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
|
||||||
const widest = widestResultEm(results)
|
const widest = widestResultEm(results)
|
||||||
if (widest <= 0) return base
|
if (widest <= 0) return base
|
||||||
const byWidth = Math.floor((liveResultColumnBudgetPx(columns) - RESULT_COLUMN_PAD_PX) / widest)
|
const byWidth = Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest)
|
||||||
return Math.max(MIN_ROW_FONT_PX, Math.min(base, byWidth))
|
return Math.max(MIN_ROW_FONT_PX, Math.min(base, byWidth))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +152,9 @@ export function fitLiveRowFontPx(
|
|||||||
*/
|
*/
|
||||||
export function liveResultColumnPx(
|
export function liveResultColumnPx(
|
||||||
results: readonly string[],
|
results: readonly string[],
|
||||||
columns: number,
|
layout: LiveLayout,
|
||||||
fontPx: number,
|
fontPx: number,
|
||||||
): number {
|
): number {
|
||||||
const need = Math.ceil(widestResultEm(results) * fontPx) + RESULT_COLUMN_PAD_PX
|
const need = Math.ceil(widestResultEm(results) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||||
return Math.max(liveResultColumnFloorPx(columns), need)
|
return Math.max(liveResultColumnFloorPx(layout), need)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||||
|
import type { LiveLayout } from './fitFont'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
|
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
|
||||||
@@ -18,6 +19,16 @@ export const MAX_LIVE_COLUMNS = 2
|
|||||||
*/
|
*/
|
||||||
export const MAX_LIVE_ROWS = 10
|
export const MAX_LIVE_ROWS = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单项目一栏时,行数到几行就把名次拆成左右两块并排。
|
||||||
|
*
|
||||||
|
* 阈值取 7 是因为它正好是「拆开之后字更大」的临界点:字号按 BOARD_AVAILABLE_PX 与高度比例
|
||||||
|
* 0.62 算(见 fitFont.ts),一张表铺满时 7 行只剩 49px(低于半屏版式的 56px 上限),而 6 行
|
||||||
|
* 还有 57px。7 行起拆成两块(每块 ≤5 行)换来的字号永远更大,再往下(8/9/10 行)差距越拉越大;
|
||||||
|
* fitFont.test.ts 里有一条测试盯着这个临界点。
|
||||||
|
*/
|
||||||
|
export const LIVE_SPLIT_MIN_ROWS = 7
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定时兜底刷新间隔。
|
* 定时兜底刷新间隔。
|
||||||
*
|
*
|
||||||
@@ -116,6 +127,33 @@ export function liveRefreshDelay(
|
|||||||
return Math.max(0, minGapMs - (now - lastAttemptAt))
|
return Math.max(0, minGapMs - (now - lastAttemptAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单栏拆几块:只有「一个项目 + 行数够多」才拆,两个项目时本来就是一栏一块。
|
||||||
|
*/
|
||||||
|
export function liveBoardLayout(columns: number, rowCount: number): LiveLayout {
|
||||||
|
if (columns > 1) return 'duo'
|
||||||
|
return rowCount >= LIVE_SPLIT_MIN_ROWS ? 'split' : 'single'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字号要按「一块里有几行」算,而不是整栏几行——拆两块的意义就在这里。
|
||||||
|
*/
|
||||||
|
export function liveBlockRowCount(rowCount: number, layout: LiveLayout): number {
|
||||||
|
return layout === 'split' ? Math.ceil(rowCount / 2) : rowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一栏的名次切成左右两块:上半(名次靠前)在左,下半在右。
|
||||||
|
*
|
||||||
|
* 前一半多一行(7 行 → 4 + 3),让更重要的名次留在左块;两块的行数最多差 1 行,
|
||||||
|
* 行高才不会一块紧一块松。非 split 版式原样返回单块。
|
||||||
|
*/
|
||||||
|
export function liveRowBlocks<T>(rows: readonly T[], layout: LiveLayout): T[][] {
|
||||||
|
if (layout !== 'split' || rows.length < LIVE_SPLIT_MIN_ROWS) return [rows as T[]]
|
||||||
|
const half = Math.ceil(rows.length / 2)
|
||||||
|
return [rows.slice(0, half), rows.slice(half)]
|
||||||
|
}
|
||||||
|
|
||||||
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
|
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
|
||||||
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
|
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
|
||||||
let latest: EventResultGroup | null = null
|
let latest: EventResultGroup | null = null
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
liveResultColumnPx,
|
liveResultColumnPx,
|
||||||
resultTextEm,
|
resultTextEm,
|
||||||
} from '../fitFont'
|
} from '../fitFont'
|
||||||
|
import { LIVE_SPLIT_MIN_ROWS } from '../live'
|
||||||
|
|
||||||
test('fitRowFont 空列表用固定字号', () => {
|
test('fitRowFont 空列表用固定字号', () => {
|
||||||
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
|
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
|
||||||
@@ -39,11 +40,11 @@ test('两栏 1–10 行:字号封顶 56px、墨迹一律装得进算出来的
|
|||||||
const widest = resultTextEm('1:06.33')
|
const widest = resultTextEm('1:06.33')
|
||||||
const expectedFont = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
|
const expectedFont = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
|
||||||
for (let rows = 1; rows <= 10; rows++) {
|
for (let rows = 1; rows <= 10; rows++) {
|
||||||
const font = fitLiveRowFontPx(rows, 2, results)
|
const font = fitLiveRowFontPx(rows, 'duo', results)
|
||||||
assert.equal(font, expectedFont[rows - 1], `${rows} 行`)
|
assert.equal(font, expectedFont[rows - 1], `${rows} 行`)
|
||||||
assert.ok(font <= MAX_ROW_FONT_PX)
|
assert.ok(font <= MAX_ROW_FONT_PX)
|
||||||
const column = liveResultColumnPx(results, 2, font)
|
const column = liveResultColumnPx(results, 'duo', font)
|
||||||
assert.ok(column >= liveResultColumnFloorPx(2), `${rows} 行不该比原来的 150px 更窄`)
|
assert.ok(column >= liveResultColumnFloorPx('duo'), `${rows} 行不该比原来的 150px 更窄`)
|
||||||
assert.ok(widest * font <= column, `${rows} 行仍会裁`)
|
assert.ok(widest * font <= column, `${rows} 行仍会裁`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -52,54 +53,84 @@ test('两栏 1–7 行才需要加宽成绩列,8 行以上仍用原来的 150p
|
|||||||
const results = ['1:06.33']
|
const results = ['1:06.33']
|
||||||
const widest = resultTextEm('1:06.33')
|
const widest = resultTextEm('1:06.33')
|
||||||
for (let rows = 1; rows <= 7; rows++) {
|
for (let rows = 1; rows <= 7; rows++) {
|
||||||
const font = fitLiveRowFontPx(rows, 2, results)
|
const font = fitLiveRowFontPx(rows, 'duo', results)
|
||||||
// 写死 150px 时这些行数真的会裁:这正是本次要修的真机缺陷。
|
// 写死 150px 时这些行数真的会裁:这正是本次要修的真机缺陷。
|
||||||
assert.ok(widest * font > liveResultColumnFloorPx(2), `${rows} 行`)
|
assert.ok(widest * font > liveResultColumnFloorPx('duo'), `${rows} 行`)
|
||||||
assert.ok(liveResultColumnPx(results, 2, font) >= widest * font)
|
assert.ok(liveResultColumnPx(results, 'duo', font) >= widest * font)
|
||||||
}
|
}
|
||||||
for (const rows of [8, 9, 10]) {
|
for (const rows of [8, 9, 10]) {
|
||||||
const font = fitLiveRowFontPx(rows, 2, results)
|
const font = fitLiveRowFontPx(rows, 'duo', results)
|
||||||
assert.ok(widest * font <= liveResultColumnFloorPx(2), `${rows} 行`)
|
assert.ok(widest * font <= liveResultColumnFloorPx('duo'), `${rows} 行`)
|
||||||
assert.equal(liveResultColumnPx(results, 2, font), 150)
|
assert.equal(liveResultColumnPx(results, 'duo', font), 150)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('长成绩:先加宽列,装不下才收字号,且不越预算', () => {
|
test('长成绩:先加宽列,装不下才收字号,且不越预算', () => {
|
||||||
const results = ['64:21.50']
|
const results = ['64:21.50']
|
||||||
const font = fitLiveRowFontPx(1, 2, results)
|
const font = fitLiveRowFontPx(1, 'duo', results)
|
||||||
assert.ok(font < MAX_ROW_FONT_PX, '特别长的成绩要让出字号')
|
assert.ok(font < MAX_ROW_FONT_PX, '特别长的成绩要让出字号')
|
||||||
assert.ok(font >= 40, '让出的幅度不该把大屏字变小')
|
assert.ok(font >= 40, '让出的幅度不该把大屏字变小')
|
||||||
const column = liveResultColumnPx(results, 2, font)
|
const column = liveResultColumnPx(results, 'duo', font)
|
||||||
assert.ok(resultTextEm('64:21.50') * font <= column)
|
assert.ok(resultTextEm('64:21.50') * font <= column)
|
||||||
assert.ok(column <= liveResultColumnBudgetPx(2))
|
assert.ok(column <= liveResultColumnBudgetPx('duo'))
|
||||||
|
|
||||||
// 典型成绩(7 字符)不该因为这条上限被收字号。
|
// 典型成绩(7 字符)不该因为这条上限被收字号。
|
||||||
assert.equal(fitLiveRowFontPx(1, 2, ['1:06.33', '1:09.09']), 56)
|
assert.equal(fitLiveRowFontPx(1, 'duo', ['1:06.33', '1:09.09']), 56)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('单栏:56px 上限不因宽度被削,短成绩仍用原来的 220px 列', () => {
|
test('单栏满宽:字号用满高度(96px 上限),短成绩仍守着原来的 220px 列', () => {
|
||||||
assert.equal(fitLiveRowFontPx(1, 1, ['1:09.09']), MAX_ROW_FONT_PX)
|
// 收窄到 1180px 的旧版式把这里的字号压在 56px;铺满全场后高度换来的字号不再被削。
|
||||||
assert.equal(fitLiveRowFontPx(6, 1, ['1:09.09']), MAX_ROW_FONT_PX)
|
assert.equal(fitLiveRowFontPx(1, 'single', ['1:09.09']), 96)
|
||||||
assert.equal(liveResultColumnPx(['59.99'], 1, MAX_ROW_FONT_PX), liveResultColumnFloorPx(1))
|
assert.equal(fitLiveRowFontPx(3, 'single', ['1:09.09']), 96)
|
||||||
// 单栏也会裁长成绩(220px 装不下 56px 的 `64:21.50`),同样由内容定宽修掉。
|
assert.equal(fitLiveRowFontPx(4, 'single', ['1:09.09']), 86)
|
||||||
const font = fitLiveRowFontPx(1, 1, ['64:21.50'])
|
assert.equal(fitLiveRowFontPx(6, 'single', ['1:09.09']), 57)
|
||||||
assert.equal(font, MAX_ROW_FONT_PX)
|
assert.equal(
|
||||||
assert.ok(liveResultColumnPx(['64:21.50'], 1, font) >= resultTextEm('64:21.50') * font)
|
liveResultColumnPx(['59.99'], 'single', MAX_ROW_FONT_PX),
|
||||||
|
liveResultColumnFloorPx('single'),
|
||||||
|
)
|
||||||
|
// 单栏也会裁长成绩(220px 装不下 96px 的 `64:21.50`),同样由内容定宽修掉。
|
||||||
|
const font = fitLiveRowFontPx(1, 'single', ['64:21.50'])
|
||||||
|
assert.equal(font, 89)
|
||||||
|
assert.ok(liveResultColumnPx(['64:21.50'], 'single', font) >= resultTextEm('64:21.50') * font)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拆两块:每块 ≤5 行时字号回到 56px 上限,且行数越多越只增不减', () => {
|
||||||
|
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
|
||||||
|
for (const rows of [7, 8, 9, 10]) {
|
||||||
|
const perBlock = Math.ceil(rows / 2)
|
||||||
|
const font = fitLiveRowFontPx(perBlock, 'split', results)
|
||||||
|
assert.equal(font, MAX_ROW_FONT_PX, `${rows} 行`)
|
||||||
|
assert.ok(font > fitLiveRowFontPx(rows, 'single', results), `${rows} 行拆开必须更大`)
|
||||||
|
const column = liveResultColumnPx(results, 'split', font)
|
||||||
|
assert.ok(resultTextEm('1:06.33') * font <= column)
|
||||||
|
assert.ok(column <= liveResultColumnBudgetPx('split'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('临界点:拆分线起拆两块才划算(差一行时拆开反而更小)', () => {
|
||||||
|
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
|
||||||
|
for (let rows = 1; rows <= 10; rows++) {
|
||||||
|
const single = fitLiveRowFontPx(rows, 'single', results)
|
||||||
|
const split = fitLiveRowFontPx(Math.ceil(rows / 2), 'split', results)
|
||||||
|
if (rows >= LIVE_SPLIT_MIN_ROWS) assert.ok(split > single, `${rows} 行应当拆`)
|
||||||
|
else assert.ok(split <= single, `${rows} 行不该拆`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('没有行可排时退回固定字号与列宽下限', () => {
|
test('没有行可排时退回固定字号与列宽下限', () => {
|
||||||
assert.equal(fitLiveRowFontPx(0, 2, []), 48)
|
assert.equal(fitLiveRowFontPx(0, 'duo', []), 48)
|
||||||
assert.equal(liveResultColumnPx([], 2, 56), liveResultColumnFloorPx(2))
|
assert.equal(liveResultColumnPx([], 'duo', 56), liveResultColumnFloorPx('duo'))
|
||||||
assert.equal(liveResultColumnPx([], 1, 56), liveResultColumnFloorPx(1))
|
assert.equal(liveResultColumnPx([], 'single', 56), liveResultColumnFloorPx('single'))
|
||||||
|
assert.equal(liveResultColumnPx([], 'split', 56), liveResultColumnFloorPx('split'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('行数越多字号只减不增', () => {
|
test('行数越多字号只减不增', () => {
|
||||||
const results = ['1:06.33', 'DNF', '12.35米']
|
const results = ['1:06.33', 'DNF', '12.35米']
|
||||||
for (const columns of [1, 2]) {
|
for (const layout of ['single', 'split', 'duo'] as const) {
|
||||||
let previous = Infinity
|
let previous = Infinity
|
||||||
for (let rows = 1; rows <= 12; rows++) {
|
for (let rows = 1; rows <= 12; rows++) {
|
||||||
const font = fitLiveRowFontPx(rows, columns, results)
|
const font = fitLiveRowFontPx(rows, layout, results)
|
||||||
assert.ok(font <= previous, `${columns} 栏 ${rows} 行`)
|
assert.ok(font <= previous, `${layout} ${rows} 行`)
|
||||||
previous = font
|
previous = font
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||||
import {
|
import {
|
||||||
|
LIVE_SPLIT_MIN_ROWS,
|
||||||
MAX_LIVE_ROWS,
|
MAX_LIVE_ROWS,
|
||||||
fallbackLiveSources,
|
fallbackLiveSources,
|
||||||
|
liveBlockRowCount,
|
||||||
|
liveBoardLayout,
|
||||||
liveRefreshDelay,
|
liveRefreshDelay,
|
||||||
|
liveRowBlocks,
|
||||||
liveRows,
|
liveRows,
|
||||||
pickLiveColumns,
|
pickLiveColumns,
|
||||||
} from '../live'
|
} from '../live'
|
||||||
@@ -132,6 +136,44 @@ test('栏内按名次排序、无名次排最后,并截断到上限', () => {
|
|||||||
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
|
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('版式判定:一个项目行少就满宽一张表,行多拆两块,两个项目左一右一', () => {
|
||||||
|
assert.equal(liveBoardLayout(1, 1), 'single')
|
||||||
|
assert.equal(liveBoardLayout(1, LIVE_SPLIT_MIN_ROWS - 1), 'single')
|
||||||
|
assert.equal(liveBoardLayout(1, LIVE_SPLIT_MIN_ROWS), 'split')
|
||||||
|
assert.equal(liveBoardLayout(1, MAX_LIVE_ROWS), 'split')
|
||||||
|
// 两个项目时本来就是一栏一块,不再二次拆分。
|
||||||
|
assert.equal(liveBoardLayout(2, 1), 'duo')
|
||||||
|
assert.equal(liveBoardLayout(2, MAX_LIVE_ROWS), 'duo')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('字号按「一块几行」算:拆两块后每块行数是整栏的一半(向上取整)', () => {
|
||||||
|
assert.equal(liveBlockRowCount(MAX_LIVE_ROWS, 'split'), 5)
|
||||||
|
assert.equal(liveBlockRowCount(LIVE_SPLIT_MIN_ROWS, 'split'), 4)
|
||||||
|
assert.equal(liveBlockRowCount(8, 'split'), 4)
|
||||||
|
assert.equal(liveBlockRowCount(9, 'split'), 5)
|
||||||
|
// 不拆的版式就是整栏行数。
|
||||||
|
assert.equal(liveBlockRowCount(6, 'single'), 6)
|
||||||
|
assert.equal(liveBlockRowCount(4, 'single'), 4)
|
||||||
|
assert.equal(liveBlockRowCount(MAX_LIVE_ROWS, 'duo'), MAX_LIVE_ROWS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拆两块:名次靠前的左块多一行,两块行数最多差 1,非 split 原样一块', () => {
|
||||||
|
const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
assert.deepEqual(liveRowBlocks(ten, 'single'), [ten])
|
||||||
|
assert.deepEqual(liveRowBlocks(ten, 'duo'), [ten])
|
||||||
|
|
||||||
|
const seven = liveRowBlocks(ten.slice(0, 7), 'split')
|
||||||
|
assert.deepEqual(seven[0], [1, 2, 3, 4])
|
||||||
|
assert.deepEqual(seven[1], [5, 6, 7])
|
||||||
|
assert.deepEqual(liveRowBlocks(ten, 'split').map((block) => block.length), [5, 5])
|
||||||
|
assert.deepEqual(liveRowBlocks(ten.slice(0, 8), 'split').map((block) => block.length), [4, 4])
|
||||||
|
assert.deepEqual(liveRowBlocks(ten.slice(0, 9), 'split').map((block) => block.length), [5, 4])
|
||||||
|
|
||||||
|
// 行数不到拆分线时不能拆:否则右块空着,一屏只剩半张表有内容。
|
||||||
|
assert.deepEqual(liveRowBlocks([1, 2, 3], 'split'), [[1, 2, 3]])
|
||||||
|
assert.deepEqual(liveRowBlocks([], 'split'), [[]])
|
||||||
|
})
|
||||||
|
|
||||||
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
|
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
|
||||||
const names = new Map([[7, '男子 1500 米']])
|
const names = new Map([[7, '男子 1500 米']])
|
||||||
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
|
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
|
||||||
@@ -271,3 +313,45 @@ test('成绩列宽由内容算出来,网格里不能写死列宽', () => {
|
|||||||
// 成绩不能折行:折成两行会被读成两个成绩,第二行还会被 .rows 竖着裁掉。
|
// 成绩不能折行:折成两行会被读成两个成绩,第二行还会被 .rows 竖着裁掉。
|
||||||
assert.match(source, /\.rs \{[^}]*white-space: nowrap/s)
|
assert.match(source, /\.rs \{[^}]*white-space: nowrap/s)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('单栏铺满页框:不再用 max-width 收窄居中', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// 旧版式把卡片收窄到 1180px 居中,1920 的屏上左右各空 370px;而且收窄换不来更大的字
|
||||||
|
// (字号由高度决定),属于纯浪费。铺满后左右只剩页框自带的边距。
|
||||||
|
assert.doesNotMatch(source, /max-width: 1180px/)
|
||||||
|
assert.match(source, /\.boards-1 \.board \{[^}]*max-width: none/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('版式接到渲染上:拆两块时表头跟着块走,字号按块内行数算', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// 版式与分块都由 live.ts 判定,页面不许自己写死行数阈值。
|
||||||
|
assert.match(source, /liveBoardLayout\(boards\.value\.length, rowCount\.value\)/)
|
||||||
|
assert.match(source, /liveBlockRowCount\(rowCount\.value, layout\.value\)/)
|
||||||
|
assert.match(source, /liveRowBlocks\(board\.rows, layout\)/)
|
||||||
|
// 每一块都要有自己的表头,否则右块的名次/成绩没有列标题。
|
||||||
|
assert.match(source, /v-for="\(block, index\) in liveRowBlocks[\s\S]*class="row head"/)
|
||||||
|
assert.match(source, /v-for="row in block"/)
|
||||||
|
// 拆块时行不再拉满整屏高(1 行时 96px 的字铺在 460px 行里只会显空)。
|
||||||
|
assert.match(source, /max-height: calc\(var\(--row-font[^)]*\) \* 3\.2\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拆两块时的名次列必须装得下两位数', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// 56px 字号下 `10` 的墨迹约 78px:名次列窄于 80px 会被挤进运动员列。
|
||||||
|
const matched = /\.boards-1 \.rows\.split \.row \{[^}]*grid-template-columns: (\d+)px/s.exec(
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
assert.ok(matched !== null, '拆块版式要单独给一套网格')
|
||||||
|
assert.ok(Number(matched[1]) >= 80, `名次列 ${matched[1]}px 装不下两位数`)
|
||||||
|
// 两个项目的版式(duo)沿用改前的网格,别跟着一起动。
|
||||||
|
assert.match(source, /\.boards-2 \.row \{[^}]*60px minmax\(0, 1fr\) 190px/s)
|
||||||
|
})
|
||||||
@@ -138,6 +138,18 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
|
|||||||
entries(eventId: number): Promise<EntryWithResult[]>
|
entries(eventId: number): Promise<EntryWithResult[]>
|
||||||
categories(eventId: number): Promise<EventCategory[]>
|
categories(eventId: number): Promise<EventCategory[]>
|
||||||
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
|
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
|
||||||
|
/**
|
||||||
|
* 就地更新单个组别(`PUT /api/events/{id}/categories/{categoryId}`)。
|
||||||
|
*
|
||||||
|
* 与 saveCategories 的整表替换不同,服务端只发一条 UPDATE:组别 id 不变,
|
||||||
|
* 挂在它下面的报名、成绩、积分流水与名次全部原地保留。有报名数据的小项靠它
|
||||||
|
* 改名 / 改年龄 / 改性别 / 改权重;增删组别仍只能走 saveCategories(有报名数据时会被拒)。
|
||||||
|
*
|
||||||
|
* 注意:权重是算分输入——`multiplier` 真的变了时服务端会在同一事务里重算该组已发布成绩的
|
||||||
|
* 积分与名次(别人的分值/榜单会跟着变);name / gender / ageMin / ageMax / sortOrder 不重算。
|
||||||
|
* 返回该小项最新的组别列表。
|
||||||
|
*/
|
||||||
|
updateCategory(eventId: number, categoryId: number, category: EventCategoryInput): Promise<EventCategory[]>
|
||||||
channels(eventId: number): Promise<ScoreChannel[]>
|
channels(eventId: number): Promise<ScoreChannel[]>
|
||||||
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
|
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
|
||||||
}
|
}
|
||||||
@@ -167,6 +179,39 @@ export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
|
|||||||
* 人员已被别人删掉时回 404。
|
* 人员已被别人删掉时回 404。
|
||||||
*/
|
*/
|
||||||
purge(id: number): Promise<AthletePurgeResult>
|
purge(id: number): Promise<AthletePurgeResult>
|
||||||
|
/**
|
||||||
|
* 批量停用 / 批量彻底删除(`POST /api/athletes/batch`)。
|
||||||
|
*
|
||||||
|
* 逐个调 DELETE 是「一次操作一条请求」,现场清理几十个人时会直接撞上写限流;
|
||||||
|
* 收成一次请求既少一次配额,响应里还逐条回报成败(有人已被别人删掉、有人 id 不存在)。
|
||||||
|
* dryRun = true 时只回报「会删掉什么、重算哪些小项」,不改库,供危险确认框先展示。
|
||||||
|
*/
|
||||||
|
batch(action: AthleteBatchAction, ids: number[], dryRun?: boolean): Promise<AthleteBatchResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量动作的语义:与单条停用 / 单条彻底删除完全一致。 */
|
||||||
|
export type AthleteBatchAction = 'disable' | 'purge'
|
||||||
|
|
||||||
|
/** 批量动作里逐条的结果:个别失败不能让整批失败,也不能静默跳过。 */
|
||||||
|
export interface AthleteBatchItem {
|
||||||
|
id: number
|
||||||
|
ok: boolean
|
||||||
|
/** 失败原因的错误码(目前只有 NOT_FOUND:该 id 在库里不存在)。 */
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AthleteBatchResult {
|
||||||
|
action: AthleteBatchAction
|
||||||
|
/** true 表示这是预览(dryRun),服务端一行都没动。 */
|
||||||
|
dryRun?: boolean
|
||||||
|
/** 去重后的 id 数。 */
|
||||||
|
requested: number
|
||||||
|
succeeded: number
|
||||||
|
failed: number
|
||||||
|
items: AthleteBatchItem[]
|
||||||
|
/** 只有 action = 'purge' 时出现:合计删除规模与受影响小项。 */
|
||||||
|
purge?: AthletePurgeResult
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamsApi extends CrudApi<Team, TeamInput> {
|
export interface TeamsApi extends CrudApi<Team, TeamInput> {
|
||||||
@@ -352,6 +397,35 @@ function readPurgeResult(value: unknown): AthletePurgeResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读批量动作的响应。
|
||||||
|
*
|
||||||
|
* 逐条结果缺失时按「这一条失败」处理而不是成功:批量删除不可回滚,把没看到的 id 当成功
|
||||||
|
* 比多说一句「未能确认」危险得多。requested / succeeded / failed 也按 items 重算,
|
||||||
|
* 老服务端字段缺失时不至于报出「0 人成功」这种自相矛盾的结论。
|
||||||
|
*/
|
||||||
|
function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBatchResult {
|
||||||
|
const source = isRecord(value) ? value : {}
|
||||||
|
const items: AthleteBatchItem[] = Array.isArray(source['items'])
|
||||||
|
? source['items'].filter(isRecord).map((raw) => ({
|
||||||
|
id: typeof raw['id'] === 'number' ? raw['id'] : 0,
|
||||||
|
ok: raw['ok'] === true,
|
||||||
|
code: typeof raw['code'] === 'string' ? raw['code'] : undefined,
|
||||||
|
message: typeof raw['message'] === 'string' ? raw['message'] : undefined,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
const purge = source['purge']
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
dryRun: source['dryRun'] === true,
|
||||||
|
requested: items.length,
|
||||||
|
succeeded: items.filter((item) => item.ok).length,
|
||||||
|
failed: items.filter((item) => !item.ok).length,
|
||||||
|
items,
|
||||||
|
purge: isRecord(purge) ? readPurgeResult(purge) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createTeamsApi(http: Http): TeamsApi {
|
function createTeamsApi(http: Http): TeamsApi {
|
||||||
return {
|
return {
|
||||||
...createCrudApi<Team, TeamInput>(http, '/teams'),
|
...createCrudApi<Team, TeamInput>(http, '/teams'),
|
||||||
@@ -366,6 +440,15 @@ function createAthletesApi(http: Http): AthletesApi {
|
|||||||
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
|
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
|
||||||
purge: async (id) =>
|
purge: async (id) =>
|
||||||
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
|
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
|
||||||
|
batch: async (action, ids, dryRun = false) =>
|
||||||
|
// dryRun 缺省不传:老服务端不认这个字段时仍按真删处理,不能被预览悄悄降级。
|
||||||
|
readBatchResult(
|
||||||
|
action,
|
||||||
|
await http.request<unknown>('/athletes/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: dryRun ? { action, ids, dryRun: true } : { action, ids },
|
||||||
|
}),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +462,11 @@ function createEventsApi(http: Http): EventsApi {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { categories },
|
body: { categories },
|
||||||
}),
|
}),
|
||||||
|
updateCategory: (eventId, categoryId, category) =>
|
||||||
|
http.request<EventCategory[]>(`/events/${eventId}/categories/${categoryId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: category,
|
||||||
|
}),
|
||||||
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
|
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
|
||||||
saveChannels: (eventId, channels) =>
|
saveChannels: (eventId, channels) =>
|
||||||
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
|
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* 报名名单与基础数据包(单位 / 大项 / 小项 / 组别)的 xlsx 导入导出。
|
||||||
|
*
|
||||||
|
* 这两组接口没进 `shared/api.ts` 的 ApiClient(那是全站共用的客户端,本任务不改它),
|
||||||
|
* 单独放一个模块,导出页与测试都从这里取。
|
||||||
|
*
|
||||||
|
* 错误处理与全站一致:只有连不上 / 超时才可能是「后端未就绪」,
|
||||||
|
* 403 / 400 / 409 这些是服务端的真实回答,必须原样抛出,否则会把
|
||||||
|
* 「没权限」「表头写错了」伪装成示例数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiError } from './api'
|
||||||
|
import type { ApiErrorCode } from './types'
|
||||||
|
|
||||||
|
const BASE_URL = '/api'
|
||||||
|
/** 导出要等服务端现算 xlsx,比普通读接口慢。 */
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 60_000
|
||||||
|
/** 上传整包要读全部工作表再写库,弱网下更慢。 */
|
||||||
|
const UPLOAD_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
|
const API_ERROR_CODES: readonly string[] = [
|
||||||
|
'AUTH_REQUIRED',
|
||||||
|
'AUTH_FAILED',
|
||||||
|
'FORBIDDEN',
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'CONFLICT',
|
||||||
|
'STATE_INVALID',
|
||||||
|
'RATE_LIMITED',
|
||||||
|
'INTERNAL',
|
||||||
|
'NETWORK',
|
||||||
|
'TIMEOUT',
|
||||||
|
'PARSE_FAILED',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ENTRIES_XLSX_FILENAME = '报名名单.xlsx'
|
||||||
|
export const BASEDATA_XLSX_FILENAME = '基础数据包.xlsx'
|
||||||
|
|
||||||
|
export interface DownloadFile {
|
||||||
|
blob: Blob
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报名名单的筛选条件;不传的字段 = 该维度不筛。 */
|
||||||
|
export interface EntryExportFilter {
|
||||||
|
eventId?: number
|
||||||
|
categoryId?: number
|
||||||
|
teamId?: number
|
||||||
|
heat?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseDataFieldDiff {
|
||||||
|
field: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行差异:哪张表、第几行、新增还是更新、哪个字段从什么变成什么。 */
|
||||||
|
export interface BaseDataChange {
|
||||||
|
sheet: string
|
||||||
|
line: number
|
||||||
|
action: string
|
||||||
|
key: string
|
||||||
|
fields: BaseDataFieldDiff[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 差异预览 / 导入结果。
|
||||||
|
* `errors` 是行级中文错误(形如「单位 第 3 行:单位编码必填」),有错误就不会写库里那一行。
|
||||||
|
*/
|
||||||
|
export interface BaseDataImportResult {
|
||||||
|
dryRun: boolean
|
||||||
|
added: number
|
||||||
|
updated: number
|
||||||
|
unchanged: number
|
||||||
|
errorCount: number
|
||||||
|
errors: string[]
|
||||||
|
changes: BaseDataChange[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报名名单导出的请求路径(筛选参数为空就不带 query)。 */
|
||||||
|
export function entriesExportPath(filter: EntryExportFilter = {}): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
|
||||||
|
const value = filter[key]
|
||||||
|
if (value !== undefined) params.append(key, String(value))
|
||||||
|
}
|
||||||
|
const query = params.toString()
|
||||||
|
return `/export/entries.xlsx${query === '' ? '' : `?${query}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载报名名单(xlsx)。 */
|
||||||
|
export function downloadEntriesXLSX(filter: EntryExportFilter = {}): Promise<DownloadFile> {
|
||||||
|
return download(entriesExportPath(filter), ENTRIES_XLSX_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载基础数据包(多 sheet 工作簿)。 */
|
||||||
|
export function downloadBaseDataXLSX(): Promise<DownloadFile> {
|
||||||
|
return download('/export/basedata.xlsx', BASEDATA_XLSX_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传基础数据包。
|
||||||
|
* `dryRun=true` 只算差异不写库;用户确认后再用 `dryRun=false` 提交同一个文件。
|
||||||
|
*/
|
||||||
|
export async function importBaseDataXLSX(
|
||||||
|
file: File,
|
||||||
|
options: { dryRun?: boolean } = {},
|
||||||
|
): Promise<BaseDataImportResult> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
if (options.dryRun === true) form.append('dryRun', '1')
|
||||||
|
|
||||||
|
const response = await request('/export/basedata/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
// 浏览器自己带 multipart 边界,显式设 Content-Type 反而会漏掉 boundary。
|
||||||
|
headers: csrfHeader(),
|
||||||
|
}, UPLOAD_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const body = await readBody(response)
|
||||||
|
const record = isRecord(body) ? body : {}
|
||||||
|
if (record.ok !== true) {
|
||||||
|
throw toApiError(body, response.status)
|
||||||
|
}
|
||||||
|
const data = isRecord(record.data) ? record.data : {}
|
||||||
|
return {
|
||||||
|
dryRun: data.dryRun === true,
|
||||||
|
added: readNumber(data, 'added') ?? 0,
|
||||||
|
updated: readNumber(data, 'updated') ?? 0,
|
||||||
|
unchanged: readNumber(data, 'unchanged') ?? 0,
|
||||||
|
errorCount: readNumber(data, 'errorCount') ?? 0,
|
||||||
|
errors: readStrings(data.errors),
|
||||||
|
changes: readChanges(data.changes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 底层
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function download(path: string, filename: string): Promise<DownloadFile> {
|
||||||
|
const response = await request(path, { method: 'GET' })
|
||||||
|
return { blob: await response.blob(), filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path: string, init: RequestInit, timeoutMs = DOWNLOAD_TIMEOUT_MS): Promise<Response> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw toApiError(await readBody(response), response.status)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
throw new ApiError('TIMEOUT', `请求超时(${timeoutMs}ms)`, 0, error)
|
||||||
|
}
|
||||||
|
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(response: Response): Promise<unknown> {
|
||||||
|
if (response.status === 204) return null
|
||||||
|
const text = await response.text()
|
||||||
|
if (text.trim() === '') return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfHeader(): Record<string, string> {
|
||||||
|
if (typeof document === 'undefined') return {}
|
||||||
|
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
|
||||||
|
const raw = match?.[1]
|
||||||
|
if (raw === undefined) return {}
|
||||||
|
return { 'X-CSRF-Token': decodeURIComponent(raw) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiError(body: unknown, status: number): ApiError {
|
||||||
|
const record = isRecord(body) ? body : {}
|
||||||
|
const error = isRecord(record.error) ? record.error : {}
|
||||||
|
const code = typeof error.code === 'string' && API_ERROR_CODES.includes(error.code)
|
||||||
|
? (error.code as ApiErrorCode)
|
||||||
|
: 'INTERNAL'
|
||||||
|
const message = typeof error.message === 'string' && error.message !== ''
|
||||||
|
? error.message
|
||||||
|
: `请求失败(HTTP ${status})`
|
||||||
|
return new ApiError(code, message, status, error.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(source: Record<string, unknown>, key: string): number | null {
|
||||||
|
const value = source[key]
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStrings(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChanges(value: unknown): BaseDataChange[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const out: BaseDataChange[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
const fields: BaseDataFieldDiff[] = []
|
||||||
|
if (Array.isArray(item.fields)) {
|
||||||
|
for (const field of item.fields) {
|
||||||
|
if (!isRecord(field)) continue
|
||||||
|
fields.push({
|
||||||
|
field: typeof field.field === 'string' ? field.field : '',
|
||||||
|
from: typeof field.from === 'string' ? field.from : '',
|
||||||
|
to: typeof field.to === 'string' ? field.to : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
sheet: typeof item.sheet === 'string' ? item.sheet : '',
|
||||||
|
line: readNumber(item, 'line') ?? 0,
|
||||||
|
action: typeof item.action === 'string' ? item.action : '',
|
||||||
|
key: typeof item.key === 'string' ? item.key : '',
|
||||||
|
fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+15
-3
@@ -182,12 +182,12 @@ export interface ImportConflictFields {
|
|||||||
|
|
||||||
export type ImportConflictField = keyof ImportConflictFields
|
export type ImportConflictField = keyof ImportConflictFields
|
||||||
|
|
||||||
/** 同一证件号 / 号码布在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
|
/** 号码布 / 姓名 + 单位命中的人与报名表里的人员信息不一致;本轮不落库,等用户决策。 */
|
||||||
export interface ImportConflict {
|
export interface ImportConflict {
|
||||||
/** 报名表原始行号(含表头,从 1 开始)。 */
|
/** 报名表原始行号(含表头,从 1 开始)。 */
|
||||||
row: number
|
row: number
|
||||||
idCard: string
|
idCard: string
|
||||||
/** 报名表该行的号码布;证件号为空时它就是这条冲突的决策键。 */
|
/** 报名表该行的号码布;面板按号码布优先给它分决策键(decisionKeyOf)。 */
|
||||||
bib: string
|
bib: string
|
||||||
athleteId: number
|
athleteId: number
|
||||||
existing: ImportConflictFields
|
existing: ImportConflictFields
|
||||||
@@ -198,13 +198,23 @@ export interface ImportConflict {
|
|||||||
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
|
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
|
||||||
export type ConflictAction = 'keep-existing' | 'keep-new'
|
export type ConflictAction = 'keep-existing' | 'keep-new'
|
||||||
|
|
||||||
/** 决策键由服务端判定:证件号优先,为空时取号码布,因此两个字段都要带。 */
|
/** 服务端按证件号优先、为空时取号码布重算决策键,因此两个字段都要带。 */
|
||||||
export interface ImportConflictDecision {
|
export interface ImportConflictDecision {
|
||||||
idCard: string
|
idCard: string
|
||||||
bib: string
|
bib: string
|
||||||
action: ConflictAction
|
action: ConflictAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 号码布不同 → 按「同名的另一个人」新建档案的一行;只作报告,不拦截导入。 */
|
||||||
|
export interface SameNameNewPerson {
|
||||||
|
name: string
|
||||||
|
teamCode: string
|
||||||
|
/** 表里给的新号码布,也是新建档案用的号码布。 */
|
||||||
|
newBib: string
|
||||||
|
/** 库里同名同单位那个人已有的号码布。 */
|
||||||
|
existingBib: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntryImportResult {
|
export interface EntryImportResult {
|
||||||
created: number
|
created: number
|
||||||
updated: number
|
updated: number
|
||||||
@@ -212,6 +222,8 @@ export interface EntryImportResult {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
|
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
|
||||||
conflicts: ImportConflict[]
|
conflicts: ImportConflict[]
|
||||||
|
/** 本次按「同名的另一个人」新建的档案(号码布不同);仅提示,不阻塞导入。 */
|
||||||
|
sameNameNewPersons: SameNameNewPerson[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量设置批次(PUT /api/entries/heat)的请求体;heat 为 null 表示清除为「未分批」。 */
|
/** 批量设置批次(PUT /api/entries/heat)的请求体;heat 为 null 表示清除为「未分批」。 */
|
||||||
|
|||||||
Reference in new issue
Block a user