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:
1 parent
6841cf49aa
commit
ed2c0b3f3c
35 files changed
+3998
-110
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scoreboard-console",
|
||||
"productName": "Scoreboard Console",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"author": "AskaEth",
|
||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||
|
||||
+88
-11
@@ -53,12 +53,22 @@
|
||||
|
||||
## 3. 仪表盘
|
||||
|
||||
用于快速掌握全局:
|
||||
用于快速掌握全局。上方四张概览卡:领先单位、已发布项目数 / 总项目数、纪录条目数、实时连接状态。
|
||||
|
||||
- 领先单位、已发布项目数 / 总项目数、纪录条目数、实时连接状态;
|
||||
- 「当前赛程」列出小项及状态(已发布 / 待录入),点「录入」直接跳到该项目;
|
||||
下面一组是四张现场卡片——每个数据源各自独立,**任何一张读不到时只让那一张显示 `—`,不影响其它卡片**:
|
||||
|
||||
| 卡片 | 显示什么 |
|
||||
| --- | --- |
|
||||
| 今天的小项 | 排期(`scheduledAt`)落在**本机时区的当天**的小项数量与进度(`已发布 X / 共 Y`)。排期为空的小项不属于任何一天;项目列表还没加载完时显示「数据加载中…」,确实没有排期才显示「今天没有排期」 |
|
||||
| 草稿待发布 | 还没发布的成绩草稿条数(点了「存草稿」但还没「发布」的);读不到时显示 `—`,不会用 0 冒充「一条都没有」 |
|
||||
| 待确认破纪录 | 状态为**待确认**的破纪录登记条数,点「去确认」跳到「破纪录」页;访客只读时显示「访客只读」 |
|
||||
| 大屏在线数 | `在线 / 总数`(读在线大屏实例列表)。副标题是**本机管理端的实时连接状态**(本机在线 / 连接中 / 未连接 / 连接中断 / 示例数据)——服务端没有「管理端在线数」这种统计,所以这里如实显示本机状态,不编一个数字出来 |
|
||||
|
||||
其余两块:
|
||||
|
||||
- 「当前赛程」列出小项及状态(已发布 / 待录入),今天的小项带「今天」标记,点「录入」直接跳到该项目;
|
||||
- 「实时积分 Top 3」显示前三名与奖牌数;
|
||||
- 有待录入项目时显示「待办」提醒。
|
||||
- 有待录入项目、草稿或待确认破纪录时显示「待办」提醒。
|
||||
|
||||
---
|
||||
|
||||
@@ -90,7 +100,7 @@
|
||||
|
||||
### 4.2 录入成绩
|
||||
|
||||
表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
|
||||
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
|
||||
|
||||
| 列 | 操作 |
|
||||
| --- | --- |
|
||||
@@ -99,6 +109,7 @@
|
||||
| 状态 | 下拉选择:**有效**、**DNS 弃赛**、**DQ 取消**、**DNF 未完赛**、**退赛** |
|
||||
| 积分(自动) | 随成绩实时计算,仅供参考 |
|
||||
| 备注 | 自由填写,示例文本是 `可留空` |
|
||||
| 操作 | 已发布的行显示 **「作废本条」**(已作废的行改显示 **「重新录入」**);草稿行这里是 `—`(草稿本来就能直接改)。详见 4.10 |
|
||||
|
||||
**成绩写法**:能接受的写法比过去多,中文输入法下的全角冒号 `1:06`、全角数字也认。
|
||||
|
||||
@@ -168,7 +179,7 @@
|
||||
3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去(末句在延迟结算下会换成「名次与榜单等该组全部录完后再更新」,见下面);
|
||||
4. 确认后这批成绩立即上屏;**实时结算(默认)下,名次与积分先按已发布的这部分算**,大屏立刻有内容(延迟结算下先不动,见下);
|
||||
5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新;
|
||||
6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。
|
||||
6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布。**只错一条**时不必撤销整组发布,用该行的「作废本条 → 重新录入」(见 4.10);整组都要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。
|
||||
|
||||
> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。
|
||||
|
||||
@@ -186,6 +197,8 @@
|
||||
2. 系统作废该组已产生的积分流水,大屏回退到发布前状态;
|
||||
3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。
|
||||
|
||||
> **只错了一条成绩时**,不必撤销整组发布、把别人的成绩也一起翻下来——用该行「操作」列的「作废本条 / 重新录入」即可,见 4.10。
|
||||
|
||||
### 4.8 勾选与批量操作(退赛 / 恢复 / 移出报名)
|
||||
|
||||
表格每行最左边有勾选框,表头那个勾选框是**全选当前筛选范围内的行**——它只勾当前组别、当前批次筛选下看得见的行,被筛掉的行不会被选上。勾选后工具栏里出现「已选 N 行」与三个按钮:
|
||||
@@ -223,6 +236,34 @@
|
||||
- **权限**:读得到成绩的人都能看到这块卡片(访客只读,输入框置灰并写明原因);**能保存的是录入员及以上**(与成绩录入同权限)。示例数据模式下不能保存;
|
||||
- 团体名次是**独立保存**的:点「保存团体名次」只提交这块卡片,不会顺手发布成绩;成绩仍然要另外「发布」。
|
||||
|
||||
### 4.10 单条作废 / 重新录入(手滑填错了怎么办)
|
||||
|
||||
已发布的成绩行在表格里仍然是**锁着的**(成绩、名次、状态、备注都改不了)——这是有意的:已发布的行已经上屏、已经计分,允许就地改会让「看到的榜单」和「改到一半的数据」对不上。要改就必须先明确地把它退回来。表格最后一列 **「操作」** 就是这件事的入口,只有已发布的行才有按钮。
|
||||
|
||||
**两个按钮,什么时候用哪个**
|
||||
|
||||
| 按钮 | 什么时候出现 | 做什么 |
|
||||
| --- | --- | --- |
|
||||
| **作废本条** | 这一行是**已发布且正常**的成绩 | 让这**一条**成绩退出名次与积分(同组其它人重新排名),但**不改**它的原始记录——需要改回来时还是用「重新录入」 |
|
||||
| **重新录入** | 这一行是**已作废**的(红徽标「已作废」) | 把这一条**退回草稿**(原来填的成绩还在格里,直接改就行)、不再计入名次与积分,之后就能像普通草稿一样编辑;改完再发布本组 |
|
||||
|
||||
两个按钮**都必须填写原因**(确认框里的「作废原因 / 退回原因」),留空时确认键点了也不会提交——原因会写进审计日志,事后能查清是谁、什么时候、为什么动的这一条。
|
||||
|
||||
**点完会立刻发生什么**
|
||||
|
||||
- 服务端在同一个事务里**按当前已发布成绩重算**该小项该组的名次与积分,并**立即推送大屏**;
|
||||
- 这一步**不受**「分批发布时实时更新榜单」这个延迟结算开关影响(它改的是「这条成绩算不算数」,不是「录了多少」),不会出现「榜上还有分、成绩却已经撤了」;
|
||||
- 两者都会留痕:**审计日志**(第 13 节)里的动作分别是 `result.void` 与 `result.reopen`,含操作人、时间与填写的原因。注意「变更历史」(第 12 节)记的是**发布 / 撤销发布**这类整组版本,单条作废 / 重新录入**不会**在那里新增一个版本节点。
|
||||
|
||||
**手滑填错了怎么办:两条路**
|
||||
|
||||
1. **只错了一条**(最常见):在那一行点 **「作废本条」** 填原因 → 这一条退出名次与积分、大屏立刻更新 → 同一行点 **「重新录入」**(此时按钮已变成它)填原因 → 退回草稿,该行可以编辑了 → 改对成绩 → 再点页头的 **「发布」**(该组还有别的已发布成绩时显示为「发布本批」,见 4.6)。全程只动这一条,同组别人的成绩一直留在榜上;
|
||||
2. **整组都要改**(例如整组被判罚、名次口径整体调整):点页面上的 **「撤销发布」** → 修改 → 重新发布(见 4.7)。这条会把该组已产生的积分流水整体作废再重发,大屏会先回退一次。
|
||||
|
||||
> 只错一条时**优先用第 1 条路**:撤销发布会让整组的分数先消失再回来,大屏在中间会闪一下;作废 / 重新录入只影响这一条。
|
||||
|
||||
> 需要先「存草稿」再点这两个按钮:作废 / 退回会重算并整表刷新,未保存的编辑会被刷新覆盖,页面会拦下并提示。
|
||||
|
||||
---
|
||||
|
||||
## 5. 成绩审核
|
||||
@@ -245,17 +286,52 @@
|
||||
|
||||
页面顶部会显示破纪录加分开关状态(来自规则包 `recordBonus.enabled`)。当前版本默认关闭;若当前角色读不到规则包,会如实显示「未知」,以服务端校验结果为准。
|
||||
|
||||
### 6.1 疑似破纪录
|
||||
### 6.1 疑似破纪录与「已批准的破纪录」
|
||||
|
||||
- 系统用**已发布且有效**的成绩与当前纪录自动比对,推导出疑似破纪录,草稿不参与;
|
||||
- 列表显示小项、组别、单位 / 运动员、成绩、原纪录、档位(赛会 / 市级 / 省级 / 全国)、预计加分;
|
||||
- **确认加分**:登记破纪录并生成加分流水、更新纪录库;开关关闭时按钮不可用;
|
||||
- **拒绝**:当前版本服务端没有拒绝接口,此操作只在本地隐藏该条,刷新后会重新出现。
|
||||
- **拒绝**:判定这条不是破纪录时点它。系统会**在服务端先登记一条破纪录记录、再立即驳回**(原因固定写「现场判定不是破纪录」),所以**刷新后不会再出现在待确认列表里**,谁在什么时候否掉了哪一条也能在日志里查到。示例数据模式或后端未就绪时只在本机隐藏,提示会如实说明「刷新页面后该条会重新出现」。
|
||||
|
||||
#### 已批准的破纪录(批错了可以撤回)
|
||||
|
||||
「已批准的破纪录」卡片列出所有**已批准**的登记。这条接口**需要录入员及以上**才能读(含待审核的登记与驳回理由);访客只读时卡片会写明「访客只读:看不到破纪录登记」,不会假装成「一条都没有」。
|
||||
|
||||
每行显示小项、级别、成绩、加分、批准时间、说明,右端有:
|
||||
|
||||
- **作废**:批准时已经加过分,所以作废不只是改状态。点它会弹确认框,写明「作废后将撤回该笔加分,并恢复该级别的上一条纪录;大屏与此处纪录库会立即更新」,并要求填 **「作废原因」**(必填,留空点确认不会提交)。确认后服务端在同一个事务里**撤回该笔加分、重算并推榜、把该级别的当前纪录恢复为上一条**,完成后提示「加分已撤回,该级别纪录已恢复上一条」。
|
||||
- 如果该级别本来就没有更早的纪录,撤下后就留空——「无纪录」是事实,不会留一条刚被作废的纪录充数。
|
||||
|
||||
> 待确认的登记被驳回不涉及加分与纪录,服务端只改状态、记理由,不会白重算一次榜单。
|
||||
|
||||
### 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 天」;
|
||||
2. 左侧时间轴最新在上,节点显示动作(发布 / 撤销发布 / 编辑)、项目、操作人、时间与备注;
|
||||
3. 点节点,右侧显示操作人、影响单位与成绩前后对比(diff);
|
||||
4. 本页**不提供一键回滚**。纠错请到「成绩录入」走「撤销发布 → 修改 → 重新发布」。
|
||||
4. 本页**不提供一键回滚**。纠错请到「成绩录入」:只错一条走「作废本条 → 重新录入 → 修改 → 重新发布」(见 4.10),整组要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。
|
||||
|
||||
---
|
||||
|
||||
@@ -909,7 +985,8 @@
|
||||
草稿不会上屏。需要点「发布」(或 `Alt+Enter`),发布成功后立即更新。
|
||||
|
||||
**Q:发布后发现有错怎么办?**
|
||||
到「成绩录入」找到该组,点「撤销发布」→ 修改 → 重新发布。撤销会作废该组积分流水,大屏回退;全程可在「变更历史」追溯。
|
||||
只错一条成绩:到「成绩录入」找到那一行,点「作废本条」→ 再点「重新录入」→ 改 → 重新发布,只动这一条(见 4.10)。
|
||||
整组要改:点「撤销发布」→ 修改 → 重新发布。撤销会作废该组积分流水,大屏回退;全程可在「变更历史」追溯。
|
||||
|
||||
**Q:并列名次怎么显示?**
|
||||
成绩相同(或手动名次相同)即视为并列,显示同一名次,例如两个「3」。积分按规则包里的并列口径计算,默认是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名是第 5 名。奖牌也按并列处理,并列第 1 各记 1 金、无银牌。
|
||||
|
||||
+48
-1
@@ -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
@@ -34,7 +34,7 @@
|
||||
草稿不上屏。需要「发布」(或 `Alt+Enter`),发布后 1 秒内大屏更新。
|
||||
|
||||
**Q8:发布错了怎么改?**
|
||||
撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。
|
||||
撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。**只错了一条成绩时不必撤销整组**,用那一行的「作废本条 → 重新录入」即可,见 Q98。
|
||||
|
||||
**Q9:为什么「发布」是灰的?**
|
||||
常见原因:未登录、服务端断开、示例数据模式,或存在**会阻止发布**的校验错误(秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。**没录成绩不算错误、不挡发布**(见 Q77)。成绩格的报错会把**那一格的实际内容**打出来并对症给改法(见 Q75、Q76)。
|
||||
@@ -101,6 +101,8 @@
|
||||
**Q19:破纪录加分为什么点不了?**
|
||||
规则包里破纪录加分默认关闭(`recordBonus.enabled = false`)时按钮不可用;若当前角色读不到规则包,页面显示「未知」,可提交,由服务端校验决定是否加分。
|
||||
|
||||
> 这个开关只管「**确认加分**」能不能点,不影响的:疑似破纪录照样列出,「**拒绝**」照样能用(它不会加分),以及 **已批准但批错了的登记可以「作废」**(会撤回加分并恢复上一条纪录,见 Q99)。
|
||||
|
||||
**Q20:人工调整为什么点不了?**
|
||||
规则包里人工调整默认关闭(`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. 点「保存纪录」,提示「已新增历史纪录 · 来源记为「历史」」。
|
||||
|
||||
新录的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(仍留在库里)。纪录库的 **「来源」列**因此有了两种值:**历史** = 赛前手工登记的旧资料,**本届** = 本场赛事批准破纪录时自动写入的(以前没有历史录入入口,实际只会看到「本届」)。
|
||||
|
||||
**删除**:每行右端有「删除」(需**录入员及以上**)。确认框会说明「若它是当前纪录,会恢复上一条」,并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;删除不会触发重算。
|
||||
@@ -234,6 +234,9 @@ type recordBreakDTO struct {
|
||||
State string `json:"state"`
|
||||
ApprovedBy *int64 `json:"approvedBy"`
|
||||
ApprovedAt *string `json:"approvedAt"`
|
||||
// Note 是驳回 / 作废这条登记时写下的理由(0021 新增的列);从未被驳回的登记(含
|
||||
// 老库里的历史行)为 null——空理由与「没有理由」是两回事,所以这里用指针。
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -226,6 +241,53 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques
|
||||
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 里取公告正文与全屏时长;
|
||||
|
||||
@@ -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(¤tID, ¤tName); 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)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,37 @@ type recordBreakInput struct {
|
||||
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 是大屏控制指令的请求体。
|
||||
type displayCommandInput struct {
|
||||
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
|
||||
}
|
||||
@@ -726,10 +726,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
|
||||
// ---------- records ----------
|
||||
|
||||
// recordSourceHistory 是手工登记的历史纪录的来源标记,与 records.source 的 CHECK 一致。
|
||||
// 只认它:source='current' 只能由「确认破纪录」写出来(见 upsertCurrentRecord)。
|
||||
const recordSourceHistory = "history"
|
||||
|
||||
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
|
||||
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
|
||||
holder_name, holder_team_id, achieved_at, is_current, source
|
||||
FROM records WHERE is_current = 1`
|
||||
query := `SELECT ` + recordEntryColumns + ` FROM records WHERE is_current = 1`
|
||||
args := []any{}
|
||||
if eventID > 0 {
|
||||
query += ` AND event_id = ?`
|
||||
@@ -745,19 +747,10 @@ func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryD
|
||||
|
||||
out := make([]recordEntryDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r recordEntryDTO
|
||||
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 {
|
||||
r, err := scanRecordEntry(rows)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
r.HolderName = strPtr(holderName)
|
||||
r.HolderTeamID = i64Ptr(holderTeamID)
|
||||
r.AchievedAt = strPtr(achievedAt)
|
||||
out = append(out, r)
|
||||
}
|
||||
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) {
|
||||
var (
|
||||
b recordBreakDTO
|
||||
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)
|
||||
b, err := scanRecordBreak(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+recordBreakColumns+` FROM record_breaks WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
if err != nil {
|
||||
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, ¬e); err != nil {
|
||||
return b, err
|
||||
}
|
||||
b.ResultValue = value.Float64
|
||||
b.ResultText = text.String
|
||||
b.ApprovedBy = i64Ptr(approvedBy)
|
||||
b.ApprovedAt = strPtr(approvedAt)
|
||||
b.Note = strPtr(note)
|
||||
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) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -879,11 +912,14 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
}
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state != "pending" {
|
||||
if state != "pending" && state != "rejected" {
|
||||
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
|
||||
}
|
||||
// 重新批准(pending → approved 或 rejected → approved)都要清掉上一次的驳回理由:
|
||||
// note 的含义是「这条登记现在为什么被驳回」,批准后它已经不成立,留着只会误导。
|
||||
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 {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
@@ -914,6 +950,102 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
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(¤tID)
|
||||
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:同小项同档位的旧纪录改为非当前,
|
||||
// 新纪录标为 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 {
|
||||
@@ -952,6 +1084,161 @@ func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64
|
||||
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 ----------
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -942,37 +942,28 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
|
||||
return s.resultsByEvent(ctx, in.EventID)
|
||||
}
|
||||
|
||||
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() }()
|
||||
|
||||
// loadResultForAction 读一条成绩及其所属 (小项, 组别),供「作废 / 重新录入」这类单条成绩
|
||||
// 动作使用:两者要读的字段完全一样,各自写一遍扫描迟早会漂移。
|
||||
func loadResultForAction(ctx context.Context, q queryer, resultID int64) (resultDTO, int64, int64, error) {
|
||||
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 (
|
||||
eventID, catID int64
|
||||
rank, recordedBy, verifiedBy sql.NullInt64
|
||||
text, unit, recordedAt, verifiedAt, note sql.NullString
|
||||
value, override sql.NullFloat64
|
||||
)
|
||||
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
|
||||
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e, &catID); err != nil {
|
||||
err := q.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).
|
||||
Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
|
||||
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e, &catID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resultDTO{}, notFound("成绩不存在")
|
||||
return resultDTO{}, 0, 0, notFound("成绩不存在")
|
||||
}
|
||||
return resultDTO{}, internal(err)
|
||||
if err != nil {
|
||||
return resultDTO{}, 0, 0, internal(err)
|
||||
}
|
||||
before.EventID = eventID
|
||||
before.Rank = intPtr(rank)
|
||||
@@ -985,6 +976,23 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
before.VerifiedBy = i64Ptr(verifiedBy)
|
||||
before.VerifiedAt = strPtr(verifiedAt)
|
||||
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,
|
||||
`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)
|
||||
}
|
||||
|
||||
// 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 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
|
||||
|
||||
@@ -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("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
|
||||
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))
|
||||
@@ -275,8 +278,16 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
|
||||
// 纪录
|
||||
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))
|
||||
// 审核清单: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))
|
||||
// 驳回 / 作废(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))
|
||||
|
||||
@@ -38,6 +38,14 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
|
||||
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
|
||||
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()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -60,6 +68,9 @@ func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
|
||||
}
|
||||
ev.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = ev.Close() })
|
||||
if prepare != nil {
|
||||
prepare(ev)
|
||||
}
|
||||
if err := store.MigrateEvent(ev); err != nil {
|
||||
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, ¬e); 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(¬e); 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 的老库一次性升到最新。
|
||||
//
|
||||
// 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳;
|
||||
// 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0020 九条,
|
||||
// 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0021 十条,
|
||||
// 其中 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 {
|
||||
t.Fatalf("从 0001–0011 直接升到最新失败: %v", err)
|
||||
}
|
||||
@@ -213,6 +213,15 @@ func TestMigration0001ToLatestKeepsData(t *testing.T) {
|
||||
if countToMedals != 1 || countToTeamMedals != 1 {
|
||||
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"} {
|
||||
@@ -238,7 +247,7 @@ func TestMigration0001ToLatestKeepsData(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = fkRows.Close() }()
|
||||
if fkRows.Next() {
|
||||
t.Error("跨九条迁移升级后存在外键悬挂")
|
||||
t.Error("跨十条迁移升级后存在外键悬挂")
|
||||
}
|
||||
var fkEnabled int
|
||||
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 {
|
||||
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 也在:合法的拒绝了非法值才算真的生效。
|
||||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil {
|
||||
t.Error("升级后非法 event_type 应被 CHECK 拒绝")
|
||||
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
PublishPayload,
|
||||
RecordBreak,
|
||||
RecordBreakState,
|
||||
RecordEntry,
|
||||
RecordEntryInput,
|
||||
ReopenResultPayload,
|
||||
Result,
|
||||
SaveDraftPayload,
|
||||
ScoringProfile,
|
||||
@@ -27,6 +31,7 @@ import type {
|
||||
TeamBreakdown,
|
||||
TeamRanksInput,
|
||||
TeamRanksView,
|
||||
VoidResultPayload,
|
||||
} from '@/shared'
|
||||
import { mockDb } from './mock'
|
||||
|
||||
@@ -219,6 +224,18 @@ export const adminApi = {
|
||||
unpublish: (payload: PublishPayload): Promise<Result[]> =>
|
||||
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[]> =>
|
||||
call(() => client.standings.teams(), () => mockDb.standings()),
|
||||
|
||||
@@ -244,6 +261,36 @@ export const adminApi = {
|
||||
records: (): Promise<RecordEntry[]> =>
|
||||
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>> =>
|
||||
call(() => client.adjustments.list(query), () => mockDb.adjustments()),
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import MxIcon from './MxIcon.vue'
|
||||
|
||||
/**
|
||||
* 危险操作二次确认(07 第 7 节)。
|
||||
* 只负责「确认」这一件事,loading 态由调用方通过 `pending` 传入,避免弹窗自己管请求。
|
||||
*
|
||||
* `reasonRequired` 打开时,弹窗里多出一个必填原因输入框(作废成绩 / 退回草稿 / 作废破纪录登记),
|
||||
* 原因随 confirm 事件一起 emit 给调用方,由调用方写进请求体(服务端再写审计)。
|
||||
* 原因留空时**不 emit confirm**:这类操作都改榜单,宁可多拦一次,也不能带着空原因落库。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -16,6 +20,10 @@ const props = withDefaults(
|
||||
cancelText?: string
|
||||
tone?: 'info' | 'warning' | 'danger'
|
||||
pending?: boolean
|
||||
/** true 时要求填写原因,并在 confirm 事件里带出。 */
|
||||
reasonRequired?: boolean
|
||||
reasonLabel?: string
|
||||
reasonPlaceholder?: string
|
||||
}>(),
|
||||
{
|
||||
details: () => [],
|
||||
@@ -23,10 +31,29 @@ const props = withDefaults(
|
||||
cancelText: '取消',
|
||||
tone: 'warning',
|
||||
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、遮罩、右上角关闭键都走这里。
|
||||
@@ -40,6 +67,16 @@ function requestCancel(): void {
|
||||
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 {
|
||||
if (!props.open) return
|
||||
if (event.key === 'Escape') requestCancel()
|
||||
@@ -66,6 +103,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
<ul v-if="details.length > 0" class="confirm-details">
|
||||
<li v-for="(line, index) in details" :key="index">{{ line }}</li>
|
||||
</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 class="mx-modal-foot">
|
||||
<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'"
|
||||
type="button"
|
||||
:disabled="pending"
|
||||
@click="emit('confirm')"
|
||||
@click="requestConfirm"
|
||||
>
|
||||
{{ pending ? '处理中…' : confirmText }}
|
||||
</button>
|
||||
@@ -105,4 +157,19 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
content: '·';
|
||||
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>
|
||||
@@ -2,9 +2,18 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { formatPoints, type RecordEntry } from '@/shared'
|
||||
import type { DisplayInstance } from '@/shared/api'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import {
|
||||
adminLinkLabel,
|
||||
countText,
|
||||
displayCountText,
|
||||
displayCounts,
|
||||
scheduleProgress,
|
||||
todayEvents,
|
||||
} from './dashboard'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
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 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(() => {
|
||||
if (data.isMock) return '示例数据'
|
||||
if (data.realtimeStatus === 'online') return '实时在线'
|
||||
@@ -24,12 +65,43 @@ const connectionText = computed(() => {
|
||||
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 {
|
||||
records.value = await adminApi.records()
|
||||
} catch {
|
||||
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>
|
||||
|
||||
@@ -70,11 +142,47 @@ onMounted(async () => {
|
||||
</div>
|
||||
</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">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<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 class="mx-card-body no-pad">
|
||||
<div class="mx-table-wrap">
|
||||
@@ -89,7 +197,10 @@ onMounted(async () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<span
|
||||
@@ -137,9 +248,24 @@ onMounted(async () => {
|
||||
<li v-if="topTeams.length === 0" class="top-empty">暂无已发布成绩</li>
|
||||
</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>
|
||||
<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.map((event) => event.name).join('、') }}
|
||||
</p>
|
||||
@@ -237,6 +363,20 @@ onMounted(async () => {
|
||||
margin-bottom: 10px;
|
||||
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 {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -48,6 +48,15 @@ import {
|
||||
type EntryBatchPlan,
|
||||
type EntryBatchRow,
|
||||
} 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 {
|
||||
DEFAULT_HEAT_SIZE,
|
||||
MAX_HEAT,
|
||||
@@ -60,7 +69,7 @@ import {
|
||||
type HeatAllocation,
|
||||
type HeatFilter,
|
||||
} from './entryHeat'
|
||||
import { adminApi } from '../api/client'
|
||||
import { adminApi, sampleDataBlockedNotice } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
|
||||
@@ -83,6 +92,8 @@ interface EditableRow {
|
||||
heat: number | null
|
||||
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
|
||||
entryStatus: EntryStatus
|
||||
/** 成绩 id(服务端按它定位作废 / 退回;草稿行也有,未报名时才是 null)。 */
|
||||
resultId: number | null
|
||||
resultText: string
|
||||
rank: number | null
|
||||
status: ResultStatus
|
||||
@@ -453,6 +464,34 @@ function rowLocked(row: EditableRow): boolean {
|
||||
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,避免无意义悬浮。 */
|
||||
function resultTitle(row: EditableRow): string {
|
||||
if (rowLocked(row)) return '已发布,请先撤销发布'
|
||||
if (rowLocked(row)) {
|
||||
// 已作废的行同样是「锁定」的,但原因是作废而不是发布:提示要指向真正的出路。
|
||||
return row.status === 'withdrawn'
|
||||
? '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
|
||||
: '已发布,请先撤销发布'
|
||||
}
|
||||
const hint = resultHintOf(row)
|
||||
return hint !== null && hint.level !== 'ok' ? hint.message : ''
|
||||
}
|
||||
@@ -681,6 +725,7 @@ function toRow(entry: EntryWithResult): EditableRow {
|
||||
memberNames: entry.memberNames,
|
||||
heat: entry.heat,
|
||||
entryStatus: entry.status,
|
||||
resultId: result?.id ?? null,
|
||||
resultText: result?.resultText ?? '',
|
||||
rank: result?.rank ?? null,
|
||||
status: result?.status ?? 'ok',
|
||||
@@ -1350,6 +1395,69 @@ const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
|
||||
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 {
|
||||
const plan = splitPlan.value
|
||||
@@ -1810,6 +1918,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<th>状态</th>
|
||||
<th class="center">积分(自动)</th>
|
||||
<th>备注</th>
|
||||
<th class="center action-col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1827,8 +1936,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" />
|
||||
</td>
|
||||
<td class="center">
|
||||
<!--
|
||||
已作废是独立状态:它仍是 published(所以输入框照样锁着),
|
||||
但成绩不计入名次与积分,徽标必须先说清这件事,别只显示「已发布」。
|
||||
-->
|
||||
<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"
|
||||
title="已发布上屏"
|
||||
>已发布</span>
|
||||
@@ -1937,9 +2055,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
/>
|
||||
</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 v-if="!loading && visibleRows.length === 0">
|
||||
<td colspan="11">
|
||||
<td colspan="12">
|
||||
<div class="mx-empty">
|
||||
<div class="mx-empty-title">
|
||||
{{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }}
|
||||
@@ -2099,6 +2235,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
@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
|
||||
:open="settleDialog"
|
||||
tone="warning"
|
||||
@@ -2426,6 +2584,18 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-cell {
|
||||
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 / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatDateTime,
|
||||
formatPoints,
|
||||
type Event,
|
||||
type RecordBreak,
|
||||
type RecordEntry,
|
||||
type RecordLevel,
|
||||
type RulePack,
|
||||
@@ -13,7 +14,19 @@ import {
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.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 { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -44,15 +57,6 @@ interface RecordGroup {
|
||||
records: RecordEntry[]
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<RecordLevel, string> = {
|
||||
meet: '赛会',
|
||||
city: '市级',
|
||||
province: '省级',
|
||||
national: '全国',
|
||||
}
|
||||
|
||||
const LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
|
||||
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
@@ -73,10 +77,39 @@ const levelFilter = ref<RecordLevel | 'all'>('all')
|
||||
const approveTarget = ref<SuspectedBreak | null>(null)
|
||||
const approving = ref(false)
|
||||
const rejectTarget = ref<SuspectedBreak | null>(null)
|
||||
/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */
|
||||
const rejecting = ref(false)
|
||||
/**
|
||||
* 兜底用的本地标记。
|
||||
*
|
||||
* 服务端登记成功后靠「已处理登记」抑制(handledKeys),这两个数组只在接口写不进去时
|
||||
* (示例数据 / 后端旧版)用,并且提示里会如实说明「仅本机有效,刷新后重新出现」。
|
||||
*/
|
||||
const rejectedKeys = 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 isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
/**
|
||||
@@ -129,14 +162,24 @@ const recordGroups = computed<RecordGroup[]>(() => {
|
||||
.sort((a, b) => a.eventName.localeCompare(b.eventName))
|
||||
})
|
||||
|
||||
/**
|
||||
* 待确认的疑似破纪录。
|
||||
*
|
||||
* 三层过滤:推导出的疑似条目,减去本机兜底标记(接口写不进去时用的),
|
||||
* 再减去服务端已登记(approved / rejected)的键——最后这层才是「驳回以后刷新不再出现」的关键。
|
||||
*/
|
||||
const pendingBreaks = computed(() =>
|
||||
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 {
|
||||
const index = LEVEL_ORDER.indexOf(level)
|
||||
const index = RECORD_LEVEL_ORDER.indexOf(level)
|
||||
return index < 0 ? 0 : index
|
||||
}
|
||||
|
||||
@@ -154,6 +197,20 @@ function teamLabel(teamId: number): string {
|
||||
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 {
|
||||
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> {
|
||||
if (!data.loaded) return
|
||||
suspectedLoading.value = true
|
||||
@@ -250,7 +343,7 @@ async function loadSuspected(): Promise<void> {
|
||||
}
|
||||
if (best === null) continue
|
||||
list.push({
|
||||
key: `${event.id}-${entry.id}-${best.level}`,
|
||||
key: breakKey(event.id, entry.id, best.level),
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
eventCode: event.code,
|
||||
@@ -315,10 +408,10 @@ async function confirmApprove(): Promise<void> {
|
||||
toast.success(
|
||||
'已确认破纪录',
|
||||
item.bonusPoints === null
|
||||
? `${LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准`
|
||||
: `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`,
|
||||
? `${RECORD_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) {
|
||||
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
|
||||
if (item === null) return
|
||||
if (item === null || rejecting.value) return
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
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.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现')
|
||||
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> {
|
||||
await Promise.all([loadRecords(), loadRules()])
|
||||
await Promise.all([loadRecords(), loadRules(), loadBreaks()])
|
||||
await loadSuspected()
|
||||
}
|
||||
|
||||
@@ -460,7 +724,7 @@ watch(
|
||||
</td>
|
||||
<td class="mono highlight">{{ item.resultText }}</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">
|
||||
{{ item.bonusPoints === null ? '—' : `+${formatPoints(item.bonusPoints)}` }}
|
||||
</td>
|
||||
@@ -495,21 +759,105 @@ watch(
|
||||
</div>
|
||||
</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">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">纪录库</span>
|
||||
<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">
|
||||
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
|
||||
<button
|
||||
v-for="level in LEVEL_ORDER"
|
||||
v-for="level in RECORD_LEVEL_ORDER"
|
||||
:key="level"
|
||||
class="mx-tab"
|
||||
:class="{ active: levelFilter === level }"
|
||||
type="button"
|
||||
@click="levelFilter = level"
|
||||
>
|
||||
{{ LEVEL_LABELS[level] }}
|
||||
{{ RECORD_LEVEL_LABELS[level] }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-input-wrap search">
|
||||
@@ -551,20 +899,37 @@ watch(
|
||||
<th>达成时间</th>
|
||||
<th>来源</th>
|
||||
<th class="center">当前</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>{{ record.holderName ?? '—' }}</td>
|
||||
<td>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td>
|
||||
<td>{{ holderTeamLabel(record.holderTeamId) }}</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">
|
||||
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</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>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -582,8 +947,8 @@ watch(
|
||||
approveTarget === null
|
||||
? ''
|
||||
: approveTarget.bonusPoints === null
|
||||
? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。`
|
||||
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
|
||||
? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${RECORD_LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。`
|
||||
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${RECORD_LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
|
||||
"
|
||||
:details="[
|
||||
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
|
||||
@@ -599,12 +964,126 @@ watch(
|
||||
:open="rejectTarget !== null"
|
||||
tone="danger"
|
||||
title="拒绝这条疑似破纪录?"
|
||||
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。"
|
||||
:message="rejectMessage"
|
||||
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
|
||||
confirm-text="确认拒绝"
|
||||
:pending="rejecting"
|
||||
@confirm="confirmReject"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
@@ -704,6 +1183,39 @@ watch(
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 '连接中断'
|
||||
}
|
||||
@@ -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: '改完记得再点一次「发布本批」,名次与积分会按新成绩重算',
|
||||
},
|
||||
}
|
||||
@@ -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 = '现场判定不是破纪录'
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
@@ -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"/)
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -34,7 +34,10 @@ import type {
|
||||
PublishPayload,
|
||||
RecordBreak,
|
||||
RecordBreakInput,
|
||||
RecordBreakState,
|
||||
RecordEntry,
|
||||
RecordEntryInput,
|
||||
ReopenResultPayload,
|
||||
Result,
|
||||
ResultQuery,
|
||||
RulePack,
|
||||
@@ -324,6 +327,14 @@ export interface ResultsApi {
|
||||
publish(payload: PublishPayload): Promise<Result[]>
|
||||
unpublish(payload: PublishPayload): 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 {
|
||||
@@ -342,6 +353,27 @@ export interface RecordsApi {
|
||||
list(query?: { eventId?: number }): Promise<RecordEntry[]>
|
||||
createBreak(input: RecordBreakInput): 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) =>
|
||||
http.request<Result[]>('/results/unpublish', { 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 }),
|
||||
approveBreak: (id) =>
|
||||
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 = {
|
||||
|
||||
@@ -377,6 +377,12 @@ export interface VoidResultPayload {
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/** POST /api/results/{id}/reopen 的请求体:把已作废的一条成绩退回草稿继续录入。 */
|
||||
export interface ReopenResultPayload {
|
||||
reason: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface ResultQuery extends PageQuery {
|
||||
eventId?: number
|
||||
state?: ResultState
|
||||
@@ -489,6 +495,24 @@ export interface RecordEntry {
|
||||
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 interface RecordBreak {
|
||||
@@ -502,6 +526,11 @@ export interface RecordBreak {
|
||||
state: RecordBreakState
|
||||
approvedBy: number | null
|
||||
approvedAt: string | null
|
||||
/**
|
||||
* 本次登记 / 驳回的说明(驳回时写的是驳回原因)。
|
||||
* 旧服务端 / 旧缓存不下发它,所以可选;界面缺字段时不渲染那一行,不显示 undefined。
|
||||
*/
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface RecordBreakInput {
|
||||
|
||||
Reference in new issue
Block a user