feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)

## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
This commit is contained in:
AskaEth committed 2026-09-12 01:27:09 +08:00
1 parent 6841cf49aa
commit ed2c0b3f3c
35 files changed
+4003 -115

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.2.0 dev_0.3.0
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scoreboard-console", "name": "scoreboard-console",
"productName": "Scoreboard Console", "productName": "Scoreboard Console",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"author": "AskaEth", "author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+88 -11
View File
@@ -53,12 +53,22 @@
## 3. 仪表盘 ## 3. 仪表盘
用于快速掌握全局: 用于快速掌握全局。上方四张概览卡:领先单位、已发布项目数 / 总项目数、纪录条目数、实时连接状态。
- 领先单位、已发布项目数 / 总项目数、纪录条目数、实时连接状态; 下面一组是四张现场卡片——每个数据源各自独立,**任何一张读不到时只让那一张显示 `—`,不影响其它卡片**:
- 「当前赛程」列出小项及状态(已发布 / 待录入),点「录入」直接跳到该项目;
| 卡片 | 显示什么 |
| --- | --- |
| 今天的小项 | 排期(`scheduledAt`)落在**本机时区的当天**的小项数量与进度(`已发布 X / 共 Y`)。排期为空的小项不属于任何一天;项目列表还没加载完时显示「数据加载中…」,确实没有排期才显示「今天没有排期」 |
| 草稿待发布 | 还没发布的成绩草稿条数(点了「存草稿」但还没「发布」的);读不到时显示 `—`,不会用 0 冒充「一条都没有」 |
| 待确认破纪录 | 状态为**待确认**的破纪录登记条数,点「去确认」跳到「破纪录」页;访客只读时显示「访客只读」 |
| 大屏在线数 | `在线 / 总数`(读在线大屏实例列表)。副标题是**本机管理端的实时连接状态**(本机在线 / 连接中 / 未连接 / 连接中断 / 示例数据)——服务端没有「管理端在线数」这种统计,所以这里如实显示本机状态,不编一个数字出来 |
其余两块:
- 「当前赛程」列出小项及状态(已发布 / 待录入),今天的小项带「今天」标记,点「录入」直接跳到该项目;
- 「实时积分 Top 3」显示前三名与奖牌数; - 「实时积分 Top 3」显示前三名与奖牌数;
- 有待录入项目时显示「待办」提醒。 - 有待录入项目、草稿或待确认破纪录时显示「待办」提醒。
--- ---
@@ -90,7 +100,7 @@
### 4.2 录入成绩 ### 4.2 录入成绩
表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。 表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
| 列 | 操作 | | 列 | 操作 |
| --- | --- | | --- | --- |
@@ -99,6 +109,7 @@
| 状态 | 下拉选择:**有效**、**DNS 弃赛**、**DQ 取消**、**DNF 未完赛**、**退赛** | | 状态 | 下拉选择:**有效**、**DNS 弃赛**、**DQ 取消**、**DNF 未完赛**、**退赛** |
| 积分(自动) | 随成绩实时计算,仅供参考 | | 积分(自动) | 随成绩实时计算,仅供参考 |
| 备注 | 自由填写,示例文本是 `可留空` | | 备注 | 自由填写,示例文本是 `可留空` |
| 操作 | 已发布的行显示 **「作废本条」**(已作废的行改显示 **「重新录入」**);草稿行这里是 `—`(草稿本来就能直接改)。详见 4.10 |
**成绩写法**:能接受的写法比过去多,中文输入法下的全角冒号 `1:06`、全角数字也认。 **成绩写法**:能接受的写法比过去多,中文输入法下的全角冒号 `1:06`、全角数字也认。
@@ -168,7 +179,7 @@
3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去(末句在延迟结算下会换成「名次与榜单等该组全部录完后再更新」,见下面); 3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去(末句在延迟结算下会换成「名次与榜单等该组全部录完后再更新」,见下面);
4. 确认后这批成绩立即上屏;**实时结算(默认)下,名次与积分先按已发布的这部分算**,大屏立刻有内容(延迟结算下先不动,见下); 4. 确认后这批成绩立即上屏;**实时结算(默认)下,名次与积分先按已发布的这部分算**,大屏立刻有内容(延迟结算下先不动,见下);
5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新; 5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新;
6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。 6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布。**只错一条**时不必撤销整组发布,用该行的「作废本条 → 重新录入」(见 4.10);整组都要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。
> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。 > 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。
@@ -186,6 +197,8 @@
2. 系统作废该组已产生的积分流水,大屏回退到发布前状态; 2. 系统作废该组已产生的积分流水,大屏回退到发布前状态;
3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。 3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。
> **只错了一条成绩时**,不必撤销整组发布、把别人的成绩也一起翻下来——用该行「操作」列的「作废本条 / 重新录入」即可,见 4.10。
### 4.8 勾选与批量操作(退赛 / 恢复 / 移出报名) ### 4.8 勾选与批量操作(退赛 / 恢复 / 移出报名)
表格每行最左边有勾选框,表头那个勾选框是**全选当前筛选范围内的行**——它只勾当前组别、当前批次筛选下看得见的行,被筛掉的行不会被选上。勾选后工具栏里出现「已选 N 行」与三个按钮: 表格每行最左边有勾选框,表头那个勾选框是**全选当前筛选范围内的行**——它只勾当前组别、当前批次筛选下看得见的行,被筛掉的行不会被选上。勾选后工具栏里出现「已选 N 行」与三个按钮:
@@ -223,6 +236,34 @@
- **权限**:读得到成绩的人都能看到这块卡片(访客只读,输入框置灰并写明原因);**能保存的是录入员及以上**(与成绩录入同权限)。示例数据模式下不能保存; - **权限**:读得到成绩的人都能看到这块卡片(访客只读,输入框置灰并写明原因);**能保存的是录入员及以上**(与成绩录入同权限)。示例数据模式下不能保存;
- 团体名次是**独立保存**的:点「保存团体名次」只提交这块卡片,不会顺手发布成绩;成绩仍然要另外「发布」。 - 团体名次是**独立保存**的:点「保存团体名次」只提交这块卡片,不会顺手发布成绩;成绩仍然要另外「发布」。
### 4.10 单条作废 / 重新录入(手滑填错了怎么办)
已发布的成绩行在表格里仍然是**锁着的**(成绩、名次、状态、备注都改不了)——这是有意的:已发布的行已经上屏、已经计分,允许就地改会让「看到的榜单」和「改到一半的数据」对不上。要改就必须先明确地把它退回来。表格最后一列 **「操作」** 就是这件事的入口,只有已发布的行才有按钮。
**两个按钮,什么时候用哪个**
| 按钮 | 什么时候出现 | 做什么 |
| --- | --- | --- |
| **作废本条** | 这一行是**已发布且正常**的成绩 | 让这**一条**成绩退出名次与积分(同组其它人重新排名),但**不改**它的原始记录——需要改回来时还是用「重新录入」 |
| **重新录入** | 这一行是**已作废**的(红徽标「已作废」) | 把这一条**退回草稿**(原来填的成绩还在格里,直接改就行)、不再计入名次与积分,之后就能像普通草稿一样编辑;改完再发布本组 |
两个按钮**都必须填写原因**(确认框里的「作废原因 / 退回原因」),留空时确认键点了也不会提交——原因会写进审计日志,事后能查清是谁、什么时候、为什么动的这一条。
**点完会立刻发生什么**
- 服务端在同一个事务里**按当前已发布成绩重算**该小项该组的名次与积分,并**立即推送大屏**;
- 这一步**不受**「分批发布时实时更新榜单」这个延迟结算开关影响(它改的是「这条成绩算不算数」,不是「录了多少」),不会出现「榜上还有分、成绩却已经撤了」;
- 两者都会留痕:**审计日志**(第 13 节)里的动作分别是 `result.void` 与 `result.reopen`,含操作人、时间与填写的原因。注意「变更历史」(第 12 节)记的是**发布 / 撤销发布**这类整组版本,单条作废 / 重新录入**不会**在那里新增一个版本节点。
**手滑填错了怎么办:两条路**
1. **只错了一条**(最常见):在那一行点 **「作废本条」** 填原因 → 这一条退出名次与积分、大屏立刻更新 → 同一行点 **「重新录入」**(此时按钮已变成它)填原因 → 退回草稿,该行可以编辑了 → 改对成绩 → 再点页头的 **「发布」**(该组还有别的已发布成绩时显示为「发布本批」,见 4.6)。全程只动这一条,同组别人的成绩一直留在榜上;
2. **整组都要改**(例如整组被判罚、名次口径整体调整):点页面上的 **「撤销发布」** → 修改 → 重新发布(见 4.7)。这条会把该组已产生的积分流水整体作废再重发,大屏会先回退一次。
> 只错一条时**优先用第 1 条路**:撤销发布会让整组的分数先消失再回来,大屏在中间会闪一下;作废 / 重新录入只影响这一条。
> 需要先「存草稿」再点这两个按钮:作废 / 退回会重算并整表刷新,未保存的编辑会被刷新覆盖,页面会拦下并提示。
--- ---
## 5. 成绩审核 ## 5. 成绩审核
@@ -245,17 +286,52 @@
页面顶部会显示破纪录加分开关状态(来自规则包 `recordBonus.enabled`)。当前版本默认关闭;若当前角色读不到规则包,会如实显示「未知」,以服务端校验结果为准。 页面顶部会显示破纪录加分开关状态(来自规则包 `recordBonus.enabled`)。当前版本默认关闭;若当前角色读不到规则包,会如实显示「未知」,以服务端校验结果为准。
### 6.1 疑似破纪录 ### 6.1 疑似破纪录与「已批准的破纪录」
- 系统用**已发布且有效**的成绩与当前纪录自动比对,推导出疑似破纪录,草稿不参与; - 系统用**已发布且有效**的成绩与当前纪录自动比对,推导出疑似破纪录,草稿不参与;
- 列表显示小项、组别、单位 / 运动员、成绩、原纪录、档位(赛会 / 市级 / 省级 / 全国)、预计加分; - 列表显示小项、组别、单位 / 运动员、成绩、原纪录、档位(赛会 / 市级 / 省级 / 全国)、预计加分;
- **确认加分**:登记破纪录并生成加分流水、更新纪录库;开关关闭时按钮不可用; - **确认加分**:登记破纪录并生成加分流水、更新纪录库;开关关闭时按钮不可用;
- **拒绝**:当前版本服务端没有拒绝接口,此操作只在本地隐藏该条,刷新后会重新出现。 - **拒绝**:判定这条不是破纪录时点它。系统会**在服务端先登记一条破纪录记录、再立即驳回**(原因固定写「现场判定不是破纪录」),所以**刷新后不会再出现在待确认列表里**,谁在什么时候否掉了哪一条也能在日志里查到。示例数据模式或后端未就绪时只在本机隐藏,提示会如实说明「刷新页面后该条会重新出现」。
#### 已批准的破纪录(批错了可以撤回)
「已批准的破纪录」卡片列出所有**已批准**的登记。这条接口**需要录入员及以上**才能读(含待审核的登记与驳回理由);访客只读时卡片会写明「访客只读:看不到破纪录登记」,不会假装成「一条都没有」。
每行显示小项、级别、成绩、加分、批准时间、说明,右端有:
- **作废**:批准时已经加过分,所以作废不只是改状态。点它会弹确认框,写明「作废后将撤回该笔加分,并恢复该级别的上一条纪录;大屏与此处纪录库会立即更新」,并要求填 **「作废原因」**(必填,留空点确认不会提交)。确认后服务端在同一个事务里**撤回该笔加分、重算并推榜、把该级别的当前纪录恢复为上一条**,完成后提示「加分已撤回,该级别纪录已恢复上一条」。
- 如果该级别本来就没有更早的纪录,撤下后就留空——「无纪录」是事实,不会留一条刚被作废的纪录充数。
> 待确认的登记被驳回不涉及加分与纪录,服务端只改状态、记理由,不会白重算一次榜单。
### 6.2 纪录库 ### 6.2 纪录库
- 按小项分组展示,可用「全部 / 赛会 / 市级 / 省级 / 全国」页签筛选,也可搜索小项或保持者; - 按小项分组展示,可用「全部 / 赛会 / 市级 / 省级 / 全国」页签筛选,也可搜索小项或保持者;
- 每行显示档位、成绩、保持者、单位、达成时间、来源(本届 / 历史)以及是否为当前纪录。 - 每行显示档位、成绩、保持者、单位、达成时间、**来源**以及是否为当前纪录(当前纪录带「当前」绿标);
- **「来源」列的含义**:
- **历史** = 赛前手工登记的旧资料(用下面的「新增历史纪录」录进来的);
- **本届** = 本场赛事批准破纪录时系统自动写入的;
- 读不到时显示 `—`(旧服务端不下发这个字段时不会冒充成「历史」)。
#### 新增历史纪录
点「纪录库」卡片头部的 **「新增历史纪录」**(**录入员及以上**;访客只读时按钮置灰并提示「访客只读,无法新增」):
1. 选 **小项**(必选);
2. 选 **级别**:赛会 / 市级 / 省级 / 全国(默认「赛会」);
3. 填 **成绩值**(数字,例如 `11.2`)和 / 或 **成绩文本**(例如 `11.20`、`1:06.33`)——**两者至少填一个**,都填时比较与破纪录判断以成绩值为准;老秩序册上常常只有其中一种;
4. 可选填 **保持人姓名**、**保持单位**(不指定就留空;很多历史纪录已经查不到人是谁,留空是正常的,不会逼你编一个名字)、**创造日期**(日期选择器,可留空);
5. 点 **「保存纪录」**;成功提示「已新增历史纪录 · 来源记为「历史」」。
新录入的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(但仍留在库里,来源与达成时间不变)。
#### 删除
每行右端有 **「删除」**(同样需要录入员及以上):
1. 确认框会写明将删除哪一条(「将删除「小项」的「级别」纪录 X;若它是当前纪录,会恢复上一条。」),并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;
2. 点 **「确认删除」** 后:删掉这一条;如果删的正是当前纪录,服务端会把该级别的**上一条恢复为当前**;一条都没有就把该级别留空;
3. 删除**不会**重算名次与积分——它只动纪录库本身。
--- ---
@@ -711,7 +787,7 @@
1. 顶部可筛选小项,以及「全部时间 / 近 24 小时 / 近 7 天」; 1. 顶部可筛选小项,以及「全部时间 / 近 24 小时 / 近 7 天」;
2. 左侧时间轴最新在上,节点显示动作(发布 / 撤销发布 / 编辑)、项目、操作人、时间与备注; 2. 左侧时间轴最新在上,节点显示动作(发布 / 撤销发布 / 编辑)、项目、操作人、时间与备注;
3. 点节点,右侧显示操作人、影响单位与成绩前后对比(diff); 3. 点节点,右侧显示操作人、影响单位与成绩前后对比(diff);
4. 本页**不提供一键回滚**。纠错请到「成绩录入」走「撤销发布 → 修改 → 重新发布」。 4. 本页**不提供一键回滚**。纠错请到「成绩录入」:只错一条走「作废本条 → 重新录入 → 修改 → 重新发布」(见 4.10),整组要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。
--- ---
@@ -909,7 +985,8 @@
草稿不会上屏。需要点「发布」(或 `Alt+Enter`),发布成功后立即更新。 草稿不会上屏。需要点「发布」(或 `Alt+Enter`),发布成功后立即更新。
**Q:发布后发现有错怎么办?** **Q:发布后发现有错怎么办?**
到「成绩录入」找到该组,点「撤销发布」→ 修改 → 重新发布。撤销会作废该组积分流水,大屏回退;全程可在「变更历史」追溯。 只错一条成绩:到「成绩录入」找到那一行,点「作废本条」→ 再点「重新录入」→ 改 → 重新发布,只动这一条(见 4.10)。
整组要改:点「撤销发布」→ 修改 → 重新发布。撤销会作废该组积分流水,大屏回退;全程可在「变更历史」追溯。
**Q:并列名次怎么显示?** **Q:并列名次怎么显示?**
成绩相同(或手动名次相同)即视为并列,显示同一名次,例如两个「3」。积分按规则包里的并列口径计算,默认是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名是第 5 名。奖牌也按并列处理,并列第 1 各记 1 金、无银牌。 成绩相同(或手动名次相同)即视为并列,显示同一名次,例如两个「3」。积分按规则包里的并列口径计算,默认是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名是第 5 名。奖牌也按并列处理,并列第 1 各记 1 金、无银牌。
+48 -1
View File
@@ -6,7 +6,54 @@
--- ---
## dev_0.2.0(当前版本) ## dev_0.3.0(当前版本)
### 一句话
**旧版赛事项目包仍可导入**:本版只有一条迁移(**0021**),只给破纪录登记表加一列可空的「理由」。升级后榜单数字应当与升级前一致;新增四个现场入口(作废 / 重新录入成绩、作废已批准的破纪录、录入历史纪录、仪表盘四张新卡片)。
### ① 迁移只有一条(0021),而且只是「加一列」
- 迁移 **`0021_record_breaks_note.sql`**:给 `record_breaks` 表加一列 **`note`**(TEXT,**可空**,默认 NULL)——记「这条破纪录登记为什么被驳回 / 作废」。
- 它**只加列**:不重建表、不改任何既有数据行;老登记的 `note` 保持 `NULL`(「本来就没有理由」是事实,不会被写成一条假理由)。
- ✅ **旧版赛事项目包仍可导入**。导入一个不含 `note` 列的旧包后,它**成为当前赛事时(切换过去、服务端启动打开赛事库时)会自动补跑迁移**,**不需要任何手工处理**;旧的破纪录登记、纪录库、成绩与积分一行不丢。
- 备份同理:升级前的备份文件(旧 `event.db`)照常能恢复,恢复后再打开时也会自动补迁移。
### ② 四个新入口
| 入口 | 位置 | 做什么 |
| --- | --- | --- |
| **作废本条 / 重新录入** | 成绩录入页每行的「操作」列 | 手滑填错了不必整组撤销发布:已发布的行仍然是锁的,但可以点「作废本条」(**必填原因**)让这一条退出名次与积分;已作废的行显示红徽标「已作废」,点「重新录入」(必填原因)退回草稿,改完再发布本组。两者都**立即重算并推大屏**,不受「分批发布时实时更新榜单」这个延迟结算开关影响 |
| **已批准的破纪录 / 作废** | 破纪录页新增的「已批准的破纪录」卡片 | 批错的破纪录可以撤回:点「作废」(**必填原因**)→ 撤回该笔加分、重算推榜,并把该级别的当前纪录**恢复为上一条** |
| **新增历史纪录 / 删除** | 破纪录页「纪录库」卡片 | 赛前手工登记历史纪录(小项 / 级别 / 成绩值或文本 / 保持人 / 保持单位 / 创造日期);每行可「删除」,删的是当前纪录时会恢复上一条。**「来源」列现在真的会显示「历史」或「本届」**(以前没有历史录入入口,实际只会是「本届」) |
| **仪表盘四张新卡片** | 仪表盘 | 今天的小项(按排期 + 进度)、草稿待发布、待确认破纪录、大屏在线数;旧卡片保留 |
### ③ 「拒绝」从内存改持久带来的行为变化
- 以前点「拒绝」只在**本机浏览器里**把这条疑似破纪录藏起来,刷新就回来。现在改成**先登记再驳回**:服务端会登记一条破纪录记录并立即驳回(理由写「现场判定不是破纪录」),**刷新后不再出现**;谁在什么时候否掉了哪一条,也能在审计日志里查到。
- ⚠️ **升级前在本机「拒绝」过的疑似破纪录,升级后刷新会重新出现一次**——因为那次拒绝从来没有写到服务端。重新点一次「拒绝」即可(这次会落到服务端)。
- 只有真的写不进去时(示例数据模式 / 未连接服务端)才退回本机隐藏,提示会如实说明「刷新页面后该条会重新出现」。
### ④ 升级后 5 分钟核对
1. 打开一次「成绩录入」页,确认表格最后一列是「操作」;
2. 打开「破纪录」页,确认多出「已批准的破纪录」卡片,且「纪录库」卡片有「新增历史纪录」按钮与每行的「删除」;
3. 打开仪表盘,确认多出今天的小项 / 草稿待发布 / 待确认破纪录 / 大屏在线数四张卡片;
4. 旧赛事包核对:导入一个**升级前导出的赛事项目包**并切换为当前赛事,确认能正常打开、破纪录与纪录库数据都在(迁移 0021 会自动补跑);
5. 用《上线检查清单》过一遍。
### ⑤ 详细说明在哪
| 想了解 | 看 |
| --- | --- |
| 手滑填错一条成绩怎么改 | 《使用手册》4.10;《常见问题》Q98 |
| 破纪录批错了 / 怎么拒绝 | 《使用手册》6.1;《常见问题》Q99 |
| 历史纪录怎么录 | 《使用手册》6.2;《常见问题》Q100 |
| 仪表盘四张卡片 | 《使用手册》第 3 节;开发侧 `devdocs/07` §7 |
---
## dev_0.2.0
### 一句话 ### 一句话
+52 -1
View File
@@ -34,7 +34,7 @@
草稿不上屏。需要「发布」(或 `Alt+Enter`),发布后 1 秒内大屏更新。 草稿不上屏。需要「发布」(或 `Alt+Enter`),发布后 1 秒内大屏更新。
**Q8:发布错了怎么改?** **Q8:发布错了怎么改?**
撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。 撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。**只错了一条成绩时不必撤销整组**,用那一行的「作废本条 → 重新录入」即可,见 Q98。
**Q9:为什么「发布」是灰的?** **Q9:为什么「发布」是灰的?**
常见原因:未登录、服务端断开、示例数据模式,或存在**会阻止发布**的校验错误(秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。**没录成绩不算错误、不挡发布**(见 Q77)。成绩格的报错会把**那一格的实际内容**打出来并对症给改法(见 Q75、Q76)。 常见原因:未登录、服务端断开、示例数据模式,或存在**会阻止发布**的校验错误(秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。**没录成绩不算错误、不挡发布**(见 Q77)。成绩格的报错会把**那一格的实际内容**打出来并对症给改法(见 Q75、Q76)。
@@ -101,6 +101,8 @@
**Q19:破纪录加分为什么点不了?** **Q19:破纪录加分为什么点不了?**
规则包里破纪录加分默认关闭(`recordBonus.enabled = false`)时按钮不可用;若当前角色读不到规则包,页面显示「未知」,可提交,由服务端校验决定是否加分。 规则包里破纪录加分默认关闭(`recordBonus.enabled = false`)时按钮不可用;若当前角色读不到规则包,页面显示「未知」,可提交,由服务端校验决定是否加分。
> 这个开关只管「**确认加分**」能不能点,不影响的:疑似破纪录照样列出,「**拒绝**」照样能用(它不会加分),以及 **已批准但批错了的登记可以「作废」**(会撤回加分并恢复上一条纪录,见 Q99)。
**Q20:人工调整为什么点不了?** **Q20:人工调整为什么点不了?**
规则包里人工调整默认关闭(`manualAdjustment.enabled = false`)时不可用;以页面顶部显示的开关状态为准。 规则包里人工调整默认关闭(`manualAdjustment.enabled = false`)时不可用;以页面顶部显示的开关状态为准。
@@ -628,3 +630,52 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
- **什么现象**:在「变更历史」里展开某个历史版本时,页面会按**那一版保存下来的成绩**重新算一遍各单位分数。而**按单位录入的「团体名次」不在历史版本快照里**,所以回看时这些组别的团体分算不出来,各单位的分数增减可能与当时现场看到的略有差异; - **什么现象**:在「变更历史」里展开某个历史版本时,页面会按**那一版保存下来的成绩**重新算一遍各单位分数。而**按单位录入的「团体名次」不在历史版本快照里**,所以回看时这些组别的团体分算不出来,各单位的分数增减可能与当时现场看到的略有差异;
- **不影响什么**:**当前榜单、大屏、导出全部是对的**(它们读的是库里现存的团体名次,与历史快照无关);只有「历史版本详情」里那一列**分数增减**(以及它影响的单位数)会因此偏小或对不上; - **不影响什么**:**当前榜单、大屏、导出全部是对的**(它们读的是库里现存的团体名次,与历史快照无关);只有「历史版本详情」里那一列**分数增减**(以及它影响的单位数)会因此偏小或对不上;
- 想核对当时的真实分数,以**当时的导出件**(成绩总表 / 单位积分明细)为准。 - 想核对当时的真实分数,以**当时的导出件**(成绩总表 / 单位积分明细)为准。
**Q98:成绩填错了怎么改?**
先看是**只错一条**还是**整组都要改**——两条路的影响面完全不同:
**① 只错一条(最常见)**:到「成绩录入」找到那一行,用最右端「操作」列的按钮:
1. 点 **「作废本条」**,填 **「作废原因」**(必填,留空点了不提交)→ 确认。这一条立刻退出名次与积分,同组其它人重新排名,**大屏立即更新**;
2. 同一行的按钮这时变成 **「重新录入」**,点它、填 **「退回原因」**(必填)→ 这一条退回草稿,变成可以编辑的行;
3. 把成绩改对,再点页头的 **「发布」**(该组还有别的已发布成绩时显示为「发布本批」,见 Q77)。同组别人的成绩一直在榜上,不会被翻下来。
**② 整组都要改**(例如整组被判罚、名次口径整体调整):点「撤销发布」→ 修改 → 重新发布(见 Q8)。它会把该组已产生的积分流水整体作废再重发,大屏先回退一次。
两个按钮的细节:
- **已发布的行是锁着的**(成绩 / 名次 / 状态 / 备注都改不了)——这是有意的:已发布就意味着已经上屏、已经计分,允许就地改会变成「榜单上是旧数、表格里是新数」;要改必须先明确地把它退回来,所以给了「作废本条 / 重新录入」这一对按钮,而不是直接解锁;
- 作废与退回都**立即重算并推大屏**,**不受「分批发布时实时更新榜单」这个延迟结算开关影响**(它们改的是「这条成绩算不算数」,不是「录了多少」);
- 两者都会**留痕**:审计日志(见使用手册第 13 节)里的动作分别是 `result.void` 与 `result.reopen`,含操作人、时间与填写的原因。「变更历史」记的是**发布 / 撤销发布**这类整组版本,单条作废 / 退回不会在那里新增一个节点;
- 有个前提:点之前先把未保存的编辑「存草稿」——作废 / 退回会重算并整表刷新,页面会拦下并提示。
**Q99:误批准了破纪录怎么办?**
到「破纪录」页,下面有 **「已批准的破纪录」** 卡片(需要**录入员及以上**才能看),找到那一条,点右端的 **「作废」**:
1. 确认框会写清后果并给一个 **「作废原因」** 输入框(必填);
2. 确认后服务端在同一件事里做完三样:**撤回该笔加分**、**重算并推榜**(榜单回退)、**把该级别的当前纪录恢复为上一条**;
3. 完成提示「加分已撤回,该级别纪录已恢复上一条」。
注意两点:
- 如果**该级别本来就没有更早的纪录**,撤下后就留空——「无纪录」是事实,不会留一条刚被作废的纪录充数;
- **撤回的是「加分」**:这条成绩本身的积分(名次分)不受影响,它当初是按正常成绩算的。
顺带纠正一个旧说法:点「拒绝」现在**不是**只在本机隐藏了——系统会在服务端**先登记一条破纪录记录、再立即驳回**(原因固定写「现场判定不是破纪录」),所以**刷新后不会再出现**,谁在什么时候否掉了哪一条也能在审计日志里查到。只有示例数据模式 / 服务端连不上时才退回本机隐藏,并会如实提示「刷新页面后该条会重新出现」。
> 升级提醒:**升级前在本机「拒绝」过的疑似破纪录,升级后刷新会重新出现一次**——那次拒绝没有写到服务端,重新点一次「拒绝」即可。
**Q100:历史纪录怎么录?**
到「破纪录」页的 **「纪录库」** 卡片,点卡片头部的 **「新增历史纪录」**:
1. 选 **小项**(必选)与 **级别**(赛会 / 市级 / 省级 / 全国,默认赛会);
2. 填 **成绩值**(数字)与 / 或 **成绩文本**(如 `11.20`、`1:06.33`)——**至少填一个**,都填时比较与破纪录判断以成绩值为准;
3. 可选填 **保持人姓名**、**保持单位**、**创造日期**(都可以留空,老资料常常只有一部分,留空不会逼你编数据);
4. 点「保存纪录」,提示「已新增历史纪录 · 来源记为「历史」」。
新录的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(仍留在库里)。纪录库的 **「来源」列**因此有了两种值:**历史** = 赛前手工登记的旧资料,**本届** = 本场赛事批准破纪录时自动写入的(以前没有历史录入入口,实际只会看到「本届」)。
**删除**:每行右端有「删除」(需**录入员及以上**)。确认框会说明「若它是当前纪录,会恢复上一条」,并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;删除不会触发重算。
+3
View File
@@ -234,6 +234,9 @@ type recordBreakDTO struct {
State string `json:"state"` State string `json:"state"`
ApprovedBy *int64 `json:"approvedBy"` ApprovedBy *int64 `json:"approvedBy"`
ApprovedAt *string `json:"approvedAt"` ApprovedAt *string `json:"approvedAt"`
// Note 是驳回 / 作废这条登记时写下的理由(0021 新增的列);从未被驳回的登记(含
// 老库里的历史行)为 null——空理由与「没有理由」是两回事,所以这里用指针。
Note *string `json:"note"`
} }
type auditLogDTO struct { type auditLogDTO struct {
@@ -105,6 +105,21 @@ func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any,
}) })
} }
// reopenResultHandler 把一条成绩退回草稿,供录入员直接修改后重新发布本组。
func (s *server) reopenResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in reopenResultPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.reopenResult(r.Context(), id, in.Reason, middlewareUserID(r))
})
}
// ---------- 积分与排名 ---------- // ---------- 积分与排名 ----------
func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
@@ -226,6 +241,53 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques
return s.approveRecordBreak(r.Context(), id, middlewareUserID(r)) return s.approveRecordBreak(r.Context(), id, middlewareUserID(r))
} }
// listRecordBreaksHandler 列出破纪录登记(最新在前),可选按审核状态过滤。
// 状态拼错时明确报 400:静默回空列表会让现场以为「登记全没了」。
func (s *server) listRecordBreaksHandler(w http.ResponseWriter, r *http.Request) (any, error) {
state := strings.TrimSpace(r.URL.Query().Get("state"))
switch state {
case "", "pending", "approved", "rejected":
default:
return nil, badRequest("state 只能是 pending/approved/rejected", state)
}
return s.listRecordBreaks(r.Context(), state)
}
// rejectRecordBreakHandler 驳回 / 作废一条破纪录登记;理由必填并落到 note。
func (s *server) rejectRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in rejectRecordBreakPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.rejectRecordBreak(r.Context(), id, middlewareUserID(r), in.Reason)
})
}
// createRecordHandler 手工登记一条历史纪录(纪录库的「新增」)。
func (s *server) createRecordHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in recordEntryInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.createRecordEntry(r.Context(), in, middlewareUserID(r))
})
}
// deleteRecordHandler 删除一条纪录(纪录库的「删除」)。
func (s *server) deleteRecordHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
return s.deleteRecordEntry(r.Context(), id, middlewareUserID(r))
}
// ---------- 大屏控制与运维 ---------- // ---------- 大屏控制与运维 ----------
// announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长; // announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长;
@@ -0,0 +1,181 @@
package httpapi_test
import (
"database/sql"
"fmt"
"io/fs"
"net/http"
"sort"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
// applyEventMigrationsBefore 把赛事库停在 stopAt 之前:只执行并登记文件名小于它的迁移。
// 与 migrations/event 包里的 applyBefore 同口径,只是那份是未导出函数,httpapi 的测试包调不到。
func applyEventMigrationsBefore(t *testing.T, db *sql.DB, stopAt string) {
t.Helper()
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
t.Fatalf("创建 schema_migrations: %v", err)
}
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
sort.Strings(names)
for _, n := range names {
if n >= stopAt {
continue
}
body, err := fs.ReadFile(fsys, n)
if err != nil {
t.Fatalf("读取迁移 %s: %v", n, err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("应用迁移 %s: %v", n, err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil {
t.Fatalf("登记迁移 %s: %v", n, err)
}
}
}
// 旧版赛事包(不含 record_breaks.note 的旧 event.db)导入后:走 main.go 的同一条升级路径,
// 旧数据一行不丢,新接口(审核清单 / 驳回 / 纪录库写入)当场可用。
//
// 这是主人特别交代的兼容底线:迁移只能做加法,且任何新列都只能在升级之后才被读到。
func TestLegacyEventPackUpgradeKeepsDataAndEnablesNewFeatures(t *testing.T) {
env := newTestEnvPrepared(t, func(ev *sql.DB) {
applyEventMigrationsBefore(t, ev, "0021")
exec := func(query string, args ...any) int64 {
t.Helper()
res, err := ev.Exec(query, args...)
if err != nil {
t.Fatalf("造老库数据失败: %v\n%s", err, query)
}
id, _ := res.LastInsertId()
return id
}
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
exec(`INSERT INTO athletes(name, gender, team_id, active) VALUES ('张伟', '男', 1, 1)`)
exec(`INSERT INTO sports(code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status)
VALUES (1, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 1, 'published')`)
exec(`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`)
exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'athlete', 'result', (SELECT id FROM scoring_profiles WHERE code = 'individual_top6'), 0)`)
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`)
exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, status, state)
VALUES (1, 1, 1, '11.58', 11.58, 'ok', 'published')`)
// 批准过的破纪录:纪录库已经是「本届纪录当前、历史纪录让位」的样子。
exec(`INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source)
VALUES (1, 'meet', 11.20, '11.20', '李四', 0, 'history')`)
exec(`INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source)
VALUES (1, 'meet', 11.58, '11.58', '张伟', 1, 'current')`)
exec(`INSERT INTO record_breaks(event_id, entry_id, level, result_value, result_text, bonus_points, state)
VALUES (1, 1, 'meet', 11.58, '11.58', 2, 'approved')`)
exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`)
exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 'record_bonus', 1, 2)`)
exec(`INSERT INTO settings(key, value, updated_at) VALUES ('record_bonus.enabled', 'true', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
exec(`INSERT INTO settings(key, value, updated_at) VALUES ('record_bonus.levels',
'[{"code":"meet","name":"赛会纪录","points":2}]', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
}, nil)
// ① 升级之后老数据一行不少、一行不多。
for table, want := range map[string]int{
"teams": 1, "athletes": 1, "events": 1, "entries": 1, "results": 1,
"records": 2, "record_breaks": 1, "score_ledger": 2,
} {
if got := countRows(t, env, `SELECT COUNT(*) FROM `+table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
var (
rank int
state string
)
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
t.Fatalf("读老成绩: %v", err)
}
if rank != 1 || state != "published" {
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
}
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// ② 新接口在升级后的旧库上当场可用:审核清单读得到老登记,note 为 null。
status, env2 := env.do(client, http.MethodGet, "/api/record-breaks?state=approved", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读审核清单应 200,得到 %d %+v", status, env2.Error)
}
breaks := decodeData[[]map[string]any](t, env2)
if len(breaks) != 1 || breaks[0]["note"] != nil || breaks[0]["state"] != "approved" {
t.Fatalf("老登记应原样出现在清单里且 note 为 null: %+v", breaks)
}
breakID := int64(breaks[0]["id"].(float64))
// ③ 驳回这条已批准的登记:加分消失、榜单回退、该档位纪录恢复为上一条历史纪录。
if got := teamPoints(t, env, client, 1); got != 12 {
t.Fatalf("前置:单位总分应是 10+2,得到 %v", got)
}
status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
map[string]any{"reason": "录像复核未破"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("驳回老登记应 200,得到 %d %+v", status, env2.Error)
}
rejected := decodeData[map[string]any](t, env2)
if rejected["state"] != "rejected" || rejected["note"] != "录像复核未破" {
t.Fatalf("驳回后状态与理由不对: %+v", rejected)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
WHERE source_type = 'record_bonus' AND voided = 0`); n != 0 {
t.Fatalf("驳回后加分流水应作废,仍有 %d 条存活", n)
}
if got := teamPoints(t, env, client, 1); got != 10 {
t.Fatalf("驳回后单位总分应回到 10,得到 %v", got)
}
var (
currentName string
currentID int64
)
if err := env.ev.QueryRow(`SELECT id, COALESCE(holder_name, '') FROM records
WHERE event_id = 1 AND level = 'meet' AND is_current = 1`).
Scan(&currentID, &currentName); err != nil {
t.Fatalf("读当前纪录: %v", err)
}
if currentID != 1 || currentName != "李四" {
t.Errorf("驳回后当前纪录应恢复为历史那条(李四),得到 id=%d holder=%q", currentID, currentName)
}
// ④ 纪录库的写入路径在旧库升级后可用:登记一条历史纪录。
status, env2 = env.do(client, http.MethodPost, "/api/records", map[string]any{
"eventId": 1, "level": "city", "resultValue": 10.90, "holderName": "王五",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("升级后的旧库登记历史纪录应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["source"] != "history" || created["isCurrent"] != true {
t.Fatalf("新登记的历史纪录不对: %+v", created)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE event_id = 1 AND level = 'city'`); n != 1 {
t.Fatalf("历史纪录应落库,得到 %d 行", n)
}
}
+31
View File
@@ -73,6 +73,37 @@ type recordBreakInput struct {
RequestID string `json:"requestId"` RequestID string `json:"requestId"`
} }
// reopenResultPayload 是 POST /api/results/{id}/reopen 的请求体:
// 把一条成绩退回草稿让录入员直接改,理由必填(与作废同口径)。
type reopenResultPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
// rejectRecordBreakPayload 是 POST /api/record-breaks/{id}/reject 的请求体:
// 驳回 / 作废一条破纪录登记,理由必填并落到 record_breaks.note。
type rejectRecordBreakPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
// recordEntryInput 是 POST /api/records 的请求体:手工登记一条历史纪录。
//
// source 只接受 history(缺省即 history):source='current' 是「确认破纪录」写出来的产物,
// 客户端造不得——那会让一条没人批准过的成绩看起来像是本届刚破的纪录。
type recordEntryInput struct {
EventID int64 `json:"eventId"`
Level string `json:"level"`
// ResultValue 与 ResultText 至少给一个:纪录可以只有数值(10.5)或只有文本('10秒5')。
ResultValue *float64 `json:"resultValue"`
ResultText *string `json:"resultText"`
HolderName *string `json:"holderName"`
HolderTeamID *int64 `json:"holderTeamId"`
AchievedAt *string `json:"achievedAt"`
Source string `json:"source"`
RequestID string `json:"requestId"`
}
// displayCommandInput 是大屏控制指令的请求体。 // displayCommandInput 是大屏控制指令的请求体。
type displayCommandInput struct { type displayCommandInput struct {
Action string `json:"action"` Action string `json:"action"`
@@ -0,0 +1,299 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"testing"
)
// recordRow 是纪录库里一行的存储层事实(测试直接读表,不走接口)。
type recordRow struct {
ID int64
IsCurrent bool
Source string
ResultText string
HolderName sql.NullString
}
// recordsOf 读出某小项某档位的全部纪录行(含非当前行),按 id 升序。
func recordsOf(t *testing.T, env *testEnv, eventID int64, level string) []recordRow {
t.Helper()
rows, err := env.ev.Query(
`SELECT id, is_current, source, COALESCE(result_text, ''), holder_name
FROM records WHERE event_id = ? AND level = ? ORDER BY id`, eventID, level)
if err != nil {
t.Fatalf("读纪录库: %v", err)
}
defer func() { _ = rows.Close() }()
out := make([]recordRow, 0)
for rows.Next() {
var r recordRow
if err := rows.Scan(&r.ID, &r.IsCurrent, &r.Source, &r.ResultText, &r.HolderName); err != nil {
t.Fatalf("扫描纪录: %v", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历纪录: %v", err)
}
return out
}
// currentRecord 取某小项某档位的当前纪录;没有当前纪录时返回 nil。
func currentRecord(t *testing.T, env *testEnv, eventID int64, level string) *recordRow {
t.Helper()
for _, r := range recordsOf(t, env, eventID, level) {
if r.IsCurrent {
row := r
return &row
}
}
return nil
}
// createBreak 登记一条破纪录并返回它的 id。
func createBreak(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entryID int64, level string, value float64) int64 {
t.Helper()
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
"eventId": ids.event, "entryId": entryID, "level": level,
"resultValue": value, "resultText": fmt.Sprintf("%.2f", value),
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
}
return int64(decodeData[map[string]any](t, env2)["id"].(float64))
}
// 驳回一条「已批准」的破纪录:加分消失、榜单回退、该档位纪录恢复到上一条(A41)。
func TestRejectApprovedRecordBreakRevertsBonusAndRecord(t *testing.T) {
env, ids, client := recorderWithSeed(t)
enableRecordBonus(t, env)
publishResults(t, env, client, ids.event, "reject",
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"},
)
// 先手工登记一条历史纪录:驳回破纪录后要恢复的正是它。
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
"eventId": ids.event, "level": "meet", "resultValue": 11.20, "resultText": "11.20",
"holderName": "李四", "achievedAt": "2019-05-01",
}, true)
if status != http.StatusOK {
t.Fatalf("登记历史纪录应 200,得到 %d %+v", status, env2.Error)
}
historyID := int64(decodeData[map[string]any](t, env2)["id"].(float64))
breakID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
if status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
t.Fatalf("批准破纪录应 200,得到 %d %+v", status, env2.Error)
}
// 前置:加分 2 分已进榜,纪录库当前行换成了本届纪录(source=current)。
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
t.Fatalf("前置:批准后应有 2 分加分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
t.Fatalf("前置:单位总分应是 10+2,得到 %v", got)
}
if cur := currentRecord(t, env, ids.event, "meet"); cur == nil || cur.Source != "current" || cur.ID == historyID {
t.Fatalf("前置:当前纪录应是批准写下的那条: %+v", cur)
}
status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
map[string]any{"reason": "录像复核未破纪录", "requestId": "reject-1"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("驳回应 200,得到 %d %+v", status, env2.Error)
}
rejected := decodeData[map[string]any](t, env2)
if rejected["state"] != "rejected" || rejected["note"] != "录像复核未破纪录" {
t.Fatalf("驳回后状态与理由不对: %+v", rejected)
}
// 加分随驳回消失,榜单回到批准前。
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
t.Fatalf("驳回后加分应清零,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("驳回后单位总分应回到 10,得到 %v", got)
}
// 该档位纪录恢复到上一条(历史纪录),本届那条退出当前。
if cur := currentRecord(t, env, ids.event, "meet"); cur == nil || cur.ID != historyID || cur.Source != "history" {
t.Fatalf("驳回后当前纪录应恢复为历史那条 %d: %+v", historyID, cur)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE event_id = ? AND level = 'meet' AND is_current = 1`,
ids.event); n != 1 {
t.Fatalf("同一档位只该有一条当前纪录,得到 %d", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'record_break.reject'`); n != 1 {
t.Fatalf("驳回应写 1 条审计,得到 %d", n)
}
// 再驳回一次:已经是 rejected,必须拒绝而不是空转。
status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
map[string]any{"reason": "再驳回", "requestId": "reject-2"}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "该破纪录登记已被驳回/作废" {
t.Fatalf("重复驳回应 400,得到 %d %+v", status, env2.Error)
}
// 理由必填、登记不存在。
if status, _ = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", breakID), map[string]any{"reason": " "}, true); status != http.StatusBadRequest {
t.Fatalf("空理由应 400,得到 %d", status)
}
if status, _ = env.do(client, http.MethodPost, "/api/record-breaks/999999/reject",
map[string]any{"reason": "不存在"}, true); status != http.StatusNotFound {
t.Fatalf("登记不存在应 404,得到 %d", status)
}
}
// 驳回一条 pending 的登记:没有加过分,就不该动榜单与纪录库。
func TestRejectPendingRecordBreakLeavesScoresAlone(t *testing.T) {
env, ids, client := recorderWithSeed(t)
enableRecordBonus(t, env)
publishResults(t, env, client, ids.event, "pending-reject",
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"},
)
pointsBefore := teamPoints(t, env, client, ids.teamA)
standingsBefore := rawData(t, env, client, "/api/standings")
breakID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
if status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
map[string]any{"reason": "现场登记错了"}, true); status != http.StatusOK {
t.Fatalf("驳回 pending 登记应 200,得到 %d %+v", status, env2.Error)
}
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
t.Fatalf("pending 驳回不该产生加分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != pointsBefore {
t.Fatalf("pending 驳回不该改动总分:%v → %v", pointsBefore, got)
}
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
t.Fatalf("pending 驳回不该改动榜单:\n%s\n%s", standingsBefore, got)
}
if rows := recordsOf(t, env, ids.event, "meet"); len(rows) != 0 {
t.Fatalf("pending 驳回不该写纪录库,得到 %+v", rows)
}
}
// 重新批准一条被驳回的登记:加分回来、理由被清掉、纪录库重新记上本届纪录。
func TestReapproveRejectedRecordBreak(t *testing.T) {
env, ids, client := recorderWithSeed(t)
enableRecordBonus(t, env)
publishResults(t, env, client, ids.event, "reapprove",
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"},
)
breakID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
if status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
map[string]any{"reason": "先驳回", "requestId": "reapprove-reject"}, true); status != http.StatusOK {
t.Fatalf("驳回应 200,得到 %d %+v", status, env2.Error)
}
// 被驳回的登记给不了加分(引擎只认 approved)。
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
t.Fatalf("驳回后不该有加分,得到 %v", got)
}
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重新批准应 200,得到 %d %+v", status, env2.Error)
}
approved := decodeData[map[string]any](t, env2)
if approved["state"] != "approved" {
t.Fatalf("重新批准后状态应为 approved: %+v", approved)
}
if approved["note"] != nil {
t.Fatalf("重新批准应清掉驳回理由,得到 %+v", approved["note"])
}
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
t.Fatalf("重新批准后加分应回来,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
t.Fatalf("重新批准后单位总分应是 12,得到 %v", got)
}
if cur := currentRecord(t, env, ids.event, "meet"); cur == nil || cur.Source != "current" {
t.Fatalf("重新批准后当前纪录应是本届纪录: %+v", cur)
}
// 已经 approved 的再批准一次仍按老口径拒绝,不能重复加分。
status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Message != "该破纪录登记已处理" {
t.Fatalf("已批准的登记再批准应 409(既有口径),得到 %d %+v", status, env2.Error)
}
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
t.Fatalf("重复批准不该重复加分,得到 %v", got)
}
}
// 审核清单:按状态过滤、最新在前、老库升级来的行 note 为 null;guest 读不到。
func TestListRecordBreaksFilterAndPermission(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "list-breaks",
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"},
)
pendingID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
approvedID := createBreak(t, env, client, ids, ids.entryB, "city", 11.92)
env.exec(t, `UPDATE record_breaks SET state = 'approved' WHERE id = ?`, approvedID)
rejectedID := createBreak(t, env, client, ids, ids.entryA, "province", 11.58)
if status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/reject", rejectedID),
map[string]any{"reason": "不算"}, true); status != http.StatusOK {
t.Fatalf("驳回应 200,得到 %d %+v", status, env2.Error)
}
list := func(query string) []map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/record-breaks"+query, nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读审核清单应 200,得到 %d %+v", status, env2.Error)
}
return decodeData[[]map[string]any](t, env2)
}
all := list("")
if len(all) != 3 {
t.Fatalf("不过滤应返回 3 条,得到 %d: %+v", len(all), all)
}
// 最新在前:id 降序。
if int64(all[0]["id"].(float64)) != rejectedID || int64(all[2]["id"].(float64)) != pendingID {
t.Fatalf("清单应按 id 降序: %+v", all)
}
// 老库 / 未驳回的行没有理由,必须是 null(不是空串)。
for _, item := range all {
note := item["note"]
switch int64(item["id"].(float64)) {
case rejectedID:
if note != "不算" {
t.Fatalf("被驳回的行应带上理由: %+v", item)
}
case pendingID, approvedID:
if note != nil {
t.Fatalf("没被驳回的行 note 应为 null: %+v", item)
}
}
}
if got := list("?state=pending"); len(got) != 1 || int64(got[0]["id"].(float64)) != pendingID {
t.Fatalf("?state=pending 结果不对: %+v", got)
}
if got := list("?state=approved"); len(got) != 1 || int64(got[0]["id"].(float64)) != approvedID {
t.Fatalf("?state=approved 结果不对: %+v", got)
}
if got := list("?state=rejected"); len(got) != 1 || int64(got[0]["id"].(float64)) != rejectedID {
t.Fatalf("?state=rejected 结果不对: %+v", got)
}
// 状态拼错要明确报错,不能静默回空列表。
if status, env2 := env.do(client, http.MethodGet, "/api/record-breaks?state=bogus", nil, false); status != http.StatusBadRequest {
t.Fatalf("非法 state 应 400,得到 %d %+v", status, env2.Error)
}
// guest 读不到(含未审核的登记与驳回理由)。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/record-breaks", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读审核清单应 403,得到 %d", status)
}
}
@@ -0,0 +1,182 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// recordsList 读 GET /api/records(当前纪录清单)。
func recordsList(t *testing.T, env *testEnv, client *http.Client, eventID int64) []map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodGet, fmt.Sprintf("/api/records?eventId=%d", eventID), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读纪录库应 200,得到 %d %+v", status, env2.Error)
}
return decodeData[[]map[string]any](t, env2)
}
// 手工登记历史纪录:新行成为当前纪录(source=history),同档位旧 current 让位(C11)。
func TestCreateHistoryRecordReplacesCurrent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
"eventId": ids.event, "level": "meet", "resultValue": 11.20, "resultText": "11.20",
"holderName": "李四", "holderTeamId": ids.teamA, "achievedAt": "2019-05-01",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("登记历史纪录应 200,得到 %d %+v", status, env2.Error)
}
first := decodeData[map[string]any](t, env2)
if first["source"] != "history" || first["isCurrent"] != true {
t.Fatalf("新登记的历史纪录应是当前纪录且 source=history: %+v", first)
}
if first["holderName"] != "李四" || first["achievedAt"] != "2019-05-01" {
t.Fatalf("持有者与达成时间没落库: %+v", first)
}
firstID := int64(first["id"].(float64))
// 只给文本、不给数值也能录:很多老纪录只留了一句话。
status, env2 = env.do(client, http.MethodPost, "/api/records", map[string]any{
"eventId": ids.event, "level": "meet", "resultText": "10秒4",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("只有成绩文本的历史纪录应 200,得到 %d %+v", status, env2.Error)
}
second := decodeData[map[string]any](t, env2)
secondID := int64(second["id"].(float64))
// 接口读到的当前纪录只有新那条,且 source == history。
list := recordsList(t, env, client, ids.event)
if len(list) != 1 {
t.Fatalf("同一档位应只有一条当前纪录,得到 %+v", list)
}
if list[0]["source"] != "history" || int64(list[0]["id"].(float64)) != secondID {
t.Fatalf("当前纪录应是刚登记的那条历史纪录: %+v", list[0])
}
// 存储层:旧 current 被置 0,但仍留在库里(历史不是删掉,是让位)。
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE id = ? AND is_current = 0`, firstID); n != 1 {
t.Fatalf("旧 current 应被置 0 并保留,得到 %d", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'record.create'`); n != 2 {
t.Fatalf("每次登记应写 1 条审计,得到 %d", n)
}
}
// 登记历史纪录的校验:eventId / level / 成绩值必须有,且不许客户端造 source=current。
func TestCreateHistoryRecordValidation(t *testing.T) {
env, ids, client := recorderWithSeed(t)
for _, tc := range []struct {
name string
body map[string]any
}{
{"缺 eventId", map[string]any{"level": "meet", "resultValue": 11.2}},
{"level 非法", map[string]any{"eventId": 1, "level": "world", "resultValue": 11.2}},
{"成绩值全缺", map[string]any{"eventId": 1, "level": "meet"}},
{"eventId 不存在", map[string]any{"eventId": 999999, "level": "meet", "resultValue": 11.2}},
// source=current 是「确认破纪录」的产物,客户端造不得。
{"伪造 current", map[string]any{"eventId": 1, "level": "meet", "resultValue": 11.2, "source": "current"}},
} {
body := tc.body
// 让 eventId=1 的用例指向真实小项,同时保留「不存在」用例的 999999。
if v, ok := body["eventId"]; ok && v == 1 {
body = copyWithEvent(body, ids.event)
}
status, env2 := env.do(client, http.MethodPost, "/api/records", body, true)
if status != http.StatusBadRequest {
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM records`); n != 0 {
t.Fatalf("被拒绝的登记不该落库,得到 %d 行", n)
}
// holderTeamId 不存在要报 400(外键兜底),不是 500。
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
"eventId": ids.event, "level": "meet", "resultValue": 11.2, "holderTeamId": 999999,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("holderTeamId 不存在应 400,得到 %d %+v", status, env2.Error)
}
// guest 不能登记。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/records", map[string]any{
"eventId": ids.event, "level": "meet", "resultValue": 11.2,
}, false); status != http.StatusUnauthorized {
t.Fatalf("未登录登记应 401,得到 %d", status)
}
}
// 删除当前纪录要恢复同档位上一条;删掉最后一条就把该档位留空(C11)。
func TestDeleteHistoryRecordRestoresPrevious(t *testing.T) {
env, ids, client := recorderWithSeed(t)
mk := func(level string, value float64, holder string) int64 {
t.Helper()
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
"eventId": ids.event, "level": level, "resultValue": value, "holderName": holder,
}, true)
if status != http.StatusOK {
t.Fatalf("登记历史纪录应 200,得到 %d %+v", status, env2.Error)
}
return int64(decodeData[map[string]any](t, env2)["id"].(float64))
}
older := mk("meet", 11.40, "老纪录")
newer := mk("meet", 11.20, "新纪录")
mk("city", 10.90, "另一档位")
// 删当前纪录:上一条历史纪录回到当前。
if status, env2 := env.do(client, http.MethodDelete,
fmt.Sprintf("/api/records/%d", newer), nil, true); status != http.StatusOK {
t.Fatalf("删除纪录应 200,得到 %d %+v", status, env2.Error)
}
list := recordsList(t, env, client, ids.event)
if len(list) != 2 {
t.Fatalf("删除后应剩 2 条当前纪录(meet 的上一条 + city),得到 %+v", list)
}
var meetCurrent map[string]any
for _, item := range list {
if item["level"] == "meet" {
meetCurrent = item
}
}
if meetCurrent == nil || int64(meetCurrent["id"].(float64)) != older {
t.Fatalf("删除当前纪录后应恢复上一条 %d: %+v", older, meetCurrent)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'record.delete'`); n != 1 {
t.Fatalf("删除应写 1 条审计,得到 %d", n)
}
// 再删最后一条:该档位没有当前纪录,接口不再返回它。
if status, env2 := env.do(client, http.MethodDelete,
fmt.Sprintf("/api/records/%d", older), nil, true); status != http.StatusOK {
t.Fatalf("删除最后一条应 200,得到 %d %+v", status, env2.Error)
}
for _, item := range recordsList(t, env, client, ids.event) {
if item["level"] == "meet" {
t.Fatalf("删空后该档位不该再有当前纪录: %+v", item)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE event_id = ?`, ids.event); n != 1 {
t.Fatalf("库里应只剩 city 那条,得到 %d", n)
}
// 不存在的 id → 404;guest 不能删。
if status, env2 := env.do(client, http.MethodDelete, "/api/records/999999", nil, true); status != http.StatusNotFound {
t.Fatalf("删除不存在的纪录应 404,得到 %d %+v", status, env2.Error)
}
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodDelete, fmt.Sprintf("/api/records/%d", newer), nil, false); status != http.StatusUnauthorized {
t.Fatalf("未登录删除应 401,得到 %d", status)
}
}
// copyWithEvent 复制请求体并把 eventId 换成真实小项(校验用例里写死 1 只是占位)。
func copyWithEvent(body map[string]any, eventID int64) map[string]any {
out := make(map[string]any, len(body))
for k, v := range body {
out[k] = v
}
out["eventId"] = eventID
return out
}
+314 -27
View File
@@ -726,10 +726,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
// ---------- records ---------- // ---------- records ----------
// recordSourceHistory 是手工登记的历史纪录的来源标记,与 records.source 的 CHECK 一致。
// 只认它:source='current' 只能由「确认破纪录」写出来(见 upsertCurrentRecord)。
const recordSourceHistory = "history"
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) { func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''), query := `SELECT ` + recordEntryColumns + ` FROM records WHERE is_current = 1`
holder_name, holder_team_id, achieved_at, is_current, source
FROM records WHERE is_current = 1`
args := []any{} args := []any{}
if eventID > 0 { if eventID > 0 {
query += ` AND event_id = ?` query += ` AND event_id = ?`
@@ -745,19 +747,10 @@ func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryD
out := make([]recordEntryDTO, 0) out := make([]recordEntryDTO, 0)
for rows.Next() { for rows.Next() {
var ( r, err := scanRecordEntry(rows)
r recordEntryDTO if err != nil {
holderName sql.NullString
holderTeamID sql.NullInt64
achievedAt sql.NullString
)
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
return nil, internal(err) return nil, internal(err)
} }
r.HolderName = strPtr(holderName)
r.HolderTeamID = i64Ptr(holderTeamID)
r.AchievedAt = strPtr(achievedAt)
out = append(out, r) out = append(out, r)
} }
return out, rows.Err() return out, rows.Err()
@@ -832,30 +825,70 @@ func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInp
} }
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) { func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
var ( b, err := scanRecordBreak(s.ev.QueryRowContext(ctx,
b recordBreakDTO `SELECT `+recordBreakColumns+` FROM record_breaks WHERE id = ?`, id))
value sql.NullFloat64
text sql.NullString
approvedBy sql.NullInt64
approvedAt sql.NullString
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
FROM record_breaks WHERE id = ?`, id).
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在") return recordBreakDTO{}, notFound("破纪录登记不存在")
} }
if err != nil { if err != nil {
return recordBreakDTO{}, internal(err) return recordBreakDTO{}, internal(err)
} }
return b, nil
}
// recordBreakColumns 是 recordBreakDTO 的列清单;note 来自迁移 0021(可空)。
const recordBreakColumns = `id, event_id, entry_id, level, result_value, result_text, bonus_points,
state, approved_by, approved_at, note`
func scanRecordBreak(row interface{ Scan(...any) error }) (recordBreakDTO, error) {
var (
b recordBreakDTO
value sql.NullFloat64
text sql.NullString
approvedBy sql.NullInt64
approvedAt sql.NullString
note sql.NullString
)
if err := row.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints,
&b.State, &approvedBy, &approvedAt, &note); err != nil {
return b, err
}
b.ResultValue = value.Float64 b.ResultValue = value.Float64
b.ResultText = text.String b.ResultText = text.String
b.ApprovedBy = i64Ptr(approvedBy) b.ApprovedBy = i64Ptr(approvedBy)
b.ApprovedAt = strPtr(approvedAt) b.ApprovedAt = strPtr(approvedAt)
b.Note = strPtr(note)
return b, nil return b, nil
} }
// listRecordBreaks 列出破纪录登记(最新在前)。state 为空表示不过滤;
// 传了就按它过滤,非法状态由 handler 拦在更外层(避免一个拼错的状态静默回空列表)。
func (s *server) listRecordBreaks(ctx context.Context, state string) ([]recordBreakDTO, error) {
query := `SELECT ` + recordBreakColumns + ` FROM record_breaks`
args := []any{}
if state != "" {
query += ` WHERE state = ?`
args = append(args, state)
}
query += ` ORDER BY id DESC`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]recordBreakDTO, 0)
for rows.Next() {
b, err := scanRecordBreak(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) { func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil) tx, err := s.ev.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -879,11 +912,14 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
} }
return recordBreakDTO{}, internal(err) return recordBreakDTO{}, internal(err)
} }
if state != "pending" { if state != "pending" && state != "rejected" {
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理") return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
} }
// 重新批准(pending → approved 或 rejected → approved)都要清掉上一次的驳回理由:
// note 的含义是「这条登记现在为什么被驳回」,批准后它已经不成立,留着只会误导。
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`, `UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now'), note = NULL
WHERE id = ?`,
nullID(userID), id); err != nil { nullID(userID), id); err != nil {
return recordBreakDTO{}, internal(err) return recordBreakDTO{}, internal(err)
} }
@@ -914,6 +950,102 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
return s.getRecordBreak(ctx, id) return s.getRecordBreak(ctx, id)
} }
// rejectRecordBreak 驳回 / 作废一条破纪录登记(A41)。
//
// 原本是 approved 的登记意味着它已经给该小项加过分,所以驳回不只是改状态:
//
// ① 状态改 rejected 并把理由写进 note;
// ② 重算该小项(引擎只认 approved 的登记),加分与随之变动的名次流水一起撤掉,再推榜;
// ③ 把「确认破纪录」写进纪录库的那条 current 纪录撤下来,并恢复该级别上一条纪录。
//
// pending 直接驳回没有加过分,②③ 都不用做——这也是本文件里唯一区分「从哪个状态驳回」的地方。
func (s *server) rejectRecordBreak(ctx context.Context, id, userID int64, reason string) (recordBreakDTO, error) {
if strings.TrimSpace(reason) == "" {
return recordBreakDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordBreakDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
state string
eventID int64
level string
)
if err := tx.QueryRowContext(ctx,
`SELECT state, event_id, level FROM record_breaks WHERE id = ?`, id).
Scan(&state, &eventID, &level); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
return recordBreakDTO{}, internal(err)
}
if state == "rejected" {
return recordBreakDTO{}, badRequest("该破纪录登记已被驳回/作废", nil)
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'rejected', note = ? WHERE id = ?`, reason, id); err != nil {
return recordBreakDTO{}, internal(err)
}
if state == "approved" {
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "驳回破纪录:"+reason); err != nil {
return recordBreakDTO{}, err
}
if err := revertCurrentRecord(ctx, tx, eventID, level); err != nil {
return recordBreakDTO{}, err
}
}
// 登记表不进实时快照,但「破纪录登记」这一块要跟着刷新,所以照 approve 的口径写一条变更。
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordBreakDTO{}, err
}
// 只有真的重算过才推榜:pending 驳回没动过任何分数,凭空推一次榜单只会让大屏白刷。
if state == "approved" {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return recordBreakDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return recordBreakDTO{}, internal(err)
}
s.audit(ctx, "record_break.reject", "record_break", id,
map[string]any{"state": state}, map[string]any{"state": "rejected", "reason": reason})
return s.getRecordBreak(ctx, id)
}
// revertCurrentRecord 撤下「确认破纪录」写进纪录库的那条当前纪录,并恢复该档位上一条纪录。
//
// 只能按 (小项, 档位) 定位:records 上没有指回 record_breaks 的列,0021 也只加了一列理由,
// 没有为此重建表。同一 (小项, 档位) 的当前纪录本来就只有一条(upsertCurrentRecord 每次都先
// 把旧的置 0 再插新的),所以「当前那条」就是这次批准写下的那条。
// 恢复对象取剩下 id 最大的一条;一条都没有就把该档位留空——「无纪录」是事实,
// 留一条刚被撤掉的纪录反而会让纪录库自相矛盾。
func revertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID int64, level string) error {
var currentID sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT id FROM records WHERE event_id = ? AND level = ? AND is_current = 1 ORDER BY id DESC LIMIT 1`,
eventID, level).Scan(&currentID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return internal(err)
}
if !currentID.Valid {
return nil
}
if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE id = ?`, currentID.Int64); err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 1
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ? AND id <> ?
ORDER BY id DESC LIMIT 1)`,
eventID, level, currentID.Int64); err != nil {
return internal(err)
}
return nil
}
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前, // upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。 // 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error { func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
@@ -952,6 +1084,161 @@ func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64
return nil return nil
} }
// recordEntryColumns 是 recordEntryDTO 的列清单。
const recordEntryColumns = `id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
holder_name, holder_team_id, achieved_at, is_current, source`
func scanRecordEntry(row interface{ Scan(...any) error }) (recordEntryDTO, error) {
var (
r recordEntryDTO
holderName sql.NullString
holderTeamID sql.NullInt64
achievedAt sql.NullString
)
if err := row.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
return r, err
}
r.HolderName = strPtr(holderName)
r.HolderTeamID = i64Ptr(holderTeamID)
r.AchievedAt = strPtr(achievedAt)
return r, nil
}
func (s *server) getRecordEntry(ctx context.Context, id int64) (recordEntryDTO, error) {
r, err := scanRecordEntry(s.ev.QueryRowContext(ctx,
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return recordEntryDTO{}, notFound("纪录不存在")
}
if err != nil {
return recordEntryDTO{}, internal(err)
}
return r, nil
}
// createRecordEntry 手工登记一条历史纪录(C11):同 (小项, 档位) 的旧 current 让位,
// 新行成为该档位的当前纪录(与 upsertCurrentRecord 同口径,只是 source 记 history)。
//
// holder 名称 / 单位都可以空:很多历史纪录只留下了一个成绩数字,人是谁已经查不到了;
// 逼着录入员编一个名字,只会往库里塞假数据。
func (s *server) createRecordEntry(ctx context.Context, in recordEntryInput, userID int64) (recordEntryDTO, error) {
if in.EventID == 0 {
return recordEntryDTO{}, badRequest("eventId 必填", nil)
}
switch in.Level {
case "meet", "city", "province", "national":
default:
return recordEntryDTO{}, badRequest("level 非法", in.Level)
}
if in.ResultValue == nil && in.ResultText == nil {
return recordEntryDTO{}, badRequest("resultValue 与 resultText 至少填一个", nil)
}
// 只收 history:source='current' 是「确认破纪录」的产物,客户端造出来的 current
// 会把一条没人批准过的成绩伪装成本届刚破的纪录,还会被后续批准当成上一条纪录恢复。
if in.Source != "" && in.Source != recordSourceHistory {
return recordEntryDTO{}, badRequest("source 只能是 history", in.Source)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordEntryDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var exists int
err = tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, in.EventID).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return recordEntryDTO{}, badRequest("eventId 不存在", in.EventID)
}
if err != nil {
return recordEntryDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
in.EventID, in.Level); err != nil {
return recordEntryDTO{}, internal(err)
}
res, err := tx.ExecContext(ctx,
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id,
achieved_at, is_current, source)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'history')`,
in.EventID, in.Level, nullF64(in.ResultValue), nullStr(in.ResultText),
nullStr(in.HolderName), nullI64(in.HolderTeamID), nullStr(in.AchievedAt))
if isForeignKeyViolation(err) {
return recordEntryDTO{}, badRequest("holderTeamId 不存在", in.HolderTeamID)
}
if err != nil {
return recordEntryDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return recordEntryDTO{}, internal(err)
}
// 纪录库进了实时快照(realtime.snapshot 读 records),不写变更大屏上的纪录页不会刷新。
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordEntryDTO{}, err
}
if err := tx.Commit(); err != nil {
return recordEntryDTO{}, internal(err)
}
created, err := s.getRecordEntry(ctx, id)
if err != nil {
return recordEntryDTO{}, err
}
s.audit(ctx, "record.create", "record", id, nil, created)
return created, nil
}
// deleteRecordEntry 删除一条纪录;删掉的若是当前纪录,恢复同 (小项, 档位) 上一条为当前,
// 一条都没有就把该档位留空(与 revertCurrentRecord 同一口径)。
func (s *server) deleteRecordEntry(ctx context.Context, id, userID int64) error {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
eventID int64
level string
isCurrent bool
)
if err := tx.QueryRowContext(ctx,
`SELECT event_id, level, is_current FROM records WHERE id = ?`, id).
Scan(&eventID, &level, &isCurrent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("纪录不存在")
}
return internal(err)
}
before, err := scanRecordEntry(tx.QueryRowContext(ctx,
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
if err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id); err != nil {
return internal(err)
}
if isCurrent {
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 1
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ?
ORDER BY id DESC LIMIT 1)`,
eventID, level); err != nil {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "record", id, "void", map[string]any{"id": id}, userID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
s.audit(ctx, "record.delete", "record", id, before, nil)
return nil
}
// ---------- adjustments ---------- // ---------- adjustments ----------
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) { func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
@@ -0,0 +1,179 @@
package httpapi_test
import (
"fmt"
"net/http"
"reflect"
"testing"
)
// resultIDOf 取某条报名项当前成绩行的 id。
func resultIDOf(t *testing.T, env *testEnv, entryID int64) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, entryID).Scan(&id); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
return id
}
// 作废 → 重新录入 → 改 → 重新发布:名次、流水与榜单必须与「一开始就填对」逐字节一致(A16)。
//
// 这是主人「手滑填错」场景的验收:填错不是删数据,改完重新发布后,榜上不该留下任何
// 「曾经填错」的痕迹(旧流水要作废、名次要让位、别人的名次要跟着回退)。
func TestReopenResultMatchesCorrectionFromScratch(t *testing.T) {
// ① 现场路径:先按填错的值发布 → 作废这一条 → 重新录入 → 改成正确值 → 重新发布本组。
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "typo",
map[string]any{"entryId": ids.entryA, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
// 填错的是 A 的成绩(11.50 填成了 11.92),改对后 A 该是第一。
// 前置:填错时 B 是第一,A 只有第 2 名。
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); got[ids.entryB] != 1 || got[ids.entryA] != 2 {
t.Fatalf("前置名次不对: %v", got)
}
resultA := resultIDOf(t, env, ids.entryA)
if status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/void", resultA),
map[string]any{"reason": "成绩填错", "requestId": "typo-void"}, true); status != http.StatusOK {
t.Fatalf("作废应 200,得到 %d %+v", status, env2.Error)
}
// 重新录入要推一条 result 变更(大屏据此重拉成绩),change_log.action 只有 upsert/void/replace。
upsertsBefore := changeLogCount(t, env, "result", "upsert")
standingsBefore := changeLogCount(t, env, "standings", "replace")
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "成绩填错,重新录入", "requestId": "typo-reopen"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重新录入应 200,得到 %d %+v", status, env2.Error)
}
reopened := decodeData[map[string]any](t, env2)
if reopened["state"] != "draft" || reopened["status"] != "ok" {
t.Fatalf("重新录入后应是草稿且状态复位为 ok: %+v", reopened)
}
if reopened["rank"] != nil {
t.Fatalf("重新录入后名次应被清空: %+v", reopened)
}
if reopened["note"] != "成绩填错,重新录入" {
t.Fatalf("重新录入理由应写进 note: %+v", reopened)
}
if n := changeLogCount(t, env, "result", "upsert") - upsertsBefore; n != 1 {
t.Fatalf("重新录入应写 1 条 result 变更,得到 %d", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'result.reopen'`); n != 1 {
t.Fatalf("重新录入应写 1 条审计,得到 %d", n)
}
// 退回草稿后这一条立刻不再计分(回滚类动作无条件重算并推榜,与作废同口径):
// 少了这一步,榜上就还挂着一条「已经退回草稿」的成绩。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 0 {
t.Fatalf("退回草稿后该条不该再有有效流水,得到 %d 条", n)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("退回草稿后单位总分应清零,得到 %v", got)
}
if n := changeLogCount(t, env, "standings", "replace") - standingsBefore; n < 1 {
t.Fatalf("重新录入必须推榜(standings 变更),得到 %d 条", n)
}
// 退回草稿的意义就在这里:这一条现在真的能改(已发布行会被 saveDraft 拒绝)。
publishResults(t, env, client, ids.event, "fixed",
map[string]any{"entryId": ids.entryA, "resultValue": 11.50, "resultText": "11.50", "unit": "s", "status": "ok"},
)
// ② 对照路径:同样两条成绩,一开始就填对(全新环境,报名项 / 单位 id 与上面逐一对应)。
fresh, freshIDs, freshClient := recorderWithSeed(t)
publishResults(t, fresh, freshClient, freshIDs.event, "correct",
map[string]any{"entryId": freshIDs.entryA, "resultValue": 11.50, "resultText": "11.50", "unit": "s", "status": "ok"},
map[string]any{"entryId": freshIDs.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
entries := []int64{ids.entryA, ids.entryB}
if got, want := ranksOf(t, env, entries), ranksOf(t, fresh, entries); !reflect.DeepEqual(got, want) {
t.Fatalf("改错后重新发布的名次应与一开始就填对一致:%v vs %v", got, want)
}
if got, want := ledgerRowsOf(t, env, ids.event, nil), ledgerRowsOf(t, fresh, freshIDs.event, nil); !reflect.DeepEqual(got, want) {
t.Fatalf("改错后重新发布的流水应与一开始就填对一致:\n%v\n%v", got, want)
}
if got, want := rawData(t, env, client, "/api/standings"), rawData(t, fresh, freshClient, "/api/standings"); got != want {
t.Fatalf("改错后重新发布的榜单应与一开始就填对一致:\n%s\n%s", got, want)
}
}
// 直接对一条「已发布」的成绩重新录入(不经作废):它必须立刻从榜上消失。
//
// 现场更常见的路径是先作废再重新录入,但接口本身允许直接重录(只有草稿才拒绝),
// 少了「无条件重算 + 推榜」,这一条就会一边是草稿、一边还在榜上计分。
func TestReopenPublishedResultDropsItFromBoard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "published-then-reopen",
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 got := teamPoints(t, env, client, ids.teamA); got == 0 {
t.Fatalf("前置:A 队应有名次分,得到 %v", got)
}
resultA := resultIDOf(t, env, ids.entryA)
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "要改成绩", "requestId": "reopen-published"}, 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 entry_id = ? AND voided = 0`, ids.entryA); n != 0 {
t.Fatalf("退回草稿后不该还挂着旧流水,得到 %d 条", n)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("退回草稿后 A 队总分应清零,得到 %v", got)
}
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
t.Fatalf("退回草稿后名次应让出来,得到 %v", got)
}
// 让出来的名次立刻归 B。
if got := ranksOf(t, env, []int64{ids.entryB}); got[ids.entryB] != 1 {
t.Fatalf("A 让出的第 1 名应归 B,得到 %v", got)
}
}
// 重新录入的校验:理由必填、非草稿才能退回、登记不存在 404;已是草稿时不许再退(否则会清掉录入员正在改的内容)。
func TestReopenResultValidation(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "reopen-check",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
resultA := resultIDOf(t, env, ids.entryA)
// 理由必填,措辞与作废一致。
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("空理由应 400,得到 %d %+v", status, env2.Error)
}
// 登记不存在。
if status, env2 = env.do(client, http.MethodPost, "/api/results/999999/reopen",
map[string]any{"reason": "不存在"}, true); status != http.StatusNotFound {
t.Fatalf("成绩不存在应 404,得到 %d %+v", status, env2.Error)
}
// 已是草稿:再退一次只会把录入员正在填的内容清空,必须拒绝。
if status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "第一次", "requestId": "reopen-1"}, true); status != http.StatusOK {
t.Fatalf("第一次重新录入应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "第二次", "requestId": "reopen-2"}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "该成绩已是草稿,可直接修改" {
t.Fatalf("草稿再退回应 400「该成绩已是草稿,可直接修改」,得到 %d %+v", status, env2.Error)
}
// guest(未登录)不能重新录入。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "guest"}, false); status != http.StatusUnauthorized {
t.Fatalf("未登录重新录入应 401,得到 %d", status)
}
}
+94 -26
View File
@@ -942,37 +942,28 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
return s.resultsByEvent(ctx, in.EventID) return s.resultsByEvent(ctx, in.EventID)
} }
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) { // loadResultForAction 读一条成绩及其所属 (小项, 组别),供「作废 / 重新录入」这类单条成绩
if reason == "" { // 动作使用:两者要读的字段完全一样,各自写一遍扫描迟早会漂移。
return resultDTO{}, badRequest("reason 必填", nil) func loadResultForAction(ctx context.Context, q queryer, resultID int64) (resultDTO, int64, int64, error) {
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
before resultDTO
eventID int64
catID int64
)
row := tx.QueryRowContext(ctx,
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID)
var ( var (
before resultDTO
eventID, catID int64
rank, recordedBy, verifiedBy sql.NullInt64 rank, recordedBy, verifiedBy sql.NullInt64
text, unit, recordedAt, verifiedAt, note sql.NullString text, unit, recordedAt, verifiedAt, note sql.NullString
value, override sql.NullFloat64 value, override sql.NullFloat64
) )
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie, err := q.QueryRowContext(ctx,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID); err != nil { `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
if errors.Is(err, sql.ErrNoRows) { r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
return resultDTO{}, notFound("成绩不存在") e.category_id
} FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID).
return resultDTO{}, internal(err) Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID)
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, 0, 0, notFound("成绩不存在")
}
if err != nil {
return resultDTO{}, 0, 0, internal(err)
} }
before.EventID = eventID before.EventID = eventID
before.Rank = intPtr(rank) before.Rank = intPtr(rank)
@@ -985,6 +976,23 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
before.VerifiedBy = i64Ptr(verifiedBy) before.VerifiedBy = i64Ptr(verifiedBy)
before.VerifiedAt = strPtr(verifiedAt) before.VerifiedAt = strPtr(verifiedAt)
before.Note = strPtr(note) before.Note = strPtr(note)
return before, eventID, catID, nil
}
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
if reason == "" {
return resultDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, eventID, catID, err := loadResultForAction(ctx, tx, resultID)
if err != nil {
return resultDTO{}, err
}
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`, `UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`,
@@ -1032,6 +1040,66 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
return s.getResult(ctx, resultID) return s.getResult(ctx, resultID)
} }
// reopenResult 把一条成绩退回草稿(A16)。现场「手滑填错」时,「作废本条」只把它从榜上撤掉,
// 录入员仍然改不了它(作废行不是草稿,saveDraft 只认草稿);本接口把状态退回 draft 并复位成
// 一条干净的成绩,录入员直接改、再重新发布本组即可——不需要「撤销发布」整组把别人的成绩也翻下来。
//
// 重算口径与作废完全一致:这是回滚类动作(改的是「这条成绩算不算数」),无条件重算该
// (小项, 组别) 并推榜,不受 standings.live_on_publish 与延迟结算约束。否则退回草稿后
// 旧名次与旧流水还留在榜上,等于这条成绩「既不算数又还在计分」。
func (s *server) reopenResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
if reason == "" {
return resultDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, eventID, catID, err := loadResultForAction(ctx, tx, resultID)
if err != nil {
return resultDTO{}, err
}
// 已经是草稿就没有「退回」可言,硬做只会把录入员正在改的内容清掉(下面会复位 status / rank)。
if before.State == string(domain.ResultStateDraft) {
return resultDTO{}, badRequest("该成绩已是草稿,可直接修改", nil)
}
// status 复位成 ok:作废写下的 withdrawn 只是「这条不算数」的标记,退回草稿后它就是一条
// 待重新录入的空成绩,留着 withdrawn 会让录入页按异常状态显示。
if _, err := tx.ExecContext(ctx,
`UPDATE results SET state = 'draft', status = 'ok', rank = NULL, note = ?, updated_at = datetime('now')
WHERE id = ?`, reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "重新录入:"+reason)
if err != nil {
return resultDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
}
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
if err != nil {
return resultDTO{}, internal(err)
}
// change_log.action 的 CHECK 只认 upsert / void / replace(0009),退回草稿是这一行的
// 状态更新,所以走 upsert;「重新录入」这个语义记在审计(result.reopen)里。
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "upsert", userID); err != nil {
return resultDTO{}, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return resultDTO{}, err
}
if err := tx.Commit(); err != nil {
return resultDTO{}, internal(err)
}
s.audit(ctx, "result.reopen", "result", resultID, before, after)
return s.getResult(ctx, resultID)
}
// ---------- 分批结算 ---------- // ---------- 分批结算 ----------
// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。 // settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
+11
View File
@@ -264,6 +264,9 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler)) 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/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))
// 「重新录入」把成绩退回草稿让录入员直接改(A16)。与作废同权限、同重算口径:
// 它是回滚类动作,不受 standings.live_on_publish 约束。
mux.HandleFunc("POST /api/results/{id}/reopen", s.write(auth.RoleRecorder, s.reopenResultHandler))
// 积分与排名 // 积分与排名
mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler)) mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler))
@@ -275,8 +278,16 @@ func (s *server) routes(mux *http.ServeMux) {
// 纪录 // 纪录
mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler)) mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler))
// 纪录库的写入路径(C11):手工登记 / 删除历史纪录。这不是破纪录登记,
// 所以两者分开:/api/records 只碰纪录库本身,source 只收 history。
mux.HandleFunc("POST /api/records", s.write(auth.RoleRecorder, s.createRecordHandler))
mux.HandleFunc("DELETE /api/records/{id}", s.writeVoid(auth.RoleRecorder, s.deleteRecordHandler))
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler)) mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
// 审核清单:guest 不可读(含未审核的登记与驳回理由),recorder 起可读。
mux.HandleFunc("GET /api/record-breaks", s.read(auth.RoleRecorder, s.listRecordBreaksHandler))
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler)) mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
// 驳回 / 作废(A41):已批准的登记被驳回时要撤掉加分、回退榜单与纪录库,见 rejectRecordBreak。
mux.HandleFunc("POST /api/record-breaks/{id}/reject", s.write(auth.RoleRecorder, s.rejectRecordBreakHandler))
// 大屏控制与运维 // 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler)) mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
+11
View File
@@ -38,6 +38,14 @@ func newTestEnv(t *testing.T) *testEnv {
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。 // newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv { func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
t.Helper()
return newTestEnvPrepared(t, nil, customize)
}
// newTestEnvPrepared 在赛事库迁移之前给测试一次插手的机会(prepare),
// 用于构造「旧版赛事包」现场:先把库停在某条迁移之前、塞进老数据,再走 store.MigrateEvent
// 的真实升级路径——与 main.go 打开旧包后补跑迁移的顺序完全一致。
func newTestEnvPrepared(t *testing.T, prepare func(ev *sql.DB), customize func(*httpapi.Options)) *testEnv {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
@@ -60,6 +68,9 @@ func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
} }
ev.SetMaxOpenConns(1) ev.SetMaxOpenConns(1)
t.Cleanup(func() { _ = ev.Close() }) t.Cleanup(func() { _ = ev.Close() })
if prepare != nil {
prepare(ev)
}
if err := store.MigrateEvent(ev); err != nil { if err := store.MigrateEvent(ev); err != nil {
t.Fatalf("迁移赛事库: %v", err) t.Fatalf("迁移赛事库: %v", err)
} }
@@ -0,0 +1,9 @@
-- 0021:给 record_breaks 补一列 note(驳回 / 作废这条登记的理由)。
--
-- 只加一列(ALTER TABLE ... ADD COLUMN),不重建表、不改既有数据行:
-- 旧的赛事项目包(含旧 event.db)导入后由 main 的 store.MigrateEvent 补跑本迁移,
-- 重建表会丢掉老库上的行与自增序列,NOT NULL / 默认值还会把「本来就没有理由」的
-- 历史登记写成一条假理由。列可空,历史行取 NULL,这才是事实(DTO 里是 *string)。
--
-- 不加索引:note 只在读登记时随行取出,没有任何按它过滤或排序的查询。
ALTER TABLE record_breaks ADD COLUMN note TEXT;
@@ -0,0 +1,117 @@
package event
import (
"database/sql"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0021 只给 record_breaks 补一列 note(可空):老库升级后数据一行不丢、老登记取 NULL、
// 新列可写可清,且迁移只登记一次(ALTER TABLE ADD COLUMN 重放会报 duplicate column)。
//
// 这一列存在的意义是「驳回 / 作废这条登记的理由」:老库里没有理由,NULL 才是事实,
// 所以它不能有 NOT NULL、也不能有默认值(默认值会把历史行写成一条假理由)。
func TestMigration0021AddsRecordBreakNote(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0021")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, record_eligible, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, status, state)
VALUES (1, 1, 1, '11.58', 11.58, 'ok', 'published')`,
`INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source)
VALUES (1, 'meet', 11.20, '11.20', '李四', 1, 'history')`,
`INSERT INTO record_breaks(event_id, entry_id, level, result_value, result_text, bonus_points, state)
VALUES (1, 1, 'meet', 11.58, '11.58', 2, 'approved')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 升级前不该有 note 列。
var probe sql.NullString
if err := db.QueryRow(`SELECT note FROM record_breaks`).Scan(&probe); err == nil {
t.Fatal("升级前不该有 record_breaks.note 列")
}
// 走真实升级路径(main.go 导入旧赛事包后补跑的就是它);ADD COLUMN 只登记一次。
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
for table, want := range map[string]int{"records": 1, "record_breaks": 1, "results": 1, "entries": 1} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 老登记的状态与加分原样保留,note 为 NULL(没有理由,不是空串理由)。
var (
state string
bonus float64
note sql.NullString
text string
level string
heldBy string
)
if err := db.QueryRow(`SELECT state, bonus_points, note FROM record_breaks WHERE id = 1`).
Scan(&state, &bonus, &note); err != nil {
t.Fatalf("读破纪录登记: %v", err)
}
if state != "approved" || bonus != 2 {
t.Errorf("老登记被改动: state=%q bonus=%v", state, bonus)
}
if note.Valid {
t.Errorf("老登记的 note 应为 NULL(历史行没有理由),得到 %q", note.String)
}
if err := db.QueryRow(`SELECT level, COALESCE(result_text, ''), COALESCE(holder_name, '') FROM records WHERE id = 1`).
Scan(&level, &text, &heldBy); err != nil {
t.Fatalf("读纪录库: %v", err)
}
if level != "meet" || text != "11.20" || heldBy != "李四" {
t.Errorf("老纪录被改动: level=%q text=%q holder=%q", level, text, heldBy)
}
// 新列可用:写理由 → 清理由;状态机照旧接受 rejected。
if _, err := db.Exec(`UPDATE record_breaks SET state = 'rejected', note = '录像复核未破' WHERE id = 1`); err != nil {
t.Fatalf("驳回老登记: %v", err)
}
if err := db.QueryRow(`SELECT note FROM record_breaks WHERE id = 1`).Scan(&note); err != nil {
t.Fatalf("读 note: %v", err)
}
if !note.Valid || note.String != "录像复核未破" {
t.Errorf("写入的驳回理由未生效: %+v", note)
}
if _, err := db.Exec(`UPDATE record_breaks SET state = 'approved', note = NULL WHERE id = 1`); err != nil {
t.Fatalf("重新批准并清理由: %v", err)
}
// 新登记不写 note 也能插(列可空)。
if _, err := db.Exec(`INSERT INTO record_breaks(event_id, entry_id, level, state) VALUES (1, 1, 'city', 'pending')`); err != nil {
t.Fatalf("插入未带 note 的新登记: %v", err)
}
// 非法状态仍被 CHECK 挡住(迁移没有动过状态机)。
if _, err := db.Exec(`UPDATE record_breaks SET state = 'bogus' WHERE id = 1`); err == nil {
t.Error("state='bogus' 应被 CHECK 拒绝")
}
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("升级后存在外键悬挂")
}
}
@@ -10,7 +10,7 @@ import (
// C12:0001–0011 的老库一次性升到最新。 // C12:0001–0011 的老库一次性升到最新。
// //
// 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳; // 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳;
// 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0020 九条, // 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0021 十条,
// 其中 0016 会就地改写 events 的建表语句、0017 会重建 score_ledger——这些步骤正是 // 其中 0016 会就地改写 events 的建表语句、0017 会重建 score_ledger——这些步骤正是
// 「只在老库上才走得到」的路径。这条用例把这条路整条走一遍: // 「只在老库上才走得到」的路径。这条用例把这条路整条走一遍:
// 数据一行不少、新列取文档写明的默认值、新表建出来、升级后新能力可用、再跑一次也不报错。 // 数据一行不少、新列取文档写明的默认值、新表建出来、升级后新能力可用、再跑一次也不报错。
@@ -72,7 +72,7 @@ func TestMigration0001ToLatestKeepsData(t *testing.T) {
} }
} }
// 走真实升级路径:一次补跑 0012~0020 全部九条。 // 走真实升级路径:一次补跑 0012~0021 全部十条(含只加一列的 0018/0019/0021)。
if err := store.MigrateEvent(db); err != nil { if err := store.MigrateEvent(db); err != nil {
t.Fatalf("从 0001–0011 直接升到最新失败: %v", err) t.Fatalf("从 0001–0011 直接升到最新失败: %v", err)
} }
@@ -213,6 +213,15 @@ func TestMigration0001ToLatestKeepsData(t *testing.T) {
if countToMedals != 1 || countToTeamMedals != 1 { if countToMedals != 1 || countToTeamMedals != 1 {
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals) t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
} }
// 0021 的 note 是老库上不存在的列,老登记(这条是 approved 的破纪录)升级后必须是 NULL:
// 历史行没有驳回理由,NULL 才是事实,写成空串或默认文案都会让审核清单说谎。
var breakNote sql.NullString
if err := db.QueryRow(`SELECT note FROM record_breaks WHERE id = 1`).Scan(&breakNote); err != nil {
t.Fatalf("读破纪录登记的 note: %v", err)
}
if breakNote.Valid {
t.Errorf("老破纪录登记的 note 应为 NULL,得到 %q", breakNote.String)
}
// ④ 新表建出来了,且是空的(升级不该凭空造名次)。 // ④ 新表建出来了,且是空的(升级不该凭空造名次)。
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} { for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
@@ -238,7 +247,7 @@ func TestMigration0001ToLatestKeepsData(t *testing.T) {
} }
defer func() { _ = fkRows.Close() }() defer func() { _ = fkRows.Close() }()
if fkRows.Next() { if fkRows.Next() {
t.Error("跨九条迁移升级后存在外键悬挂") t.Error("跨十条迁移升级后存在外键悬挂")
} }
var fkEnabled int var fkEnabled int
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fkEnabled); err != nil { if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fkEnabled); err != nil {
@@ -267,6 +276,13 @@ func TestMigration0001ToLatestKeepsData(t *testing.T) {
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil { VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil {
t.Fatalf("写 0020 新建的名次表: %v", err) t.Fatalf("写 0020 新建的名次表: %v", err)
} }
// 0021 的 note 也要能写能清:列建了却写不进去,等于没升。
if _, err := db.Exec(`UPDATE record_breaks SET state = 'rejected', note = '录像复核未破' WHERE id = 1`); err != nil {
t.Fatalf("写 0021 的驳回理由: %v", err)
}
if _, err := db.Exec(`UPDATE record_breaks SET state = 'approved', note = NULL WHERE id = 1`); err != nil {
t.Fatalf("清 0021 的驳回理由: %v", err)
}
// 新 CHECK 也在:合法的拒绝了非法值才算真的生效。 // 新 CHECK 也在:合法的拒绝了非法值才算真的生效。
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil { if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil {
t.Error("升级后非法 event_type 应被 CHECK 拒绝") t.Error("升级后非法 event_type 应被 CHECK 拒绝")
+47
View File
@@ -17,7 +17,11 @@ import type {
PageQuery, PageQuery,
PointAdjustment, PointAdjustment,
PublishPayload, PublishPayload,
RecordBreak,
RecordBreakState,
RecordEntry, RecordEntry,
RecordEntryInput,
ReopenResultPayload,
Result, Result,
SaveDraftPayload, SaveDraftPayload,
ScoringProfile, ScoringProfile,
@@ -27,6 +31,7 @@ import type {
TeamBreakdown, TeamBreakdown,
TeamRanksInput, TeamRanksInput,
TeamRanksView, TeamRanksView,
VoidResultPayload,
} from '@/shared' } from '@/shared'
import { mockDb } from './mock' import { mockDb } from './mock'
@@ -219,6 +224,18 @@ export const adminApi = {
unpublish: (payload: PublishPayload): Promise<Result[]> => unpublish: (payload: PublishPayload): Promise<Result[]> =>
callWrite(() => client.results.unpublish(payload)), callWrite(() => client.results.unpublish(payload)),
/**
* 作废一条已发布的成绩(外科手术式,影响面远小于整组撤销发布)。
*
* 写操作不降级:示例数据模式下没有后端可落库,页面会先用 data.isMock 拦下。
*/
voidResult: (id: number, payload: VoidResultPayload): Promise<Result> =>
callWrite(() => client.results.void(id, payload)),
/** 把一条已作废的成绩退回草稿,改完再发布本组;同样是不降级的写操作。 */
reopenResult: (id: number, payload: ReopenResultPayload): Promise<Result> =>
callWrite(() => client.results.reopen(id, payload)),
standings: (): Promise<StandingRow[]> => standings: (): Promise<StandingRow[]> =>
call(() => client.standings.teams(), () => mockDb.standings()), call(() => client.standings.teams(), () => mockDb.standings()),
@@ -244,6 +261,36 @@ export const adminApi = {
records: (): Promise<RecordEntry[]> => records: (): Promise<RecordEntry[]> =>
call(() => client.records.list(), () => mockDb.records()), call(() => client.records.list(), () => mockDb.records()),
/**
* 破纪录登记列表(GET /api/record-breaks?state=...)。
*
* 刻意**不走示例数据降级**:示例数据里根本没有登记表,回一个空数组会让页面把
* 「读不到」显示成「没有已批准的破纪录」。读不到时原样抛出,页面据此显示空态说明。
*/
recordBreaks: (state: RecordBreakState): Promise<RecordBreak[]> =>
client.records.listBreaks(state),
/** 驳回 / 作废一条登记;原本 approved 的会连加分一起撤掉(服务端行为)。 */
rejectBreak: (id: number, reason: string): Promise<RecordBreak> =>
callWrite(() => client.records.rejectBreak(id, reason)),
/** 登记一条历史纪录;服务端固定 source = history。 */
createRecord: (input: RecordEntryInput): Promise<RecordEntry> =>
callWrite(() => client.records.createRecord(input)),
/** 删除一条纪录;删的若是当前纪录,服务端恢复上一条。 */
removeRecord: (id: number): Promise<void> => callWrite(() => client.records.removeRecord(id)),
/**
* 「草稿待发布数」:分页只取 1 条,要的是 Page.total 这个总数。
*
* 同样不降级:读不到时页面显示 `—`,不用 0 冒充「一条草稿都没有」。
*/
draftResultCount: async (): Promise<number> => {
const page = await client.results.list({ state: 'draft', page: 1, size: 1 })
return page.total
},
adjustments: (query: PageQuery = {}): Promise<Page<PointAdjustment>> => adjustments: (query: PageQuery = {}): Promise<Page<PointAdjustment>> =>
call(() => client.adjustments.list(query), () => mockDb.adjustments()), call(() => client.adjustments.list(query), () => mockDb.adjustments()),
+70 -3
View File
@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import MxIcon from './MxIcon.vue' import MxIcon from './MxIcon.vue'
/** /**
* 危险操作二次确认(07 第 7 节)。 * 危险操作二次确认(07 第 7 节)。
* 只负责「确认」这一件事,loading 态由调用方通过 `pending` 传入,避免弹窗自己管请求。 * 只负责「确认」这一件事,loading 态由调用方通过 `pending` 传入,避免弹窗自己管请求。
*
* `reasonRequired` 打开时,弹窗里多出一个必填原因输入框(作废成绩 / 退回草稿 / 作废破纪录登记),
* 原因随 confirm 事件一起 emit 给调用方,由调用方写进请求体(服务端再写审计)。
* 原因留空时**不 emit confirm**:这类操作都改榜单,宁可多拦一次,也不能带着空原因落库。
*/ */
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -16,6 +20,10 @@ const props = withDefaults(
cancelText?: string cancelText?: string
tone?: 'info' | 'warning' | 'danger' tone?: 'info' | 'warning' | 'danger'
pending?: boolean pending?: boolean
/** true 时要求填写原因,并在 confirm 事件里带出。 */
reasonRequired?: boolean
reasonLabel?: string
reasonPlaceholder?: string
}>(), }>(),
{ {
details: () => [], details: () => [],
@@ -23,10 +31,29 @@ const props = withDefaults(
cancelText: '取消', cancelText: '取消',
tone: 'warning', tone: 'warning',
pending: false, pending: false,
reasonRequired: false,
reasonLabel: '原因',
reasonPlaceholder: '必填,会写入审计日志',
}, },
) )
const emit = defineEmits<{ confirm: []; cancel: [] }>() const emit = defineEmits<{ confirm: [reason: string]; cancel: [] }>()
/** 原因输入内容;每次弹窗打开都清空,避免把上一次的原因带进这次操作。 */
const reason = ref('')
/** 只有点过确认键(或按键提交)之后才亮红字,避免刚打开就一片报错。 */
const reasonTouched = ref(false)
const reasonMissing = computed(() => props.reasonRequired && reason.value.trim() === '')
watch(
() => props.open,
(open) => {
if (!open) return
reason.value = ''
reasonTouched.value = false
},
)
/** /**
* 关闭弹窗的唯一出口:ESC、遮罩、右上角关闭键都走这里。 * 关闭弹窗的唯一出口:ESC、遮罩、右上角关闭键都走这里。
@@ -40,6 +67,16 @@ function requestCancel(): void {
emit('cancel') emit('cancel')
} }
/** 确认键:先过原因必填,再 emit。 */
function requestConfirm(): void {
if (props.pending) return
if (reasonMissing.value) {
reasonTouched.value = true
return
}
emit('confirm', reason.value.trim())
}
function onKeydown(event: KeyboardEvent): void { function onKeydown(event: KeyboardEvent): void {
if (!props.open) return if (!props.open) return
if (event.key === 'Escape') requestCancel() if (event.key === 'Escape') requestCancel()
@@ -66,6 +103,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<ul v-if="details.length > 0" class="confirm-details"> <ul v-if="details.length > 0" class="confirm-details">
<li v-for="(line, index) in details" :key="index">{{ line }}</li> <li v-for="(line, index) in details" :key="index">{{ line }}</li>
</ul> </ul>
<!--
原因输入:pending 时用 readonly 而不是 disabled——请求已经在服务端跑了,
这时候改文字不会被提交(弹窗本身也锁死了,关不掉),但不该把已填的内容抹成灰。
-->
<label v-if="reasonRequired" class="confirm-reason">
<span class="confirm-reason-label">{{ reasonLabel }}</span>
<textarea
v-model="reason"
class="mx-textarea"
rows="3"
:readonly="pending"
:placeholder="reasonPlaceholder"
></textarea>
<span v-if="reasonTouched && reasonMissing" class="confirm-reason-error">原因必填</span>
</label>
</div> </div>
<div class="mx-modal-foot"> <div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="requestCancel"> <button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="requestCancel">
@@ -76,7 +128,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
:class="tone === 'danger' ? 'mx-btn-danger' : 'mx-btn-primary'" :class="tone === 'danger' ? 'mx-btn-danger' : 'mx-btn-primary'"
type="button" type="button"
:disabled="pending" :disabled="pending"
@click="emit('confirm')" @click="requestConfirm"
> >
{{ pending ? '处理中…' : confirmText }} {{ pending ? '处理中…' : confirmText }}
</button> </button>
@@ -105,4 +157,19 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
content: '·'; content: '·';
color: var(--mx-primary); color: var(--mx-primary);
} }
.confirm-reason {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.confirm-reason-label {
font-size: 12px;
font-weight: 600;
color: var(--mx-t2);
}
.confirm-reason-error {
font-size: 12px;
color: var(--mx-red);
}
</style> </style>
+145 -5
View File
@@ -2,9 +2,18 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { formatPoints, type RecordEntry } from '@/shared' import { formatPoints, type RecordEntry } from '@/shared'
import type { DisplayInstance } from '@/shared/api'
import MxIcon from '../components/MxIcon.vue' import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue' import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client' import { adminApi } from '../api/client'
import {
adminLinkLabel,
countText,
displayCountText,
displayCounts,
scheduleProgress,
todayEvents,
} from './dashboard'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event' import { useEventStore } from '../stores/event'
@@ -17,6 +26,38 @@ const pendingEvents = computed(() => data.events.filter((event) => event.status
const topTeams = computed(() => data.standings.slice(0, 3)) const topTeams = computed(() => data.standings.slice(0, 3))
const leader = computed(() => data.standings[0] ?? null) const leader = computed(() => data.standings[0] ?? null)
/** 今天的小项与进度(devdocs/07 §7「当前赛程」卡片)。 */
const today = computed(() => todayEvents(data.events))
const todayProgress = computed(() => scheduleProgress(today.value))
const todayIds = computed(() => new Set(today.value.map((event) => event.id)))
/**
* 进度说明。
*
* 项目列表还没加载完时不能显示「今天没有排期」——那会把「还没读到」说成一条结论;
* 加载完成且确实没有排期时才是真的空。
*/
const todayHint = computed(() => {
if (!data.loaded) return '数据加载中…'
return todayProgress.value.text ?? '今天没有排期'
})
/**
* 待办:草稿待发布数、疑似破纪录待确认数。
*
* 两个数都来自各自的接口,读不到时为 null → 显示 `—`:卡片不能把「读不到」说成「0 条」。
*/
const draftCount = ref<number | null>(null)
const pendingBreakCount = ref<number | null>(null)
/**
* 大屏实例:`null` 表示数据源不可用(示例数据 / 接口失败)。
*
* adminApi.displayInstances 在连不上服务端时会退化成空数组,单看它分不清
* 「没有大屏」与「读不到」;示例数据模式(isMock)一律按读不到处理,显示 `—`。
*/
const displayInstances = ref<DisplayInstance[] | null>(null)
const displayTotal = computed(() => displayCountText(displayCounts(displayInstances.value)))
const connectionText = computed(() => { const connectionText = computed(() => {
if (data.isMock) return '示例数据' if (data.isMock) return '示例数据'
if (data.realtimeStatus === 'online') return '实时在线' if (data.realtimeStatus === 'online') return '实时在线'
@@ -24,12 +65,43 @@ const connectionText = computed(() => {
return '连接中断' return '连接中断'
}) })
onMounted(async () => { /** 管理端连接文案:没有「管理端在线数」接口,如实显示本机连接状态。 */
const adminLink = computed(() => adminLinkLabel(data.isMock, data.realtimeStatus))
/** 待确认数读不到时的说明:访客是权限问题,其余按接口不可用说。 */
const pendingBreakHint = computed(() => {
if (pendingBreakCount.value === null) return auth.readOnly ? '访客只读' : '接口不可用'
return pendingBreakCount.value > 0 ? '去「破纪录」页确认' : '没有待确认'
})
async function loadCards(): Promise<void> {
try { try {
records.value = await adminApi.records() records.value = await adminApi.records()
} catch { } catch {
records.value = [] records.value = []
} }
// 每个数据源各自兜底:任何一个接口挂了都只让自己的卡片显示 —,不让整页崩。
try {
draftCount.value = await adminApi.draftResultCount()
} catch {
draftCount.value = null
}
try {
// GET /api/record-breaks 要求 recorder 及以上:访客必然 403,不必发这一枪。
pendingBreakCount.value = auth.readOnly ? null : (await adminApi.recordBreaks('pending')).length
} catch {
pendingBreakCount.value = null
}
try {
// GET /api/display/instances 要求 recorder 及以上:访客不必发这一枪(403 没有更多信息)。
displayInstances.value = data.isMock || auth.readOnly ? null : await adminApi.displayInstances()
} catch {
displayInstances.value = null
}
}
onMounted(() => {
void loadCards()
}) })
</script> </script>
@@ -70,11 +142,47 @@ onMounted(async () => {
</div> </div>
</section> </section>
<!--
07 §7 承诺的另外三张卡片:今天的小项与进度、待办的两类数量、大屏在线数。
任何一个数据源读不到,只让那一张显示 —,不影响其它卡片。
-->
<section class="mx-stat-grid mx-section">
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="display" :size="14" /></span>今天的小项</div>
<div class="mx-stat-value">{{ !data.loaded || todayProgress.text === null ? '—' : todayProgress.total }}</div>
<span class="mx-stat-delta" :class="todayProgress.text === null ? '' : 'up'">
{{ todayHint }}
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="record" :size="14" /></span>草稿待发布</div>
<div class="mx-stat-value">{{ countText(draftCount) }}</div>
<span class="mx-stat-delta" :class="draftCount !== null && draftCount > 0 ? 'down' : 'up'">
{{ draftCount === null ? '接口不可用' : draftCount > 0 ? '已保存未发布' : '没有待发布草稿' }}
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="record" :size="14" /></span>待确认破纪录</div>
<div class="mx-stat-value">{{ countText(pendingBreakCount) }}</div>
<span class="mx-stat-delta" :class="pendingBreakCount !== null && pendingBreakCount > 0 ? 'down' : 'up'">
{{ pendingBreakHint }}
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="display" :size="14" /></span>大屏在线数</div>
<div class="mx-stat-value">{{ displayTotal }}</div>
<!-- 没有「管理端在线数」这种接口:这里显示本机管理端的连接状态,不编造数字。 -->
<span class="mx-stat-delta up">管理端:{{ adminLink }}</span>
</div>
</section>
<div class="dash-grid"> <div class="dash-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>
<span class="mx-badge mx-badge-neutral">{{ data.events.length }} 个小项</span> <span class="mx-badge mx-badge-neutral">
{{ data.events.length }} 个小项 · 今天 {{ todayProgress.total }} 个
</span>
</div> </div>
<div class="mx-card-body no-pad"> <div class="mx-card-body no-pad">
<div class="mx-table-wrap"> <div class="mx-table-wrap">
@@ -89,7 +197,10 @@ onMounted(async () => {
</thead> </thead>
<tbody> <tbody>
<tr v-for="event in data.events.slice(0, 8)" :key="event.id"> <tr v-for="event in data.events.slice(0, 8)" :key="event.id">
<td>{{ event.name }}</td> <td>
{{ event.name }}
<span v-if="todayIds.has(event.id)" class="mx-badge mx-badge-info today-tag">今天</span>
</td>
<td class="mono">{{ event.code }}</td> <td class="mono">{{ event.code }}</td>
<td> <td>
<span <span
@@ -137,9 +248,24 @@ onMounted(async () => {
<li v-if="topTeams.length === 0" class="top-empty">暂无已发布成绩</li> <li v-if="topTeams.length === 0" class="top-empty">暂无已发布成绩</li>
</ol> </ol>
<div v-if="pendingEvents.length > 0" class="todo"> <div v-if="pendingEvents.length > 0 || draftCount !== null || pendingBreakCount !== null" class="todo">
<div class="todo-title"><MxIcon name="warning" :size="14" /> 待办</div> <div class="todo-title"><MxIcon name="warning" :size="14" /> 待办</div>
<p> <ul class="todo-list">
<li>
草稿待发布:
<strong>{{ countText(draftCount) }}</strong>
{{ draftCount === null ? '(读不到,接口不可用)' : '条成绩草稿还没发布' }}
</li>
<li>
疑似破纪录待确认:
<strong>{{ countText(pendingBreakCount) }}</strong>
{{ pendingBreakCount === null ? '(读不到,接口不可用)' : '条' }}
<RouterLink v-if="pendingBreakCount !== null && pendingBreakCount > 0" class="mx-btn mx-btn-ghost mx-btn-sm" :to="{ name: 'records' }">
去确认
</RouterLink>
</li>
</ul>
<p v-if="pendingEvents.length > 0">
还有 {{ pendingEvents.length }} 个项目未录入: 还有 {{ pendingEvents.length }} 个项目未录入:
{{ pendingEvents.map((event) => event.name).join('、') }} {{ pendingEvents.map((event) => event.name).join('、') }}
</p> </p>
@@ -237,6 +363,20 @@ onMounted(async () => {
margin-bottom: 10px; margin-bottom: 10px;
line-height: 1.6; line-height: 1.6;
} }
.todo-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
line-height: 1.6;
}
.todo-list strong {
color: var(--mx-t1);
}
.today-tag {
margin-left: 6px;
font-size: 10px;
}
.foot-hint { .foot-hint {
margin-top: 20px; margin-top: 20px;
font-size: 12px; font-size: 12px;
+174 -4
View File
@@ -48,6 +48,15 @@ import {
type EntryBatchPlan, type EntryBatchPlan,
type EntryBatchRow, type EntryBatchRow,
} from './entryBatch' } from './entryBatch'
import {
RESULT_ACTION_CONFIRM,
RESULT_ACTION_SUCCESS,
RESULT_ROW_ACTION_LABEL,
RESULT_ROW_ACTION_TITLE,
RESULT_ROW_ACTION_WORD,
resultActionTarget,
type ResultActionTarget,
} from './entryResultActions'
import { import {
DEFAULT_HEAT_SIZE, DEFAULT_HEAT_SIZE,
MAX_HEAT, MAX_HEAT,
@@ -60,7 +69,7 @@ import {
type HeatAllocation, type HeatAllocation,
type HeatFilter, type HeatFilter,
} from './entryHeat' } from './entryHeat'
import { adminApi } from '../api/client' import { adminApi, sampleDataBlockedNotice } 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 { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
@@ -83,6 +92,8 @@ interface EditableRow {
heat: number | null heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */ /** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
entryStatus: EntryStatus entryStatus: EntryStatus
/** 成绩 id(服务端按它定位作废 / 退回;草稿行也有,未报名时才是 null)。 */
resultId: number | null
resultText: string resultText: string
rank: number | null rank: number | null
status: ResultStatus status: ResultStatus
@@ -453,6 +464,34 @@ function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published' return row.resultState === 'published'
} }
/**
* 每行的行操作(作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
*
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
*/
const rowActions = computed<Map<number, ResultActionTarget>>(() => {
const map = new Map<number, ResultActionTarget>()
for (const row of visibleRows.value) {
const target = resultActionTarget(row)
if (target !== null) map.set(row.entryId, target)
}
return map
})
function rowAction(row: EditableRow): ResultActionTarget | null {
return rowActions.value.get(row.entryId) ?? null
}
function rowActionLabel(row: EditableRow): string {
const target = rowAction(row)
return target === null ? '' : RESULT_ROW_ACTION_LABEL[target.action]
}
function rowActionTitle(row: EditableRow): string {
const target = rowAction(row)
return target === null ? '' : RESULT_ROW_ACTION_TITLE[target.action]
}
/** /**
* 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。 * 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。
* *
@@ -505,7 +544,12 @@ const focusedHint = computed<ResultCellHint | null>(() => {
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */ /** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
function resultTitle(row: EditableRow): string { function resultTitle(row: EditableRow): string {
if (rowLocked(row)) return '已发布,请先撤销发布' if (rowLocked(row)) {
// 已作废的行同样是「锁定」的,但原因是作废而不是发布:提示要指向真正的出路。
return row.status === 'withdrawn'
? '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
: '已发布,请先撤销发布'
}
const hint = resultHintOf(row) const hint = resultHintOf(row)
return hint !== null && hint.level !== 'ok' ? hint.message : '' return hint !== null && hint.level !== 'ok' ? hint.message : ''
} }
@@ -681,6 +725,7 @@ function toRow(entry: EntryWithResult): EditableRow {
memberNames: entry.memberNames, memberNames: entry.memberNames,
heat: entry.heat, heat: entry.heat,
entryStatus: entry.status, entryStatus: entry.status,
resultId: result?.id ?? null,
resultText: result?.resultText ?? '', resultText: result?.resultText ?? '',
rank: result?.rank ?? null, rank: result?.rank ?? null,
status: result?.status ?? 'ok', status: result?.status ?? 'ok',
@@ -1350,6 +1395,69 @@ const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
remove: '确认移出', remove: '确认移出',
} }
// ── 单条作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────────
/** 待确认的行操作;非 null 时确认框打开。 */
const resultAction = ref<ResultActionTarget | null>(null)
const resultActionBusy = ref(false)
/** 确认框文案随动作变(作废 / 退回草稿的后果不同)。 */
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
/**
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
*
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
* 否则刚填的名次会被服务端返回的口径覆盖掉。
*/
function requestResultAction(row: EditableRow): void {
const target = rowAction(row)
if (target === null) return
if (data.isMock) {
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
toast.error(notice.title, notice.detail)
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (dirty.value) {
toast.error('有未保存的修改', '作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
return
}
resultAction.value = target
}
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
async function confirmResultAction(reason: string): Promise<void> {
const target = resultAction.value
if (target === null || resultActionBusy.value) return
resultActionBusy.value = true
try {
if (target.action === 'void') {
await adminApi.voidResult(target.resultId, { reason })
} else {
await adminApi.reopenResult(target.resultId, { reason })
}
resultAction.value = null
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
await reloadAfterSettlementChange()
const success = RESULT_ACTION_SUCCESS[target.action]
toast.success(success.title, success.detail)
} catch (caught) {
toast.error(target.action === 'void' ? '作废失败' : '退回草稿失败', caught instanceof Error ? caught.message : null)
} finally {
resultActionBusy.value = false
}
}
function cancelResultAction(): void {
if (resultActionBusy.value) return
resultAction.value = null
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */ /** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
function requestSplit(): void { function requestSplit(): void {
const plan = splitPlan.value const plan = splitPlan.value
@@ -1810,6 +1918,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<th>状态</th> <th>状态</th>
<th class="center">积分(自动)</th> <th class="center">积分(自动)</th>
<th>备注</th> <th>备注</th>
<th class="center action-col">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1827,8 +1936,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" /> <input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" />
</td> </td>
<td class="center"> <td class="center">
<!--
已作废是独立状态:它仍是 published(所以输入框照样锁着),
但成绩不计入名次与积分,徽标必须先说清这件事,别只显示「已发布」。
-->
<span <span
v-if="row.resultState === 'published'" v-if="row.resultState === 'published' && row.status === 'withdrawn'"
class="mx-badge mx-badge-error"
title="本条已作废:不计入名次与积分,可用「重新录入」退回草稿"
>已作废</span>
<span
v-else-if="row.resultState === 'published'"
class="mx-badge mx-badge-success" class="mx-badge mx-badge-success"
title="已发布上屏" title="已发布上屏"
>已发布</span> >已发布</span>
@@ -1937,9 +2055,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)" @keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/> />
</td> </td>
<!--
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能作废」而不是一片空白。
-->
<td class="center action-cell">
<button
v-if="rowAction(row) !== null"
class="mx-btn mx-btn-sm"
:class="rowAction(row)?.action === 'reopen' ? 'mx-btn-secondary' : 'mx-btn-danger'"
type="button"
:disabled="!canWrite || resultActionBusy"
:title="rowActionTitle(row)"
@click="requestResultAction(row)"
>
{{ rowActionLabel(row) }}
</button>
<span v-else class="muted">—</span>
</td>
</tr> </tr>
<tr v-if="!loading && visibleRows.length === 0"> <tr v-if="!loading && visibleRows.length === 0">
<td colspan="11"> <td colspan="12">
<div class="mx-empty"> <div class="mx-empty">
<div class="mx-empty-title"> <div class="mx-empty-title">
{{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }} {{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }}
@@ -2099,6 +2235,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@cancel="unpublishDialog = false" @cancel="unpublishDialog = false"
/> />
<!--
单条作废 / 重新录入:原因必填(服务端写审计),后果写在弹窗里。
与「撤销发布」分开:这是外科手术式改一条,不动同组其它已发布成绩。
-->
<ConfirmDialog
:open="resultAction !== null"
tone="danger"
:title="resultActionCopy.title"
:message="resultActionCopy.message"
:details="
resultAction === null
? []
: [`${resultAction.resultText === '' ? '(空成绩)' : resultAction.resultText}`, '同组其它人的名次与积分会一起重算并推送大屏']
"
:confirm-text="resultActionCopy.confirmText"
:reason-required="true"
:reason-label="resultActionCopy.reasonLabel"
:pending="resultActionBusy"
@confirm="confirmResultAction"
@cancel="cancelResultAction"
/>
<ConfirmDialog <ConfirmDialog
:open="settleDialog" :open="settleDialog"
tone="warning" tone="warning"
@@ -2426,6 +2584,18 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
.team-cell { .team-cell {
font-weight: 600; font-weight: 600;
} }
/* 行操作区:窄列、不换行,按钮别把整张表撑宽。 */
.action-col,
.action-cell {
width: 96px;
white-space: nowrap;
}
.action-cell .mx-btn {
width: 100%;
}
.action-cell .muted {
color: var(--mx-t3);
}
/* /*
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。 * 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。 * 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
+544 -32
View File
@@ -5,6 +5,7 @@ import {
formatDateTime, formatDateTime,
formatPoints, formatPoints,
type Event, type Event,
type RecordBreak,
type RecordEntry, type RecordEntry,
type RecordLevel, type RecordLevel,
type RulePack, type RulePack,
@@ -13,7 +14,19 @@ import {
import MxIcon from '../components/MxIcon.vue' import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue' 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, sampleDataBlockedNotice } from '../api/client'
import {
IGNORE_BREAK_REASON,
RECORD_LEVEL_LABELS,
RECORD_LEVEL_OPTIONS,
RECORD_LEVEL_ORDER,
breakKey,
breakNote,
breakBonusText,
handledBreakKeys,
recordSourceLabel,
} from './recordBreaks'
import { emptyRecordForm, planRecordInput, type RecordFormState } from './recordForm'
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'
@@ -44,15 +57,6 @@ interface RecordGroup {
records: RecordEntry[] records: RecordEntry[]
} }
const LEVEL_LABELS: Record<RecordLevel, string> = {
meet: '赛会',
city: '市级',
province: '省级',
national: '全国',
}
const LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
const data = useEventStore() const data = useEventStore()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToastStore() const toast = useToastStore()
@@ -73,10 +77,39 @@ const levelFilter = ref<RecordLevel | 'all'>('all')
const approveTarget = ref<SuspectedBreak | null>(null) const approveTarget = ref<SuspectedBreak | null>(null)
const approving = ref(false) const approving = ref(false)
const rejectTarget = ref<SuspectedBreak | null>(null) const rejectTarget = ref<SuspectedBreak | null>(null)
/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */ const rejecting = ref(false)
/**
* 兜底用的本地标记。
*
* 服务端登记成功后靠「已处理登记」抑制(handledKeys),这两个数组只在接口写不进去时
* (示例数据 / 后端旧版)用,并且提示里会如实说明「仅本机有效,刷新后重新出现」。
*/
const rejectedKeys = ref<string[]>([]) const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([]) const approvedKeys = ref<string[]>([])
/** 已批准的破纪录登记(GET /api/record-breaks?state=approved)。 */
const approvedBreaks = ref<RecordBreak[]>([])
const breaksLoading = ref(false)
const breaksError = ref<string | null>(null)
/** 接口读不到(示例数据 / 后端旧版)时卡片显示空态说明,绝不让整页报错。 */
const breaksUnavailable = ref(false)
/** 读不到是因为权限(guest):文案与「接口不可用」不同,别把权限说成后端太旧。 */
const breaksForbidden = ref(false)
/** 已处理登记(approved + rejected)的键,用来抑制前端推导出的疑似列表。 */
const handledKeys = ref<Set<string>>(new Set())
const revokeTarget = ref<RecordBreak | null>(null)
const revoking = ref(false)
/** 新增历史纪录表单(P2 C11)。 */
const createOpen = ref(false)
const createForm = ref<RecordFormState>(emptyRecordForm())
const createError = ref('')
const creating = ref(false)
/** 删除纪录确认。 */
const deleteTarget = ref<RecordEntry | null>(null)
const deleting = ref(false)
const canWrite = computed(() => !auth.readOnly && data.canWrite) const canWrite = computed(() => !auth.readOnly && data.canWrite)
const isAdmin = computed(() => auth.user?.role === 'admin') const isAdmin = computed(() => auth.user?.role === 'admin')
/** /**
@@ -129,14 +162,24 @@ const recordGroups = computed<RecordGroup[]>(() => {
.sort((a, b) => a.eventName.localeCompare(b.eventName)) .sort((a, b) => a.eventName.localeCompare(b.eventName))
}) })
/**
* 待确认的疑似破纪录。
*
* 三层过滤:推导出的疑似条目,减去本机兜底标记(接口写不进去时用的),
* 再减去服务端已登记(approved / rejected)的键——最后这层才是「驳回以后刷新不再出现」的关键。
*/
const pendingBreaks = computed(() => const pendingBreaks = computed(() =>
suspected.value.filter( suspected.value.filter(
(item) => !rejectedKeys.value.includes(item.key) && !approvedKeys.value.includes(item.key), (item) =>
!rejectedKeys.value.includes(item.key) &&
!approvedKeys.value.includes(item.key) &&
!handledKeys.value.has(item.key),
), ),
) )
/** 档位序号:排序与筛选下拉共用(登记卡片与纪录库都要按同一顺序排)。 */
function levelRank(level: RecordLevel): number { function levelRank(level: RecordLevel): number {
const index = LEVEL_ORDER.indexOf(level) const index = RECORD_LEVEL_ORDER.indexOf(level)
return index < 0 ? 0 : index return index < 0 ? 0 : index
} }
@@ -154,6 +197,20 @@ function teamLabel(teamId: number): string {
return team?.shortName ?? team?.name ?? `单位 ${teamId}` return team?.shortName ?? team?.name ?? `单位 ${teamId}`
} }
/** 破纪录登记只带 eventId,卡片里要显示小项名与代码;查不到时给出可定位的占位。 */
function eventNameOf(eventId: number): string {
return data.eventById(eventId)?.name ?? `项目 ${eventId}`
}
function eventCodeOf(eventId: number): string {
return data.eventById(eventId)?.code ?? ''
}
/** 保持人单位:登记的是单位 id,可能在单位列表里查不到(已停用 / 已删)。 */
function holderTeamLabel(teamId: number | null): string {
return teamId === null ? '—' : teamLabel(teamId)
}
/** 只有「已发布 + 有效成绩」才可能破纪录;草稿永远不参与判断。 */ /** 只有「已发布 + 有效成绩」才可能破纪录;草稿永远不参与判断。 */
function beats(compare: Event['compare'], value: number, recordValue: number): boolean { function beats(compare: Event['compare'], value: number, recordValue: number): boolean {
if (compare === 'asc') return value < recordValue if (compare === 'asc') return value < recordValue
@@ -225,6 +282,42 @@ async function loadRecords(): Promise<void> {
} }
} }
/**
* 读破纪录登记(approved + rejected 两态)。
*
* 两态分开取:approved 是「已批准的破纪录」卡片的数据源,两者合起来才能算出
* 「已经处理过」的键去抑制前端推导出的疑似列表(驳回过的条目刷新后不该再冒出来)。
*
* 读不到时(示例数据 / 后端旧版 404)不让整页报错,只把卡片置为空态说明;
* 这时「忽略」退回本机标记,并在提示里如实说明刷新后会重新出现。
*/
async function loadBreaks(): Promise<void> {
breaksLoading.value = true
const [approved, rejected] = await Promise.allSettled([
adminApi.recordBreaks('approved'),
adminApi.recordBreaks('rejected'),
])
const failures = [approved, rejected].filter((result) => result.status === 'rejected')
const failure = failures[0]
breaksUnavailable.value = failure !== undefined
// GET /api/record-breaks 要求 recorder 及以上:guest 必然 403,那是权限边界而不是后端太旧。
breaksForbidden.value = failures.some((item) => isForbidden(item.reason))
breaksError.value =
failure !== undefined
? failure.reason instanceof Error
? failure.reason.message
: '读取破纪录登记失败'
: null
// 部分成功也照收:能抑制多少算多少,读不到的那一态由上面的空态说明交代。
const loaded: RecordBreak[] = [
...(approved.status === 'fulfilled' ? approved.value : []),
...(rejected.status === 'fulfilled' ? rejected.value : []),
]
approvedBreaks.value = approved.status === 'fulfilled' ? approved.value : []
handledKeys.value = handledBreakKeys(loaded)
breaksLoading.value = false
}
async function loadSuspected(): Promise<void> { async function loadSuspected(): Promise<void> {
if (!data.loaded) return if (!data.loaded) return
suspectedLoading.value = true suspectedLoading.value = true
@@ -250,7 +343,7 @@ async function loadSuspected(): Promise<void> {
} }
if (best === null) continue if (best === null) continue
list.push({ list.push({
key: `${event.id}-${entry.id}-${best.level}`, key: breakKey(event.id, entry.id, best.level),
eventId: event.id, eventId: event.id,
eventName: event.name, eventName: event.name,
eventCode: event.code, eventCode: event.code,
@@ -315,10 +408,10 @@ async function confirmApprove(): Promise<void> {
toast.success( toast.success(
'已确认破纪录', '已确认破纪录',
item.bonusPoints === null item.bonusPoints === null
? `${LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准` ? `${RECORD_LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准`
: `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`, : `+${formatPoints(item.bonusPoints)} 分(${RECORD_LEVEL_LABELS[item.level]})`,
) )
await Promise.all([loadRecords(), data.refreshStandings()]) await Promise.all([loadRecords(), data.refreshStandings(), loadBreaks()])
} catch (caught) { } catch (caught) {
if (isFallback(caught)) { if (isFallback(caught)) {
// 后端未就绪:只做本地标记,绝不假装写成功。 // 后端未就绪:只做本地标记,绝不假装写成功。
@@ -333,16 +426,187 @@ async function confirmApprove(): Promise<void> {
} }
} }
function confirmReject(): void { /** 「忽略」确认框正文:示例数据模式下写不进去,必须如实说明只在本机生效。 */
const rejectMessage = computed(() =>
data.isMock
? '示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。'
: '会在服务端登记为一条破纪录记录并立即驳回(原因写「现场判定不是破纪录」),刷新后不会再出现在待确认列表里。',
)
/**
* 「忽略」疑似破纪录:在服务端登记一条 pending 再驳回,原因是固定的「现场判定不是破纪录」。
*
* 以前只在本地数组里藏起来,刷新就回来——现场看到的「已忽略」和服务端真实状态不一致。
* 现在只有真的写不进去(示例数据 / 未连接服务端)才退回本机标记,并如实说明仅本机有效。
*/
async function confirmReject(): Promise<void> {
const item = rejectTarget.value const item = rejectTarget.value
if (item === null) return if (item === null || rejecting.value) return
rejectedKeys.value = [...rejectedKeys.value, item.key] if (!canWrite.value) {
rejectTarget.value = null toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现') return
}
rejecting.value = true
try {
const created = await adminApi.client.records.createBreak({
eventId: item.eventId,
entryId: item.entryId,
level: item.level,
resultValue: item.resultValue,
resultText: item.resultText,
})
await adminApi.rejectBreak(created.id, IGNORE_BREAK_REASON)
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.success('已拒绝该条疑似破纪录', '已在服务端登记并驳回,刷新后不会再出现')
await loadBreaks()
} catch (caught) {
if (isFallback(caught)) {
// 后端未就绪:只能在本机隐藏,绝不假装写成功;提示里必须说清刷新后会回来。
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.warning('已在本机忽略(未连接服务端)', '没有写入服务端,刷新页面后该条会重新出现')
} else {
toast.error('拒绝失败', caught instanceof Error ? caught.message : null)
}
} finally {
rejecting.value = false
}
}
// ── 已批准的破纪录:作废(撤回加分 + 恢复上一条纪录) ────────────────────────
/** 示例数据模式统一拦截写操作;返回 true 表示已拦下。 */
function blockSampleData(action: string): boolean {
if (!data.isMock) return false
const notice = sampleDataBlockedNotice(action)
toast.error(notice.title, notice.detail)
return true
}
/** 点「作废」:先过权限与示例数据,再弹必填原因的确认框。 */
function requestRevoke(item: RecordBreak): void {
if (blockSampleData('作废破纪录')) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
revokeTarget.value = item
}
async function confirmRevoke(reason: string): Promise<void> {
const item = revokeTarget.value
if (item === null || revoking.value) return
revoking.value = true
try {
await adminApi.rejectBreak(item.id, reason)
revokeTarget.value = null
// 撤回加分与「恢复上一条纪录」都在服务端同一事务里做完:纪录库与榜单必须重拉。
await Promise.all([loadRecords(), data.refreshStandings()])
await Promise.all([loadBreaks(), loadSuspected()])
toast.success(
'加分已撤回,该级别纪录已恢复上一条',
`已作废 ${RECORD_LEVEL_LABELS[item.level]}破纪录登记(${breakBonusText(item)})`,
)
} catch (caught) {
toast.error('作废破纪录失败', caught instanceof Error ? caught.message : null)
} finally {
revoking.value = false
}
}
function cancelRevoke(): void {
if (revoking.value) return
revokeTarget.value = null
}
// ── 纪录库:新增历史纪录 / 删除 ──────────────────────────────────────────────
/** 打开新增表单:每次都用空表单,别把上一次的输入带进来。 */
function openCreate(): void {
if (blockSampleData('新增历史纪录')) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
createForm.value = emptyRecordForm(data.events[0]?.id ?? null)
createError.value = ''
createOpen.value = true
}
function closeCreate(): void {
if (creating.value) return
createOpen.value = false
}
/** 保持下拉受控:小项 / 单位允许留空(null),空值不能变成字符串 "null"。 */
function formEventId(event: globalThis.Event): number | null {
const raw = (event.target as HTMLSelectElement).value
return raw === '' ? null : Number(raw)
}
function formTeamId(event: globalThis.Event): number | null {
const raw = (event.target as HTMLSelectElement).value
return raw === '' ? null : Number(raw)
}
async function submitCreate(): Promise<void> {
if (creating.value) return
const plan = planRecordInput(createForm.value)
if (!plan.ok) {
createError.value = plan.error
return
}
createError.value = ''
creating.value = true
try {
await adminApi.createRecord(plan.input)
createOpen.value = false
// 新纪录可能比现有纪录更优 → 疑似破纪录的推导会变,两件事一起重拉。
await loadRecords()
await loadSuspected()
toast.success('已新增历史纪录', '来源记为「历史」')
} catch (caught) {
toast.error('新增历史纪录失败', caught instanceof Error ? caught.message : null)
} finally {
creating.value = false
}
}
/** 点「删除」:先过权限与示例数据,再弹确认(说清「当前纪录会自动恢复上一条」)。 */
function requestDelete(record: RecordEntry): void {
if (blockSampleData('删除纪录')) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
deleteTarget.value = record
}
async function confirmDelete(): Promise<void> {
const record = deleteTarget.value
if (record === null || deleting.value) return
deleting.value = true
try {
await adminApi.removeRecord(record.id)
deleteTarget.value = null
await loadRecords()
await loadSuspected()
toast.success('已删除该条纪录', '若它是当前纪录,服务端已恢复上一条')
} catch (caught) {
toast.error('删除纪录失败', caught instanceof Error ? caught.message : null)
} finally {
deleting.value = false
}
}
function cancelDelete(): void {
if (deleting.value) return
deleteTarget.value = null
} }
async function refreshAll(): Promise<void> { async function refreshAll(): Promise<void> {
await Promise.all([loadRecords(), loadRules()]) await Promise.all([loadRecords(), loadRules(), loadBreaks()])
await loadSuspected() await loadSuspected()
} }
@@ -460,7 +724,7 @@ watch(
</td> </td>
<td class="mono highlight">{{ item.resultText }}</td> <td class="mono highlight">{{ item.resultText }}</td>
<td class="mono">{{ item.recordText }}</td> <td class="mono">{{ item.recordText }}</td>
<td><span class="mx-tag mx-tag-primary">{{ LEVEL_LABELS[item.level] }}</span></td> <td><span class="mx-tag mx-tag-primary">{{ RECORD_LEVEL_LABELS[item.level] }}</span></td>
<td class="right mono points"> <td class="right mono points">
{{ item.bonusPoints === null ? '—' : `+${formatPoints(item.bonusPoints)}` }} {{ item.bonusPoints === null ? '—' : `+${formatPoints(item.bonusPoints)}` }}
</td> </td>
@@ -495,21 +759,105 @@ watch(
</div> </div>
</section> </section>
<!--
已批准的破纪录(A41):加分一旦批出去就要能撤回。
数据来自服务端登记表,不是前端推导——所以作废后刷新页面也仍然生效。
-->
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">已批准的破纪录</span>
<span class="mx-badge mx-badge-neutral">{{ approvedBreaks.length }} 条</span>
</div>
<div class="mx-card-body">
<div v-if="breaksLoading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载已批准的破纪录…</div>
</div>
<!-- 接口读不到时只让这张卡片显示空态说明,页面其余部分照常可用。 -->
<div v-else-if="breaksUnavailable" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="warning" :size="26" /></div>
<div class="mx-empty-title">{{ breaksForbidden ? '访客只读:看不到破纪录登记' : '读不到破纪录登记' }}</div>
<div v-if="breaksForbidden" class="mx-empty-desc">
破纪录登记(含待审核的登记与驳回理由)要求录入员及以上权限;登录后即可查看与作废。
</div>
<div v-else class="mx-empty-desc">
{{ breaksError ?? '接口不可用' }};示例数据模式或后端版本较旧时没有这个接口,
已批准的破纪录无法在这里作废,请在服务端升级后再试。
</div>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="loadBreaks">重试</button>
</div>
<div v-else-if="approvedBreaks.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
<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 class="right">加分</th>
<th>批准时间</th>
<th>说明</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in approvedBreaks" :key="item.id">
<td>
<span class="event-name">{{ eventNameOf(item.eventId) }}</span>
<small class="mono event-code">{{ eventCodeOf(item.eventId) }}</small>
</td>
<td><span class="mx-tag mx-tag-primary">{{ RECORD_LEVEL_LABELS[item.level] }}</span></td>
<td class="mono highlight">{{ item.resultText }}</td>
<td class="right mono points">{{ breakBonusText(item) }}</td>
<td class="mono">{{ formatDateTime(item.approvedAt) }}</td>
<!-- note 是新增字段,旧服务端不下发:缺字段时整格留白,不显示 undefined。 -->
<td class="note">{{ breakNote(item) ?? '—' }}</td>
<td class="right actions">
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite || revoking"
title="作废后撤回该笔加分,并恢复该级别的上一条纪录"
@click="requestRevoke(item)"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card mx-section"> <section class="mx-card mx-section">
<div class="mx-card-head"> <div class="mx-card-head">
<span class="mx-card-title">纪录库</span> <span class="mx-card-title">纪录库</span>
<div class="head-tools"> <div class="head-tools">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite"
:title="canWrite ? '登记一条历史纪录(来源记为「历史」)' : '访客只读,无法新增'"
@click="openCreate"
>
<MxIcon name="entry" :size="14" /> 新增历史纪录
</button>
<div class="mx-tabs"> <div class="mx-tabs">
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button> <button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
<button <button
v-for="level in LEVEL_ORDER" v-for="level in RECORD_LEVEL_ORDER"
:key="level" :key="level"
class="mx-tab" class="mx-tab"
:class="{ active: levelFilter === level }" :class="{ active: levelFilter === level }"
type="button" type="button"
@click="levelFilter = level" @click="levelFilter = level"
> >
{{ LEVEL_LABELS[level] }} {{ RECORD_LEVEL_LABELS[level] }}
</button> </button>
</div> </div>
<div class="mx-input-wrap search"> <div class="mx-input-wrap search">
@@ -551,20 +899,37 @@ watch(
<th>达成时间</th> <th>达成时间</th>
<th>来源</th> <th>来源</th>
<th class="center">当前</th> <th class="center">当前</th>
<th class="right">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="record in group.records" :key="record.id"> <tr v-for="record in group.records" :key="record.id">
<td><span class="mx-tag" :class="record.isCurrent ? 'mx-tag-primary' : ''">{{ LEVEL_LABELS[record.level] }}</span></td> <td><span class="mx-tag" :class="record.isCurrent ? 'mx-tag-primary' : ''">{{ RECORD_LEVEL_LABELS[record.level] }}</span></td>
<td class="mono highlight">{{ record.resultText }}</td> <td class="mono highlight">{{ record.resultText }}</td>
<td>{{ record.holderName ?? '—' }}</td> <td>{{ record.holderName ?? '—' }}</td>
<td>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td> <td>{{ holderTeamLabel(record.holderTeamId) }}</td>
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td> <td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
<td>{{ record.source === 'current' ? '本届' : '历史' }}</td> <!-- 来源:历史 = 老资料登记进来的,本届 = 本场赛事产生的;缺字段显示 —,不冒充「历史」。 -->
<td>
<span class="mx-badge" :class="record.source === 'history' ? 'mx-badge-neutral' : 'mx-badge-info'">
{{ recordSourceLabel(record.source) }}
</span>
</td>
<td class="center"> <td class="center">
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span> <span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
<span v-else class="muted">—</span> <span v-else class="muted">—</span>
</td> </td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || deleting"
title="删除该条纪录"
@click="requestDelete(record)"
>
删除
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -582,8 +947,8 @@ watch(
approveTarget === null approveTarget === null
? '' ? ''
: approveTarget.bonusPoints === null : approveTarget.bonusPoints === null
? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。` ? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${RECORD_LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。`
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。` : `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${RECORD_LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
" "
:details="[ :details="[
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`, `成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
@@ -599,12 +964,126 @@ watch(
:open="rejectTarget !== null" :open="rejectTarget !== null"
tone="danger" tone="danger"
title="拒绝这条疑似破纪录?" title="拒绝这条疑似破纪录?"
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。" :message="rejectMessage"
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]" :details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
confirm-text="确认拒绝" confirm-text="确认拒绝"
:pending="rejecting"
@confirm="confirmReject" @confirm="confirmReject"
@cancel="rejectTarget = null" @cancel="rejectTarget = null"
/> />
<!--
作废已批准的破纪录:批准时加过分,作废要把那笔加分撤掉并恢复上一条纪录,
所以原因必填(写审计),后果写在弹窗里。
-->
<ConfirmDialog
:open="revokeTarget !== null"
tone="danger"
title="确认作废这条破纪录登记?"
message="作废后将撤回该笔加分,并恢复该级别的上一条纪录;大屏与此处纪录库会立即更新。"
:details="
revokeTarget === null
? []
: [
`小项:${eventNameOf(revokeTarget.eventId)}`,
`成绩:${revokeTarget.resultText}(${RECORD_LEVEL_LABELS[revokeTarget.level]})`,
`撤回加分:${breakBonusText(revokeTarget)}`,
]
"
confirm-text="确认作废"
:reason-required="true"
reason-label="作废原因"
:pending="revoking"
@confirm="confirmRevoke"
@cancel="cancelRevoke"
/>
<ConfirmDialog
:open="deleteTarget !== null"
tone="danger"
title="确认删除这条纪录?"
:message="
deleteTarget === null
? ''
: `将删除「${eventNameOf(deleteTarget.eventId)}」的${RECORD_LEVEL_LABELS[deleteTarget.level]}纪录 ${deleteTarget.resultText};若它是当前纪录,会恢复上一条。`
"
:details="['删除只影响纪录库,不会改动任何已发布的成绩与积分']"
confirm-text="确认删除"
:pending="deleting"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
<!-- 新增历史纪录表单:成绩值 / 成绩文本至少一个,其余可留空(老资料常常只有一部分)。 -->
<div v-if="createOpen" class="mx-modal-overlay open" @click.self="closeCreate">
<div class="mx-modal" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">新增历史纪录</span>
<button class="mx-modal-close" type="button" :disabled="creating" @click="closeCreate">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body create-body">
<p class="create-hint">
登记的是<strong>历史资料</strong>(秩序册 / 上届成绩),来源固定记为「历史」,与本届成绩无关。
</p>
<label class="create-field">
<span>小项</span>
<select class="mx-input" :value="createForm.eventId ?? ''" @change="createForm.eventId = formEventId($event)">
<option value="">请选择小项</option>
<option v-for="event in data.events" :key="event.id" :value="event.id">
{{ event.name }}
</option>
</select>
</label>
<label class="create-field">
<span>级别</span>
<select v-model="createForm.level" class="mx-input">
<option v-for="option in RECORD_LEVEL_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<div class="create-row">
<label class="create-field">
<span>成绩值</span>
<input v-model="createForm.resultValue" class="mx-input mono" placeholder="例 11.2(秒 / 米 / 次)" />
</label>
<label class="create-field">
<span>成绩文本</span>
<input v-model="createForm.resultText" class="mx-input mono" placeholder="例 11.20 或 1:06.33" />
</label>
</div>
<p class="create-hint">成绩值与成绩文本至少填一个;比较与破纪录判断以成绩值为准。</p>
<div class="create-row">
<label class="create-field">
<span>保持人姓名</span>
<input v-model="createForm.holderName" class="mx-input" placeholder="可留空" />
</label>
<label class="create-field">
<span>保持单位</span>
<select class="mx-input" :value="createForm.holderTeamId ?? ''" @change="createForm.holderTeamId = formTeamId($event)">
<option value="">不指定</option>
<option v-for="team in data.teams" :key="team.id" :value="team.id">
{{ team.shortName ?? team.name }}
</option>
</select>
</label>
</div>
<label class="create-field">
<span>创造日期</span>
<input v-model="createForm.achievedAt" class="mx-input mono" type="date" />
</label>
<p v-if="createError !== ''" class="create-error">{{ createError }}</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="creating" @click="closeCreate">取消</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="creating" @click="submitCreate">
{{ creating ? '保存中…' : '保存纪录' }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -704,6 +1183,39 @@ watch(
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.create-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.create-row {
display: flex;
gap: 10px;
}
.create-row .create-field {
flex: 1;
}
.create-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--mx-t2);
}
.create-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.create-error {
font-size: 12px;
color: var(--mx-red);
}
.note {
max-width: 220px;
font-size: 12px;
color: var(--mx-t3);
}
.record-group-head { .record-group-head {
display: flex; display: flex;
align-items: center; align-items: center;
+97
View File
@@ -0,0 +1,97 @@
import type { Event, RealtimeStatus } from '@/shared'
/**
* 仪表盘卡片的纯逻辑(P2 B08)。
*
* `devdocs/07 §7` 承诺了「今天的小项 / 待办数 / 连接状态」三类卡片,之前实现里都没有。
* 补齐时最容易出错的是「今天是哪一天」——服务端存的是带时区的时间串,
* 一律按本地时区取当天(现场看的日历就是本机日历),所以口径抽在这里单独测。
*/
/** 本地时区的当天,`YYYY-MM-DD`。 */
export function localDayKey(at: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, '0')
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
}
/**
* 排期时间属于本地时区的哪一天;读不出日期时返回 null(不猜)。
*
* 纯日期(`2026-09-12`)没有时区含义,直接取日期部分——交给 `new Date()` 会按 UTC 午夜解析,
* 在东八区以外的机器上会整体错一天,把「今天的小项」算成昨天或明天。
* 带时间的串(`...T09:00` 或带 Z / 偏移量)交给 Date 解析后再取本地日历日:
* 带偏移量的时间串本来就是「某个绝对时刻」,显示给现场看必须是本机日历上的那一天。
*/
export function scheduleDayKey(scheduledAt: string | null | undefined): string | null {
const text = (scheduledAt ?? '').trim()
if (text === '') return null
const bare = /^(\d{4}-\d{2}-\d{2})$/.exec(text)
if (bare !== null) return bare[1] ?? null
const at = new Date(text)
if (Number.isNaN(at.getTime())) return null
return localDayKey(at)
}
/** 指定某天(`YYYY-MM-DD`)计划进行的小项;scheduledAt 为空的排期不算任何一天。 */
export function eventsOnDay(events: readonly Event[], day: string): Event[] {
return events.filter((event) => scheduleDayKey(event.scheduledAt) === day)
}
export function todayEvents(events: readonly Event[], now: Date = new Date()): Event[] {
return eventsOnDay(events, localDayKey(now))
}
export interface ScheduleProgress {
total: number
published: number
/** 可展示的进度文案;total 为 0 时给 null,由页面显示「今天没有排期」。 */
text: string | null
}
export function scheduleProgress(events: readonly Event[]): ScheduleProgress {
const published = events.filter((event) => event.status === 'published').length
return {
total: events.length,
published,
text: events.length === 0 ? null : `已发布 ${published} / 共 ${events.length}`,
}
}
export interface DisplayCounts {
online: number
total: number
}
/**
* 大屏在线数 / 总数。
*
* `instances` 为 null 表示数据源不可用(示例数据 / 接口失败):这时返回 null,
* 由页面显示 `—`,绝不能用 0 / 0 冒充「一台大屏都没连」。
*/
export function displayCounts(instances: readonly { online: boolean }[] | null): DisplayCounts | null {
if (instances === null) return null
return { online: instances.filter((item) => item.online).length, total: instances.length }
}
/** 数据源不可用时统一显示 `—`,不要用 0 冒充「没有」。 */
export function countText(value: number | null): string {
return value === null ? '—' : String(value)
}
export function displayCountText(counts: DisplayCounts | null): string {
return counts === null ? '—' : `${counts.online} / ${counts.total}`
}
/**
* 管理端的连接状态文案。
*
* 接口里没有「管理端在线数」这种统计(`GET /api/display/instances` 只登记大屏实例),
* 所以这里如实显示**本机管理端**的实时连接状态,不编一个数出来。
*/
export function adminLinkLabel(isMock: boolean, status: RealtimeStatus): string {
if (isMock) return '示例数据'
if (status === 'online') return '本机在线'
if (status === 'connecting') return '连接中'
if (status === 'idle') return '未连接'
return '连接中断'
}
+96
View File
@@ -0,0 +1,96 @@
import type { ResultState, ResultStatus } from '@/shared'
/**
* 成绩录入页「行操作区」的纯逻辑(P2 A16)。
*
* 抽出来的原因与 entryGuards 一致:`.vue` 组件跑不进 `node --test`,而这里恰好有两处
* 会真出错的地方——「哪一行显示哪个按钮」的状态判定,以及「动的是哪一条成绩」的 id 口径。
*
* 注意 id 口径:作废 / 退回的接口是 `POST /api/results/{id}/...`,id 是**成绩 id**,
* 不是报名项 id。两者在页面上挨着,拿错就会把别人的成绩作废掉,所以这里单独强调。
*/
/** 行操作区能对一条已发布成绩做的事。 */
export type ResultRowAction = 'void' | 'reopen'
export interface ResultRowState {
resultState: ResultState | null
status: ResultStatus
}
/**
* 这一行此刻该显示哪个操作;null 表示不显示(草稿行本来就能直接改,不需要作废入口)。
*
* 口径来自服务端 void 的实际行为:只把 `results.status` 改成 withdrawn,`state` 仍是 published,
* 所以「已发布 + withdrawn」= 已作废(显示「重新录入」),「已发布 + 其它状态」= 正常已发布(显示「作废本条」)。
*/
export function resultRowAction(row: ResultRowState): ResultRowAction | null {
if (row.resultState !== 'published') return null
return row.status === 'withdrawn' ? 'reopen' : 'void'
}
export interface ResultActionRow extends ResultRowState {
entryId: number
/** 成绩 id;草稿行也可能有(未发布的成绩同样有 id),缺了就没法定位接口目标。 */
resultId: number | null
resultText: string
}
export interface ResultActionTarget {
action: ResultRowAction
/** 服务端按成绩 id 定位,绝不是 entryId。 */
resultId: number
entryId: number
resultText: string
}
/** 把一行收窄成一次可提交的操作;判定不成立(草稿行 / 没有成绩 id)时为 null。 */
export function resultActionTarget(row: ResultActionRow): ResultActionTarget | null {
const action = resultRowAction(row)
if (action === null || row.resultId === null) return null
return { action, resultId: row.resultId, entryId: row.entryId, resultText: row.resultText }
}
export const RESULT_ROW_ACTION_LABEL: Record<ResultRowAction, string> = {
void: '作废本条',
reopen: '重新录入',
}
export const RESULT_ROW_ACTION_TITLE: Record<ResultRowAction, string> = {
void: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新',
reopen: '退回草稿后可继续修改,改完再发布本组',
}
/** 示例数据模式下拦下写操作时的动作名(拼进「示例数据模式不能X」)。 */
export const RESULT_ROW_ACTION_WORD: Record<ResultRowAction, string> = {
void: '作废成绩',
reopen: '退回草稿',
}
/** 确认框文案:后果必须写在弹窗里,不能只让操作员凭经验猜(07 §8)。 */
export const RESULT_ACTION_CONFIRM: Record<ResultRowAction, { title: string; message: string; confirmText: string; reasonLabel: string }> = {
void: {
title: '确认作废本条成绩?',
message: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
confirmText: '确认作废',
reasonLabel: '作废原因',
},
reopen: {
title: '确认重新录入本条成绩?',
message: '该条成绩将退回草稿(可编辑),不再计入名次与积分;改完请再发布本组。',
confirmText: '退回草稿',
reasonLabel: '退回原因',
},
}
/** 成功提示:作废与退回的后续动作不同,提示要各说各的。 */
export const RESULT_ACTION_SUCCESS: Record<ResultRowAction, { title: string; detail: string }> = {
void: {
title: '已作废该条成绩,名次与积分已重算',
detail: '大屏已更新;需要改回来时用同一行的「重新录入」退回草稿',
},
reopen: {
title: '已退回草稿,该行可以继续修改',
detail: '改完记得再点一次「发布本批」,名次与积分会按新成绩重算',
},
}
+81
View File
@@ -0,0 +1,81 @@
import { formatPoints } from '@/shared'
import type { RecordBreak, RecordLevel, RecordSource } from '@/shared'
/**
* 破纪录登记(record-breaks)与纪录库(records)的纯逻辑(P2 A41 / C11)。
*
* 抽出来的原因:`.vue` 跑不进 `node --test`,而这里有两处会真出错的地方——
* 「已处理」的登记要靠键去抑制前端推导出来的疑似列表(两边键算法必须一模一样),
* 以及「来源」列缺字段时不能把旧包显示成「历史」。
*/
export const RECORD_LEVEL_LABELS: Record<RecordLevel, string> = {
meet: '赛会',
city: '市级',
province: '省级',
national: '全国',
}
/** 档位从低到高;列表与筛选都按它排序。 */
export const RECORD_LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
export const RECORD_LEVEL_OPTIONS: Array<{ value: RecordLevel; label: string }> = RECORD_LEVEL_ORDER.map(
(level) => ({ value: level, label: RECORD_LEVEL_LABELS[level] }),
)
/**
* 纪录来源文案。
*
* 缺字段(旧包 / 示例数据 / 老服务端)时显示 `—` 而不是「历史」:
* 「历史」是一条真实结论,而这里只是读不到,两者不能混。
*/
export function recordSourceLabel(source: RecordSource | null | undefined): string {
if (source === 'current') return '本届'
if (source === 'history') return '历史'
return '—'
}
/**
* 疑似破纪录与登记行共用的键。
*
* 录入侧由「已发布成绩 vs 当前纪录」推导出疑似条目,服务端侧返回已处理的登记;
* 两侧必须用同一个函数算键,否则「已经驳回的那条」刷新后会重新冒出来。
*/
export function breakKey(eventId: number, entryId: number, level: RecordLevel): string {
return `${eventId}-${entryId}-${level}`
}
/** 已处理(approved / rejected)的登记键;pending 的不抑制——它本来就该留在待确认里。 */
export function handledBreakKeys(breaks: readonly RecordBreak[]): Set<string> {
const keys = new Set<string>()
for (const item of breaks) {
if (item.state === 'pending') continue
keys.add(breakKey(item.eventId, item.entryId, item.level))
}
return keys
}
/** 登记说明(驳回原因等);旧服务端不下发 note 时返回 null,界面据此不渲染那一行。 */
export function breakNote(item: { note?: string | null }): string | null {
const text = (item.note ?? '').trim()
return text === '' ? null : text
}
/**
* 加分展示。
*
* `bonusPoints` 缺失(旧服务端 / 示例数据)时给 `—`:直接拼 `+${formatPoints(undefined)}`
* 会渲染成「+—」这种既不是数字也不是占位的怪东西。
*/
export function breakBonusText(item: { bonusPoints?: number | null }): string {
const value = item.bonusPoints
return typeof value === 'number' && Number.isFinite(value) ? `+${formatPoints(value)}` : '—'
}
/**
* 「忽略」在服务端登记的固定原因。
*
* 现场判定不是破纪录不是「错误」而是一次判定,把它写进 note 之后,
* 谁在什么时候否掉了哪一条在审计里看得见,而不是只留在某个浏览器的内存里。
*/
export const IGNORE_BREAK_REASON = '现场判定不是破纪录'
+68
View File
@@ -0,0 +1,68 @@
import type { RecordEntryInput, RecordLevel } from '@/shared'
/**
* 「新增历史纪录」表单的纯逻辑(P2 C11)。
*
* 之前 `records.source='history'` 没有任何写入路径,这一档永远是空的;
* 接上 `POST /api/records` 之后,录入的是**手抄的历史资料**,所以两个值字段都允许只填一个:
* 老秩序册上常常只有「12.34 秒」这种文本,也可能只有换算后的数值。
* 校验与请求体的拼装放这里,组件只负责把输入框接上去。
*/
export interface RecordFormState {
eventId: number | null
level: RecordLevel
/** 成绩值输入框的原始文本;空串 = 没填。 */
resultValue: string
resultText: string
holderName: string
holderTeamId: number | null
/** 创造日期;空串 = 没填(历史资料常常记不清具体日期)。 */
achievedAt: string
}
export type RecordFormPlan = { ok: true; input: RecordEntryInput } | { ok: false; error: string }
/** 表单初始值:级别默认赛会纪录(录入历史资料时最常用),其余留空。 */
export function emptyRecordForm(eventId: number | null = null): RecordFormState {
return {
eventId,
level: 'meet',
resultValue: '',
resultText: '',
holderName: '',
holderTeamId: null,
achievedAt: '',
}
}
export function planRecordInput(form: RecordFormState): RecordFormPlan {
if (form.eventId === null) return { ok: false, error: '请选择小项' }
const valueText = form.resultValue.trim()
const text = form.resultText.trim()
let resultValue: number | null = null
if (valueText !== '') {
const parsed = Number(valueText)
if (!Number.isFinite(parsed)) return { ok: false, error: '成绩值要填数字;只填成绩文本也可以' }
resultValue = parsed
}
if (resultValue === null && text === '') {
return { ok: false, error: '成绩值或成绩文本至少填一个' }
}
return {
ok: true,
input: {
eventId: form.eventId,
level: form.level,
resultValue,
resultText: text === '' ? null : text,
holderName: form.holderName.trim() === '' ? null : form.holderName.trim(),
holderTeamId: form.holderTeamId,
achievedAt: form.achievedAt.trim() === '' ? null : form.achievedAt.trim(),
// 前端不猜 source:服务端对新登记的纪录固定写 history,这里显式带上只为与响应体同构。
source: 'history',
},
}
}
+178
View File
@@ -0,0 +1,178 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { Event } from '@/shared'
import {
adminLinkLabel,
countText,
displayCountText,
displayCounts,
eventsOnDay,
localDayKey,
scheduleDayKey,
scheduleProgress,
todayEvents,
} from '../pages/dashboard'
/**
* 仪表盘补齐 07 §7 承诺的卡片(P2 B08)。
*
* 承诺里有「今天计划进行的小项 + 进度」「待办(草稿待发布数 / 疑似破纪录待确认数)」
* 「连接状态(大屏在线数 / 总数)」,实现里一张都没有。补卡片时最容易踩的是
* 「今天是哪一天」与「读不到时不显示 0」这两件事,所以口径抽在这里测。
*/
const DASH_SOURCE = readFileSync(new URL('../pages/DashboardPage.vue', import.meta.url), 'utf8')
function event(overrides: Partial<Event>): Event {
return {
id: 1,
sportId: 1,
code: 'M100',
name: '男子 100 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
recordEligible: true,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
// ── 今天是哪一天 ────────────────────────────────────────────────────────────
test('当天用本地时区的 YYYY-MM-DD', () => {
// 本地时间 2026-09-12 09:05 → 就是 2026-09-12,与时区无关。
assert.equal(localDayKey(new Date(2026, 8, 12, 9, 5)), '2026-09-12')
assert.equal(localDayKey(new Date(2026, 0, 3, 23, 59)), '2026-01-03')
})
test('纯日期排期直接取日期部分(不能按 UTC 解析后倒推一天)', () => {
assert.equal(scheduleDayKey('2026-09-12'), '2026-09-12')
})
test('带时间的排期按本地日历日归属', () => {
assert.equal(scheduleDayKey('2026-09-12T09:00'), '2026-09-12')
// 绝对时刻按本机时区落到哪一天就算哪一天(现场看的是本机日历)。
const utc = '2026-09-12T23:30:00Z'
assert.equal(scheduleDayKey(utc), localDayKey(new Date(utc)))
})
test('空值与读不出来的时间串不算任何一天', () => {
assert.equal(scheduleDayKey(null), null)
assert.equal(scheduleDayKey(undefined), null)
assert.equal(scheduleDayKey(' '), null)
assert.equal(scheduleDayKey('待定'), null)
})
// ── 今天的小项与进度 ────────────────────────────────────────────────────────
test('只挑出当天排期的小项', () => {
const events = [
event({ id: 1, scheduledAt: '2026-09-12' }),
event({ id: 2, scheduledAt: '2026-09-12T14:00' }),
event({ id: 3, scheduledAt: '2026-09-13' }),
event({ id: 4, scheduledAt: null }),
]
assert.deepEqual(
eventsOnDay(events, '2026-09-12').map((item) => item.id),
[1, 2],
)
assert.deepEqual(
todayEvents(events, new Date(2026, 8, 12, 8, 0)).map((item) => item.id),
[1, 2],
)
})
test('今天没有小项时返回空数组、进度文案为 null(页面显示空态,不崩)', () => {
assert.deepEqual(todayEvents([], new Date(2026, 8, 12)), [])
assert.deepEqual(eventsOnDay([event({ scheduledAt: null })], '2026-09-12'), [])
const progress = scheduleProgress([])
assert.deepEqual(progress, { total: 0, published: 0, text: null })
})
test('进度按已发布 / 总数统计', () => {
const progress = scheduleProgress([
event({ id: 1, status: 'published' }),
event({ id: 2, status: 'pending' }),
event({ id: 3, status: 'published' }),
])
assert.deepEqual(progress, { total: 3, published: 2, text: '已发布 2 / 共 3' })
})
// ── 连接状态 ────────────────────────────────────────────────────────────────
test('大屏在线数 / 总数按 online 统计', () => {
assert.deepEqual(
displayCounts([{ online: true }, { online: false }, { online: true }]),
{ online: 2, total: 3 },
)
})
test('数据源不可用时给 null(页面显示 —),不能用 0 / 0 冒充', () => {
assert.equal(displayCounts(null), null)
assert.equal(displayCountText(displayCounts(null)), '—')
assert.equal(displayCountText(displayCounts([{ online: true }])), '1 / 1')
assert.equal(displayCountText(displayCounts([])), '0 / 0')
assert.equal(countText(null), '—')
assert.equal(countText(0), '0')
})
test('管理端只显示本机连接状态:接口里没有「管理端在线数」这种统计', () => {
assert.equal(adminLinkLabel(true, 'online'), '示例数据')
assert.equal(adminLinkLabel(false, 'online'), '本机在线')
assert.equal(adminLinkLabel(false, 'connecting'), '连接中')
assert.equal(adminLinkLabel(false, 'offline'), '连接中断')
assert.equal(adminLinkLabel(false, 'idle'), '未连接')
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('三张新卡片都在,旧的卡片一个没删', () => {
for (const label of ['今天的小项', '草稿待发布', '待确认破纪录', '大屏在线数']) {
assert.match(DASH_SOURCE, new RegExp(`</span>${label}</div>`), `缺少卡片:${label}`)
}
for (const label of ['领先单位', '已发布项目', '纪录条目', '实时连接']) {
assert.match(DASH_SOURCE, new RegExp(`</span>${label}</div>`), `旧卡片被删了:${label}`)
}
assert.match(DASH_SOURCE, /实时积分 Top 3/)
})
test('每个数据源各自兜底:读不到只让那一张显示 —,不整页崩', () => {
const load = DASH_SOURCE.slice(DASH_SOURCE.indexOf('async function loadCards('))
// 三个新数据源各有一个 try/catch,且失败时写 null 而不是 0。
assert.match(load, /draftCount\.value = null/)
assert.match(load, /pendingBreakCount\.value = null/)
assert.match(load, /displayInstances\.value = null/)
assert.match(DASH_SOURCE, /countText\(draftCount\)/)
assert.match(DASH_SOURCE, /countText\(pendingBreakCount\)/)
assert.match(DASH_SOURCE, /displayTotal/)
})
test('待确认破纪录数走 GET /api/record-breaks?state=pending', () => {
assert.match(DASH_SOURCE, /adminApi\.recordBreaks\('pending'\)\)\.length/)
// 访客必然 403:不发这一枪,卡片如实说明是权限而不是接口不可用。
assert.match(DASH_SOURCE, /auth\.readOnly \? null : \(await adminApi\.recordBreaks\('pending'\)\)\.length/)
assert.match(DASH_SOURCE, /pendingBreakCount\.value === null\) return auth\.readOnly \? '访客只读' : '接口不可用'/)
})
test('草稿待发布数走分页接口的 total', () => {
assert.match(DASH_SOURCE, /await adminApi\.draftResultCount\(\)/)
})
test('今天的小项用纯函数判定,模板不做日期字符串比较', () => {
assert.match(DASH_SOURCE, /todayEvents\(data\.events\)/)
assert.match(DASH_SOURCE, /todayProgress\.text === null \? '—' : todayProgress\.total/)
assert.match(DASH_SOURCE, /今天没有排期/)
const page = DASH_SOURCE.slice(DASH_SOURCE.indexOf('<template>'))
assert.doesNotMatch(page, /scheduledAt/, '模板里不该直接比较排期字符串')
})
@@ -0,0 +1,188 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
RESULT_ACTION_CONFIRM,
RESULT_ACTION_SUCCESS,
RESULT_ROW_ACTION_LABEL,
RESULT_ROW_ACTION_TITLE,
RESULT_ROW_ACTION_WORD,
resultActionTarget,
resultRowAction,
} from '../pages/entryResultActions'
/**
* 成绩录入页「作废 / 重新录入」(P2 A16)。
*
* 后端早有 `POST /api/results/{id}/void`,管理端却没有任何入口:现场改一个人填错的成绩,
* 只能「整组撤销发布 → 改 → 重发」,同组其它人的名次与积分会被整体回退再重算。
*
* 项目没有组件测试工具(无 @vue/test-utils / jsdom),判定逻辑抽在 entryResultActions 里直接测,
* 界面接线按仓库既有做法读源码断言。
*/
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
function section(source: string, start: string, end: string): string {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
return source.slice(from, to)
}
// ── 哪一行显示哪个按钮 ──────────────────────────────────────────────────────
test('已发布且未作废的行显示「作废本条」', () => {
assert.equal(resultRowAction({ resultState: 'published', status: 'ok' }), 'void')
assert.equal(resultRowAction({ resultState: 'published', status: 'dq' }), 'void')
assert.equal(resultRowAction({ resultState: 'published', status: 'dns' }), 'void')
})
test('已作废(published + withdrawn)的行显示「重新录入」', () => {
// 服务端 void 只改 status、不动 state,所以「已作废」等于「已发布 + withdrawn」。
assert.equal(resultRowAction({ resultState: 'published', status: 'withdrawn' }), 'reopen')
})
test('草稿行(含退回草稿后的行)不显示任何行操作:本来就是可编辑的', () => {
assert.equal(resultRowAction({ resultState: 'draft', status: 'withdrawn' }), null)
assert.equal(resultRowAction({ resultState: 'draft', status: 'ok' }), null)
assert.equal(resultRowAction({ resultState: null, status: 'ok' }), null)
})
test('按钮文案逐字:作废本条 / 重新录入', () => {
assert.equal(RESULT_ROW_ACTION_LABEL.void, '作废本条')
assert.equal(RESULT_ROW_ACTION_LABEL.reopen, '重新录入')
assert.match(RESULT_ROW_ACTION_TITLE.void, /不再计入名次与积分/)
assert.match(RESULT_ROW_ACTION_WORD.void, /作废/)
})
// ── 动的是哪一条成绩 ────────────────────────────────────────────────────────
test('操作目标是成绩 id(接口按它定位),不是报名项 id', () => {
const target = resultActionTarget({
entryId: 7001,
resultId: 9001,
resultState: 'published',
status: 'ok',
resultText: '11.58',
})
assert.deepEqual(target, {
action: 'void',
resultId: 9001,
entryId: 7001,
resultText: '11.58',
})
})
test('没有成绩 id 或不是已发布行时,不产生可提交的目标', () => {
const base = { entryId: 7001, resultState: 'published' as const, status: 'ok' as const, resultText: '11.58' }
assert.equal(resultActionTarget({ ...base, resultId: null }), null)
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }), null)
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('确认框文案写清了后果,且作废原因必填', () => {
assert.equal(RESULT_ACTION_CONFIRM.void.title, '确认作废本条成绩?')
assert.equal(
RESULT_ACTION_CONFIRM.void.message,
'作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
)
assert.equal(RESULT_ACTION_CONFIRM.reopen.title, '确认重新录入本条成绩?')
assert.match(RESULT_ACTION_CONFIRM.reopen.message, /退回草稿(可编辑)/)
assert.match(ENTRY_SOURCE, /:reason-required="true"/)
assert.match(ENTRY_SOURCE, /@confirm="confirmResultAction"/)
})
test('作废调用 void、退回草稿调用 reopen,提交的都是成绩 id', () => {
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
assert.match(confirm, /await adminApi\.voidResult\(target\.resultId, \{ reason \}\)/)
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
assert.doesNotMatch(confirm, /(voidResult|reopenResult)\(target\.entryId/)
})
test('作废 / 退回成功后刷新本页数据与榜单', () => {
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
// 名次与积分由服务端重算:必须整表重拉 + 刷新榜单,不能只在本地行上打补丁。
assert.match(confirm, /await reloadAfterSettlementChange\(\)/)
assert.ok(
confirm.indexOf('reloadAfterSettlementChange') < confirm.indexOf('toast.success'),
'刷新要发生在成功提示之前,否则提示先说成功、页面还没变',
)
const reload = section(ENTRY_SOURCE, 'async function reloadAfterSettlementChange(', 'async function confirmSettle(')
assert.match(reload, /await reload\(\)/)
assert.match(reload, /data\.refreshStandings\(\)/)
})
test('成功后给 toast,失败时原样报错', () => {
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
assert.match(confirm, /const success = RESULT_ACTION_SUCCESS\[target\.action\]/)
assert.match(confirm, /toast\.success\(success\.title, success\.detail\)/)
assert.match(confirm, /toast\.error\(target\.action === 'void' \? '作废失败' : '退回草稿失败'/)
assert.match(RESULT_ACTION_SUCCESS.void.title, /名次与积分已重算/)
})
test('示例数据模式在发请求前拦下,提示与既有口径一致', () => {
const request = section(ENTRY_SOURCE, 'function requestResultAction(', 'async function confirmResultAction(')
assert.match(request, /if \(data\.isMock\) \{/)
assert.match(request, /sampleDataBlockedNotice\(RESULT_ROW_ACTION_WORD\[target\.action\]\)/)
assert.ok(
request.indexOf('data.isMock') < request.indexOf('resultAction.value = target'),
'isMock 判断要发生在打开确认框之前,示例数据下不弹一个点了也没用的框',
)
})
test('访客只读时按钮保留但置灰(与既有 data.canWrite 同一口径)', () => {
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
assert.match(cell, /\{\{ rowActionLabel\(row\) \}\}/)
assert.match(cell, /@click="requestResultAction\(row\)"/)
})
test('行能不能改只看服务端返回的 state:退回草稿后必须重新可编辑', () => {
// 退回草稿(reopen)由服务端把 state 改回 draft;页面只有按 state 判定锁,行才会重新可编辑。
assert.match(
ENTRY_SOURCE,
/function rowLocked\(row: EditableRow\): boolean \{\n\s*return row\.resultState === 'published'\n\}/,
)
assert.match(ENTRY_SOURCE, /resultState: result\.state \?\? null|resultState: result\?\.state \?\? null/)
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| rowLocked\(row\)"/)
})
test('已作废的行有独立徽标,不会被显示成「已发布」', () => {
assert.match(
ENTRY_SOURCE,
/v-if="row\.resultState === 'published' && row\.status === 'withdrawn'"/,
)
assert.match(ENTRY_SOURCE, />已作废<\/span>/)
})
test('表格多了一列「操作」,空状态行的 colspan 跟着改', () => {
assert.match(ENTRY_SOURCE, /<th class="center action-col">操作<\/th>/)
assert.match(ENTRY_SOURCE, /<td colspan="12">/)
assert.doesNotMatch(ENTRY_SOURCE, /<td colspan="11">/)
})
// ── ConfirmDialog 的必填原因 ────────────────────────────────────────────────
test('原因为空时不提交(emit confirm),只亮出提示', () => {
const submit = section(DIALOG_SOURCE, 'function requestConfirm(', 'function onKeydown(')
assert.match(submit, /if \(reasonMissing\.value\) \{/)
assert.match(submit, /reasonTouched\.value = true/)
assert.ok(
submit.indexOf('reasonMissing.value') < submit.indexOf("emit('confirm'"),
'必填判断要在 emit 之前',
)
assert.match(submit, /emit\('confirm', reason\.value\.trim\(\)\)/)
})
test('每次打开弹窗都清空上一次的原因', () => {
assert.match(DIALOG_SOURCE, /watch\(\n\s*\(\) => props\.open,/)
assert.match(DIALOG_SOURCE, /reason\.value = ''\n\s*reasonTouched\.value = false/)
})
test('没有必填原因的老用法不受影响:reasonRequired 默认 false', () => {
assert.match(DIALOG_SOURCE, /reasonRequired: false/)
assert.match(DIALOG_SOURCE, /<label v-if="reasonRequired" class="confirm-reason">/)
})
@@ -0,0 +1,206 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 新增接口的契约(P2 A16 / A41 / C11):
* 作废成绩、把作废的成绩退回草稿、破纪录登记列表 / 驳回、历史纪录的登记与删除。
*
* 这些路径都由服务端契约冻结,前端只负责「发对地址、传对字段、别自作主张降级」。
* 尤其是 recordBreaks:它**不能**在连不上服务端时退化成空数组——
* 那会把「读不到」显示成「没有已批准的破纪录」,现场据此以为加分从来没批过。
*/
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' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就绑定了当时的 fetch。
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(response: () => Response): void {
lastRequest = {}
reply = response
}
function sentBody(): Record<string, unknown> {
return JSON.parse(String(lastRequest.body)) as Record<string, unknown>
}
const { ApiError, adminApi } = await import('../api/client')
const BREAK = {
id: 12,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'approved',
approvedBy: 1,
approvedAt: '2026-09-12T09:00:00Z',
note: null,
}
// ── 成绩:作废 / 退回草稿 ───────────────────────────────────────────────────
test('作废一条成绩:POST /api/results/{id}/void,原因随请求体发出', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 9001, status: 'withdrawn', state: 'published' } }))
const result = await adminApi.voidResult(9001, { reason: '成绩抄错' })
assert.equal(lastRequest.url, '/api/results/9001/void')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '成绩抄错')
// 幂等键由客户端注入,服务端靠它做重试去重。
assert.equal(typeof sentBody().requestId, 'string')
assert.equal(result.status, 'withdrawn')
})
test('退回草稿:POST /api/results/{id}/reopen,后端的业务错误原样抛出', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 9001, status: 'ok', state: 'draft' } }))
const result = await adminApi.reopenResult(9001, { reason: '改回来' })
assert.equal(lastRequest.url, '/api/results/9001/reopen')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '改回来')
assert.equal(result.state, 'draft')
// 「这条没被作废」是服务端的状态判断,不能让前端吞成成功。
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'STATE_INVALID', message: '该成绩未作废,无需重新录入' } }, 409),
)
await assert.rejects(
() => adminApi.reopenResult(9001, { reason: '改回来' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'STATE_INVALID')
assert.equal(error.message, '该成绩未作废,无需重新录入')
return true
},
)
})
// ── 破纪录登记 ──────────────────────────────────────────────────────────────
test('登记列表按 state 过滤,字段(含新增的 note)原样透传', async () => {
respondWith(() => jsonResponse({ ok: true, data: [BREAK, { ...BREAK, id: 11, note: '误判' }] }))
const list = await adminApi.recordBreaks('approved')
assert.equal(lastRequest.url, '/api/record-breaks?state=approved')
assert.equal(lastRequest.method, 'GET')
assert.equal(list.length, 2)
assert.equal(list[0]?.bonusPoints, 5)
assert.equal(list[0]?.note, null)
assert.equal(list[1]?.note, '误判')
})
test('登记列表读不到时抛错,不退化成空数组', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => adminApi.recordBreaks('approved'),
(error: unknown) => error instanceof ApiError && error.code === 'NETWORK',
)
})
test('驳回 / 作废登记:POST /api/record-breaks/{id}/reject,原因必填', async () => {
respondWith(() => jsonResponse({ ok: true, data: { ...BREAK, state: 'rejected', note: '现场判定不是破纪录' } }))
const updated = await adminApi.rejectBreak(12, '现场判定不是破纪录')
assert.equal(lastRequest.url, '/api/record-breaks/12/reject')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '现场判定不是破纪录')
assert.equal(updated.state, 'rejected')
assert.equal(updated.note, '现场判定不是破纪录')
})
// ── 纪录库:新增历史纪录 / 删除 ─────────────────────────────────────────────
test('新增历史纪录:POST /api/records,返回体里的 source 原样带回', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { id: 3, eventId: 101, level: 'meet', resultText: '11.20', source: 'history', isCurrent: false },
}),
)
const created = await adminApi.createRecord({
eventId: 101,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
holderName: '张三',
holderTeamId: 1,
achievedAt: '2025-09-01',
source: 'history',
})
assert.equal(lastRequest.url, '/api/records')
assert.equal(lastRequest.method, 'POST')
const body = sentBody()
assert.equal(body.eventId, 101)
assert.equal(body.level, 'meet')
assert.equal(body.resultValue, 11.2)
assert.equal(body.holderName, '张三')
assert.equal(body.holderTeamId, 1)
assert.equal(body.achievedAt, '2025-09-01')
assert.equal(created.source, 'history')
})
test('删除纪录:DELETE /api/records/{id},空响应也算成功', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.removeRecord(3)
assert.equal(lastRequest.url, '/api/records/3')
assert.equal(lastRequest.method, 'DELETE')
assert.equal(lastRequest.body, undefined)
})
test('删不存在 / 已被别人删掉的纪录时 404 原文原样抛出', async () => {
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '纪录不存在' } }, 404))
await assert.rejects(
() => adminApi.removeRecord(99),
(error: unknown) => error instanceof ApiError && error.status === 404 && error.message === '纪录不存在',
)
})
// ── 仪表盘的两个计数 ────────────────────────────────────────────────────────
test('草稿待发布数取分页接口的 total,不拉全量列表', async () => {
respondWith(() => jsonResponse({ ok: true, data: { items: [], total: 17, page: 1, size: 1 } }))
const total = await adminApi.draftResultCount()
assert.equal(lastRequest.url, '/api/results?state=draft&page=1&size=1')
assert.equal(total, 17)
})
test('计数接口读不到时抛错,由卡片显示 —(不用 0 冒充)', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(() => adminApi.draftResultCount())
})
+196
View File
@@ -0,0 +1,196 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { RecordBreak } from '@/shared'
import {
IGNORE_BREAK_REASON,
breakBonusText,
breakKey,
breakNote,
handledBreakKeys,
RECORD_LEVEL_LABELS,
RECORD_LEVEL_OPTIONS,
recordSourceLabel,
} from '../pages/recordBreaks'
/**
* 破纪录的驳回 / 作废(P2 A41)。
*
* 以前只有「登记 + 批准」两条路:误判的纪录在系统内无法纠正,「忽略」还只存在浏览器内存里
* (刷新就回来)。现在驳回与作废都落服务端登记表,「已处理」的键用来抑制前端推导出来的疑似列表。
*
* 项目没有组件测试工具,纯逻辑直接测,界面接线读源码断言。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
function section(source: string, start: string, end: string): string {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
return source.slice(from, to)
}
function makeBreak(overrides: Partial<RecordBreak>): RecordBreak {
return {
id: 1,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'pending',
approvedBy: null,
approvedAt: null,
...overrides,
}
}
// ── 已处理登记的键(抑制前端推导出的疑似列表) ───────────────────────────────
test('疑似条目的键与服务端登记行的键必须能对上(对不上就抑制不掉)', () => {
assert.equal(breakKey(101, 7001, 'meet'), '101-7001-meet')
})
test('approved / rejected 的登记会被抑制,pending 的不会', () => {
const keys = handledBreakKeys([
makeBreak({ id: 1, state: 'approved' }),
makeBreak({ id: 2, state: 'rejected', entryId: 7002 }),
makeBreak({ id: 3, state: 'pending', entryId: 7003 }),
])
assert.equal(keys.has(breakKey(101, 7001, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7002, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7003, 'meet')), false)
})
test('同一报名项的不同档位是两条独立的登记', () => {
const keys = handledBreakKeys([makeBreak({ state: 'approved', level: 'province' })])
assert.equal(keys.has(breakKey(101, 7001, 'province')), true)
assert.equal(keys.has(breakKey(101, 7001, 'meet')), false)
})
test('待确认列表按三层过滤(本机标记 + 服务端已处理)', () => {
const pending = section(RECORDS_SOURCE, 'const pendingBreaks = computed(', 'function levelRank(')
assert.match(pending, /!rejectedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!approvedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!handledKeys\.value\.has\(item\.key\)/)
})
test('推导疑似条目时用的就是 breakKey,两边不会各算一套', () => {
assert.match(RECORDS_SOURCE, /key: breakKey\(event\.id, entry\.id, best\.level\)/)
})
// ── 字段容错 ────────────────────────────────────────────────────────────────
test('来源:history / current 各有文案,缺字段显示 —(不冒充「历史」)', () => {
assert.equal(recordSourceLabel('history'), '历史')
assert.equal(recordSourceLabel('current'), '本届')
assert.equal(recordSourceLabel(null), '—')
assert.equal(recordSourceLabel(undefined), '—')
})
test('登记说明缺字段时不渲染', () => {
assert.equal(breakNote({ note: '现场判定不是破纪录' }), '现场判定不是破纪录')
assert.equal(breakNote({ note: ' 驳回 ' }), '驳回')
assert.equal(breakNote({ note: '' }), null)
assert.equal(breakNote({ note: null }), null)
assert.equal(breakNote({}), null)
})
test('加分缺字段时显示 —,不渲染成「+—」', () => {
assert.equal(breakBonusText({ bonusPoints: 5 }), '+5')
assert.equal(breakBonusText({ bonusPoints: 2.5 }), '+2.5')
assert.equal(breakBonusText({ bonusPoints: null }), '—')
assert.equal(breakBonusText({}), '—')
})
test('四个档位的文案与下拉选项齐全', () => {
assert.deepEqual(RECORD_LEVEL_OPTIONS.map((option) => option.value), [
'meet',
'city',
'province',
'national',
])
assert.equal(RECORD_LEVEL_LABELS.meet, '赛会')
assert.equal(RECORD_LEVEL_LABELS.national, '全国')
})
// ── 已批准的破纪录卡片 ──────────────────────────────────────────────────────
test('卡片数据来自 GET /api/record-breaks?state=approved', () => {
const loadBreaks = section(RECORDS_SOURCE, 'async function loadBreaks(', 'async function loadSuspected(')
assert.match(loadBreaks, /adminApi\.recordBreaks\('approved'\)/)
assert.match(loadBreaks, /adminApi\.recordBreaks\('rejected'\)/)
// 两态分开取、任一失败都只影响这张卡片:用 allSettled,不用 Promise.all(一个挂全挂)。
assert.match(loadBreaks, /Promise\.allSettled\(\[/)
assert.match(loadBreaks, /breaksUnavailable\.value = failure !== undefined/)
assert.match(RECORDS_SOURCE, /<span class="mx-card-title">已批准的破纪录<\/span>/)
})
test('接口读不到时卡片显示空态说明,不让整页报错', () => {
assert.match(RECORDS_SOURCE, /v-else-if="breaksUnavailable"/)
assert.match(RECORDS_SOURCE, /读不到破纪录登记/)
assert.match(RECORDS_SOURCE, /示例数据模式或后端版本较旧时没有这个接口/)
// 空态在「没有已批准的破纪录」之前判断:读不到不能被说成「没有」。
assert.ok(
RECORDS_SOURCE.indexOf('v-else-if="breaksUnavailable"') <
RECORDS_SOURCE.indexOf('approvedBreaks.length === 0'),
)
})
test('访客 403 与后端太旧分开说:权限问题不能显示成「接口不可用」', () => {
assert.match(RECORDS_SOURCE, /breaksForbidden\.value = failures\.some\(\(item\) => isForbidden\(item\.reason\)\)/)
assert.match(RECORDS_SOURCE, /访客只读:看不到破纪录登记/)
assert.match(RECORDS_SOURCE, /要求录入员及以上权限/)
})
test('每条登记一个「作废」按钮,调用 reject 并带上必填原因', () => {
assert.match(RECORDS_SOURCE, />\s*作废\s*<\/button>/)
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await adminApi\.rejectBreak\(item\.id, reason\)/)
assert.match(RECORDS_SOURCE, /:reason-required="true"/)
assert.match(RECORDS_SOURCE, /@confirm="confirmRevoke"/)
})
test('作废成功后刷新纪录库与榜单,并提示加分已撤回', () => {
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await Promise\.all\(\[loadRecords\(\), data\.refreshStandings\(\)\]\)/)
assert.match(revoke, /'加分已撤回,该级别纪录已恢复上一条'/)
assert.ok(
revoke.indexOf('rejectBreak') < revoke.indexOf('loadRecords()'),
'先作废再刷新,顺序反了会把旧数据当新数据',
)
})
test('访客只读时「作废」按钮置灰', () => {
const card = section(RECORDS_SOURCE, '<span class="mx-card-title">已批准的破纪录</span>', '</section>')
assert.match(card, /:disabled="!canWrite \|\| revoking"/)
})
// ── 「忽略」改成持久 ────────────────────────────────────────────────────────
test('忽略先登记再驳回,原因是固定的「现场判定不是破纪录」', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /adminApi\.client\.records\.createBreak\(/)
assert.match(reject, /await adminApi\.rejectBreak\(created\.id, IGNORE_BREAK_REASON\)/)
assert.equal(IGNORE_BREAK_REASON, '现场判定不是破纪录')
assert.ok(
reject.indexOf('createBreak') < reject.indexOf('rejectBreak(created.id'),
'必须先拿到 pending 行才能驳回',
)
})
test('写不进服务端时才退回本机标记,并如实说明刷新后会回来', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /toast\.warning\('已在本机忽略(未连接服务端)', '没有写入服务端,刷新页面后该条会重新出现'\)/)
// 不能出现「只在本地隐藏」这种旧说法:现在默认是落服务端的。
assert.doesNotMatch(RECORDS_SOURCE, /接口未提供拒绝动作/)
})
test('忽略确认框的正文按模式说清后果(示例数据下不能承诺「不会再出现」)', () => {
const message = section(RECORDS_SOURCE, 'const rejectMessage = computed(', 'async function confirmReject(')
assert.match(message, /示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。/)
assert.match(message, /会在服务端登记为一条破纪录记录并立即驳回/)
assert.match(RECORDS_SOURCE, /:message="rejectMessage"/)
})
+139
View File
@@ -0,0 +1,139 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { emptyRecordForm, planRecordInput, type RecordFormState } from '../pages/recordForm'
/**
* 纪录库的历史纪录录入与删除(P2 C11)。
*
* `records.source='history'` 以前没有任何写入路径,所以「来源」这一列恒为「本届」——
* 界面上承诺的「历史 / 本届」实际只有一半。现在接上 POST /api/records(固定写 history)
* 与 DELETE /api/records/{id}(删当前纪录时服务端恢复上一条)。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
function section(source: string, start: string, end: string): string {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
return source.slice(from, to)
}
function form(overrides: Partial<RecordFormState>): RecordFormState {
return { ...emptyRecordForm(101), ...overrides }
}
// ── 表单口径 ────────────────────────────────────────────────────────────────
test('没选小项时不提交', () => {
const plan = planRecordInput(form({ eventId: null, resultText: '11.20' }))
assert.equal(plan.ok, false)
assert.equal(plan.ok === false ? plan.error : '', '请选择小项')
})
test('成绩值与成绩文本至少填一个(历史资料常常只有手抄文本)', () => {
const empty = planRecordInput(form({ resultValue: '', resultText: ' ' }))
assert.equal(empty.ok, false)
assert.equal(empty.ok === false ? empty.error : '', '成绩值或成绩文本至少填一个')
const textOnly = planRecordInput(form({ resultText: '11.20' }))
assert.equal(textOnly.ok, true)
assert.equal(textOnly.ok === true ? textOnly.input.resultText : '', '11.20')
assert.equal(textOnly.ok === true ? textOnly.input.resultValue : 1, null)
const valueOnly = planRecordInput(form({ resultValue: '11.2' }))
assert.equal(valueOnly.ok, true)
assert.equal(valueOnly.ok === true ? valueOnly.input.resultValue : 0, 11.2)
assert.equal(valueOnly.ok === true ? valueOnly.input.resultText : 'x', null)
})
test('成绩值写错时明确报错,不静默丢成空值', () => {
const plan = planRecordInput(form({ resultValue: '11.2秒' }))
assert.equal(plan.ok, false)
assert.match(plan.ok === false ? plan.error : '', /数字/)
})
test('来源固定 history;空字符串归一成 null(服务端不该存空串)', () => {
const plan = planRecordInput(
form({ resultValue: '11.2', holderName: ' 张三 ', achievedAt: '', holderTeamId: null }),
)
assert.equal(plan.ok, true)
if (plan.ok !== true) return
assert.equal(plan.input.source, 'history')
assert.equal(plan.input.holderName, '张三')
assert.equal(plan.input.achievedAt, null)
assert.equal(plan.input.holderTeamId, null)
assert.equal(plan.input.level, 'meet')
})
test('保持单位与创造日期有值时原样提交', () => {
const plan = planRecordInput(form({ resultValue: '11.2', holderTeamId: 3, achievedAt: '2025-09-01' }))
assert.equal(plan.ok, true)
if (plan.ok !== true) return
assert.equal(plan.input.holderTeamId, 3)
assert.equal(plan.input.achievedAt, '2025-09-01')
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('纪录库卡片有「新增历史纪录」入口,表单字段齐全', () => {
assert.match(RECORDS_SOURCE, /新增历史纪录\s*<\/button>/)
const modal = section(RECORDS_SOURCE, '新增历史纪录表单', 'class="mx-modal-foot"')
assert.match(modal, /v-for="event in data\.events"/)
assert.match(modal, /v-for="option in RECORD_LEVEL_OPTIONS"/)
assert.match(modal, /createForm\.resultValue/)
assert.match(modal, /createForm\.resultText/)
assert.match(modal, /createForm\.holderName/)
assert.match(modal, /createForm\.holderTeamId/)
assert.match(modal, /type="date"/)
})
test('提交走 POST /api/records 并刷新列表与疑似推导', () => {
const submit = section(RECORDS_SOURCE, 'async function submitCreate(', 'function requestDelete(')
assert.match(submit, /const plan = planRecordInput\(createForm\.value\)/)
assert.match(submit, /await adminApi\.createRecord\(plan\.input\)/)
assert.match(submit, /await loadRecords\(\)/)
assert.match(submit, /await loadSuspected\(\)/)
assert.ok(
submit.indexOf('createRecord') < submit.indexOf('loadRecords()'),
'先写入再刷新,顺序反了会把旧数据当新数据',
)
})
test('表单校验不通过时把错误显示出来,不发请求', () => {
const submit = section(RECORDS_SOURCE, 'async function submitCreate(', 'function requestDelete(')
assert.match(submit, /if \(!plan\.ok\) \{\n\s*createError\.value = plan\.error\n\s*return\n\s*\}/)
assert.match(RECORDS_SOURCE, /v-if="createError !== ''"/)
})
test('每行一个「删除」,确认框说清「当前纪录会恢复上一条」', () => {
assert.match(RECORDS_SOURCE, />\s*删除\s*<\/button>/)
assert.match(RECORDS_SOURCE, /await adminApi\.removeRecord\(record\.id\)/)
assert.match(RECORDS_SOURCE, /若它是当前纪录,会恢复上一条/)
assert.match(RECORDS_SOURCE, /@confirm="confirmDelete"/)
})
test('列表把 source 显示出来(历史 / 本届),缺字段显示 —', () => {
assert.match(RECORDS_SOURCE, /\{\{ recordSourceLabel\(record\.source\) \}\}/)
assert.match(RECORDS_SOURCE, /record\.source === 'history' \? 'mx-badge-neutral' : 'mx-badge-info'/)
})
test('guest 只读:新增与删除都置灰,写操作前置检查先拦示例数据', () => {
const create = section(RECORDS_SOURCE, 'function openCreate(', 'function closeCreate(')
assert.match(create, /if \(blockSampleData\('新增历史纪录'\)\) return/)
assert.match(create, /if \(!canWrite\.value\) \{/)
const remove = section(RECORDS_SOURCE, 'function requestDelete(', 'async function confirmDelete(')
assert.match(remove, /if \(blockSampleData\('删除纪录'\)\) return/)
assert.match(remove, /if \(!canWrite\.value\) \{/)
const head = section(RECORDS_SOURCE, '<span class="mx-card-title">纪录库</span>', '<div class="mx-tabs">')
assert.match(head, /:disabled="!canWrite"/)
assert.match(RECORDS_SOURCE, /:disabled="!canWrite \|\| deleting"/)
})
test('示例数据拦截用统一口径(不是各写一句提示)', () => {
const block = section(RECORDS_SOURCE, 'function blockSampleData(', 'function requestRevoke(')
assert.match(block, /if \(!data\.isMock\) return false/)
assert.match(block, /sampleDataBlockedNotice\(action\)/)
assert.match(block, /toast\.error\(notice\.title, notice\.detail\)/)
})
+43
View File
@@ -34,7 +34,10 @@ import type {
PublishPayload, PublishPayload,
RecordBreak, RecordBreak,
RecordBreakInput, RecordBreakInput,
RecordBreakState,
RecordEntry, RecordEntry,
RecordEntryInput,
ReopenResultPayload,
Result, Result,
ResultQuery, ResultQuery,
RulePack, RulePack,
@@ -324,6 +327,14 @@ export interface ResultsApi {
publish(payload: PublishPayload): Promise<Result[]> publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]> unpublish(payload: PublishPayload): Promise<Result[]>
void(id: number, payload: VoidResultPayload): Promise<Result> void(id: number, payload: VoidResultPayload): Promise<Result>
/**
* 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。
*
* 与 void 成对:作废只把 `status` 改成 withdrawn 并撤掉这一条的积分,
* 想把这一条改回来只能先 reopen 退回草稿,再用常规的存草稿 / 发布流程重新发。
* 服务端在同一事务里重算推榜,返回更新后的成绩。
*/
reopen(id: number, payload: ReopenResultPayload): Promise<Result>
} }
export interface StandingsApi { export interface StandingsApi {
@@ -342,6 +353,27 @@ export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]> list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak> createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak> approveBreak(id: number): Promise<RecordBreak>
/**
* 破纪录登记列表(`GET /api/record-breaks?state=...`,recorder 及以上可读)。
*
* 不传 state 时由服务端决定(当前返回全部);按 id 降序。
* 「已批准的破纪录」卡片要 state=approved,仪表盘的「待确认」计数要 state=pending。
*/
listBreaks(state?: RecordBreakState): Promise<RecordBreak[]>
/**
* 驳回 / 作废一条登记(`POST /api/record-breaks/{id}/reject`,原因必填)。
*
* 原本是 approved 的登记被驳回时,服务端会撤掉那笔加分并把该级别纪录恢复成上一条。
*/
rejectBreak(id: number, reason: string): Promise<RecordBreak>
/** 登记一条历史纪录(`POST /api/records`);服务端固定把 source 写成 history。 */
createRecord(input: RecordEntryInput): Promise<RecordEntry>
/**
* 删除一条纪录(`DELETE /api/records/{id}`)。
*
* 删掉的若是当前纪录(isCurrent),服务端会把同级别的上一条恢复成当前。
*/
removeRecord(id: number): Promise<void>
} }
/** /**
@@ -590,6 +622,8 @@ function createResultsApi(http: Http): ResultsApi {
unpublish: (payload) => unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }), http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }), void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
reopen: (id, payload) =>
http.request<Result>(`/results/${id}/reopen`, { method: 'POST', body: payload }),
} }
} }
@@ -831,6 +865,15 @@ export class ApiClient {
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }), createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) => approveBreak: (id) =>
http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }), http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }),
// state 缺省时不带查询串:让服务端用自己的默认口径,前端不替它决定「全部」怎么写。
listBreaks: (state) =>
http.request<RecordBreak[]>('/record-breaks', {
query: state === undefined ? undefined : { state },
}),
rejectBreak: (id, reason) =>
http.request<RecordBreak>(`/record-breaks/${id}/reject`, { method: 'POST', body: { reason } }),
createRecord: (input) => http.request<RecordEntry>('/records', { method: 'POST', body: input }),
removeRecord: (id) => http.requestVoid(`/records/${id}`, { method: 'DELETE' }),
} }
this.display = { this.display = {
+29
View File
@@ -377,6 +377,12 @@ export interface VoidResultPayload {
requestId?: string requestId?: string
} }
/** POST /api/results/{id}/reopen 的请求体:把已作废的一条成绩退回草稿继续录入。 */
export interface ReopenResultPayload {
reason: string
requestId?: string
}
export interface ResultQuery extends PageQuery { export interface ResultQuery extends PageQuery {
eventId?: number eventId?: number
state?: ResultState state?: ResultState
@@ -489,6 +495,24 @@ export interface RecordEntry {
source: RecordSource source: RecordSource
} }
/**
* POST /api/records 的请求体:登记一条历史纪录。
*
* 成绩值 / 成绩文本至少给一个——历史资料常常只有「12.34 秒」这种手抄文本,
* 也可能只有换算后的数值,两种都允许;服务端按给的字段补齐另一侧。
*/
export interface RecordEntryInput {
eventId: number
level: RecordLevel
resultValue?: number | null
resultText?: string | null
holderName?: string | null
holderTeamId?: number | null
achievedAt?: string | null
/** 服务端把新登记的历史纪录固定写成 history;字段保留是为了与响应体同构。 */
source?: RecordSource
}
export type RecordBreakState = 'pending' | 'approved' | 'rejected' export type RecordBreakState = 'pending' | 'approved' | 'rejected'
export interface RecordBreak { export interface RecordBreak {
@@ -502,6 +526,11 @@ export interface RecordBreak {
state: RecordBreakState state: RecordBreakState
approvedBy: number | null approvedBy: number | null
approvedAt: string | null approvedAt: string | null
/**
* 本次登记 / 驳回的说明(驳回时写的是驳回原因)。
* 旧服务端 / 旧缓存不下发它,所以可选;界面缺字段时不渲染那一行,不显示 undefined。
*/
note?: string | null
} }
export interface RecordBreakInput { export interface RecordBreakInput {