diff --git a/VERSION b/VERSION index c596d97..91c7625 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.2.0 +dev_0.3.0 diff --git a/console/package.json b/console/package.json index d2ef44a..6111bc0 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.2.0", + "version": "0.3.0", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/使用手册.md b/docs/使用手册.md index b5d0d35..456675a 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -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 金、无银牌。 diff --git a/docs/升级说明.md b/docs/升级说明.md index 51468d7..cc77f46 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -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 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index 000bc67..e44fb7a 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -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. 点「保存纪录」,提示「已新增历史纪录 · 来源记为「历史」」。 + +新录的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(仍留在库里)。纪录库的 **「来源」列**因此有了两种值:**历史** = 赛前手工登记的旧资料,**本届** = 本场赛事批准破纪录时自动写入的(以前没有历史录入入口,实际只会看到「本届」)。 + +**删除**:每行右端有「删除」(需**录入员及以上**)。确认框会说明「若它是当前纪录,会恢复上一条」,并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;删除不会触发重算。 diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index e2cb73d..d916dce 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -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 { diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index 7d26af9..b2fb33d 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -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 里取公告正文与全屏时长; diff --git a/server/internal/httpapi/legacy_pack_upgrade_test.go b/server/internal/httpapi/legacy_pack_upgrade_test.go new file mode 100644 index 0000000..fa77c00 --- /dev/null +++ b/server/internal/httpapi/legacy_pack_upgrade_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index c5887a4..b60b01f 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -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"` diff --git a/server/internal/httpapi/record_break_review_test.go b/server/internal/httpapi/record_break_review_test.go new file mode 100644 index 0000000..c7c810e --- /dev/null +++ b/server/internal/httpapi/record_break_review_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/record_entry_write_test.go b/server/internal/httpapi/record_entry_write_test.go new file mode 100644 index 0000000..fe911b2 --- /dev/null +++ b/server/internal/httpapi/record_entry_write_test.go @@ -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 +} diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 08583d7..41d89a7 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -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) { diff --git a/server/internal/httpapi/result_reopen_test.go b/server/internal/httpapi/result_reopen_test.go new file mode 100644 index 0000000..6f5978f --- /dev/null +++ b/server/internal/httpapi/result_reopen_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index a5b6f0d..fa00857 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -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() }() - - 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) +// loadResultForAction 读一条成绩及其所属 (小项, 组别),供「作废 / 重新录入」这类单条成绩 +// 动作使用:两者要读的字段完全一样,各自写一遍扫描迟早会漂移。 +func loadResultForAction(ctx context.Context, q queryer, resultID int64) (resultDTO, int64, int64, error) { var ( + before resultDTO + 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 { - if errors.Is(err, sql.ErrNoRows) { - return resultDTO{}, notFound("成绩不存在") - } - return resultDTO{}, internal(err) + 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{}, 0, 0, notFound("成绩不存在") + } + 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 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。 diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index cd8c224..55c9c42 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/httpapi/testenv_test.go b/server/internal/httpapi/testenv_test.go index bde1549..3951b0d 100644 --- a/server/internal/httpapi/testenv_test.go +++ b/server/internal/httpapi/testenv_test.go @@ -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) } diff --git a/server/migrations/event/0021_record_breaks_note.sql b/server/migrations/event/0021_record_breaks_note.sql new file mode 100644 index 0000000..5cbbc85 --- /dev/null +++ b/server/migrations/event/0021_record_breaks_note.sql @@ -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; diff --git a/server/migrations/event/migration_0021_test.go b/server/migrations/event/migration_0021_test.go new file mode 100644 index 0000000..dc535bc --- /dev/null +++ b/server/migrations/event/migration_0021_test.go @@ -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("升级后存在外键悬挂") + } +} diff --git a/server/migrations/event/migration_legacy_test.go b/server/migrations/event/migration_legacy_test.go index 52f974b..6224696 100644 --- a/server/migrations/event/migration_legacy_test.go +++ b/server/migrations/event/migration_legacy_test.go @@ -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 拒绝") diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index 1358970..9c32468 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -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 => callWrite(() => client.results.unpublish(payload)), + /** + * 作废一条已发布的成绩(外科手术式,影响面远小于整组撤销发布)。 + * + * 写操作不降级:示例数据模式下没有后端可落库,页面会先用 data.isMock 拦下。 + */ + voidResult: (id: number, payload: VoidResultPayload): Promise => + callWrite(() => client.results.void(id, payload)), + + /** 把一条已作废的成绩退回草稿,改完再发布本组;同样是不降级的写操作。 */ + reopenResult: (id: number, payload: ReopenResultPayload): Promise => + callWrite(() => client.results.reopen(id, payload)), + standings: (): Promise => call(() => client.standings.teams(), () => mockDb.standings()), @@ -244,6 +261,36 @@ export const adminApi = { records: (): Promise => call(() => client.records.list(), () => mockDb.records()), + /** + * 破纪录登记列表(GET /api/record-breaks?state=...)。 + * + * 刻意**不走示例数据降级**:示例数据里根本没有登记表,回一个空数组会让页面把 + * 「读不到」显示成「没有已批准的破纪录」。读不到时原样抛出,页面据此显示空态说明。 + */ + recordBreaks: (state: RecordBreakState): Promise => + client.records.listBreaks(state), + + /** 驳回 / 作废一条登记;原本 approved 的会连加分一起撤掉(服务端行为)。 */ + rejectBreak: (id: number, reason: string): Promise => + callWrite(() => client.records.rejectBreak(id, reason)), + + /** 登记一条历史纪录;服务端固定 source = history。 */ + createRecord: (input: RecordEntryInput): Promise => + callWrite(() => client.records.createRecord(input)), + + /** 删除一条纪录;删的若是当前纪录,服务端恢复上一条。 */ + removeRecord: (id: number): Promise => callWrite(() => client.records.removeRecord(id)), + + /** + * 「草稿待发布数」:分页只取 1 条,要的是 Page.total 这个总数。 + * + * 同样不降级:读不到时页面显示 `—`,不用 0 冒充「一条草稿都没有」。 + */ + draftResultCount: async (): Promise => { + const page = await client.results.list({ state: 'draft', page: 1, size: 1 }) + return page.total + }, + adjustments: (query: PageQuery = {}): Promise> => call(() => client.adjustments.list(query), () => mockDb.adjustments()), diff --git a/web/src/admin/components/ConfirmDialog.vue b/web/src/admin/components/ConfirmDialog.vue index 5eb3d56..3c9cd6e 100644 --- a/web/src/admin/components/ConfirmDialog.vue +++ b/web/src/admin/components/ConfirmDialog.vue @@ -1,10 +1,14 @@ @@ -70,11 +142,47 @@ onMounted(async () => { + +
+
+
今天的小项
+
{{ !data.loaded || todayProgress.text === null ? '—' : todayProgress.total }}
+ + {{ todayHint }} + +
+
+
草稿待发布
+
{{ countText(draftCount) }}
+ + {{ draftCount === null ? '接口不可用' : draftCount > 0 ? '已保存未发布' : '没有待发布草稿' }} + +
+
+
待确认破纪录
+
{{ countText(pendingBreakCount) }}
+ + {{ pendingBreakHint }} + +
+
+
大屏在线数
+
{{ displayTotal }}
+ + 管理端:{{ adminLink }} +
+
+
当前赛程 - {{ data.events.length }} 个小项 + + {{ data.events.length }} 个小项 · 今天 {{ todayProgress.total }} 个 +
@@ -89,7 +197,10 @@ onMounted(async () => { - {{ event.name }} + + {{ event.name }} + 今天 + {{ event.code }} {
  • 暂无已发布成绩
  • -
    +
    待办
    -

    +

      +
    • + 草稿待发布: + {{ countText(draftCount) }} + {{ draftCount === null ? '(读不到,接口不可用)' : '条成绩草稿还没发布' }} +
    • +
    • + 疑似破纪录待确认: + {{ countText(pendingBreakCount) }} + {{ pendingBreakCount === null ? '(读不到,接口不可用)' : '条' }} + + 去确认 + +
    • +
    +

    还有 {{ pendingEvents.length }} 个项目未录入: {{ pendingEvents.map((event) => event.name).join('、') }}

    @@ -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; diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue index 85f4b93..c19e059 100644 --- a/web/src/admin/pages/EntryPage.vue +++ b/web/src/admin/pages/EntryPage.vue @@ -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>(() => { + const map = new Map() + 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(() => { /** 失焦后由既有的红框 + 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 = { remove: '确认移出', } +// ── 单条作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ────────────── + +/** 待确认的行操作;非 null 时确认框打开。 */ +const resultAction = ref(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 { + 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 状态 积分(自动) 备注 + 操作 @@ -1827,8 +1936,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i + 已作废 + 已发布 @@ -1937,9 +2055,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i @keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)" /> + + + + — + - +
    {{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }} @@ -2099,6 +2235,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i @cancel="unpublishDialog = false" /> + + + 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 / 断线)时整块降透明度,配合卡片内的只读提示说明原因。 diff --git a/web/src/admin/pages/RecordsPage.vue b/web/src/admin/pages/RecordsPage.vue index 469b6f5..414e626 100644 --- a/web/src/admin/pages/RecordsPage.vue +++ b/web/src/admin/pages/RecordsPage.vue @@ -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 = { - 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('all') const approveTarget = ref(null) const approving = ref(false) const rejectTarget = ref(null) -/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */ +const rejecting = ref(false) +/** + * 兜底用的本地标记。 + * + * 服务端登记成功后靠「已处理登记」抑制(handledKeys),这两个数组只在接口写不进去时 + * (示例数据 / 后端旧版)用,并且提示里会如实说明「仅本机有效,刷新后重新出现」。 + */ const rejectedKeys = ref([]) const approvedKeys = ref([]) +/** 已批准的破纪录登记(GET /api/record-breaks?state=approved)。 */ +const approvedBreaks = ref([]) +const breaksLoading = ref(false) +const breaksError = ref(null) +/** 接口读不到(示例数据 / 后端旧版)时卡片显示空态说明,绝不让整页报错。 */ +const breaksUnavailable = ref(false) +/** 读不到是因为权限(guest):文案与「接口不可用」不同,别把权限说成后端太旧。 */ +const breaksForbidden = ref(false) +/** 已处理登记(approved + rejected)的键,用来抑制前端推导出的疑似列表。 */ +const handledKeys = ref>(new Set()) +const revokeTarget = ref(null) +const revoking = ref(false) + +/** 新增历史纪录表单(P2 C11)。 */ +const createOpen = ref(false) +const createForm = ref(emptyRecordForm()) +const createError = ref('') +const creating = ref(false) + +/** 删除纪录确认。 */ +const deleteTarget = ref(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(() => { .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 { } } +/** + * 读破纪录登记(approved + rejected 两态)。 + * + * 两态分开取:approved 是「已批准的破纪录」卡片的数据源,两者合起来才能算出 + * 「已经处理过」的键去抑制前端推导出的疑似列表(驳回过的条目刷新后不该再冒出来)。 + * + * 读不到时(示例数据 / 后端旧版 404)不让整页报错,只把卡片置为空态说明; + * 这时「忽略」退回本机标记,并在提示里如实说明刷新后会重新出现。 + */ +async function loadBreaks(): Promise { + 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 { if (!data.loaded) return suspectedLoading.value = true @@ -250,7 +343,7 @@ async function loadSuspected(): Promise { } 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 { 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 { } } -function confirmReject(): void { +/** 「忽略」确认框正文:示例数据模式下写不进去,必须如实说明只在本机生效。 */ +const rejectMessage = computed(() => + data.isMock + ? '示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。' + : '会在服务端登记为一条破纪录记录并立即驳回(原因写「现场判定不是破纪录」),刷新后不会再出现在待确认列表里。', +) + +/** + * 「忽略」疑似破纪录:在服务端登记一条 pending 再驳回,原因是固定的「现场判定不是破纪录」。 + * + * 以前只在本地数组里藏起来,刷新就回来——现场看到的「已忽略」和服务端真实状态不一致。 + * 现在只有真的写不进去(示例数据 / 未连接服务端)才退回本机标记,并如实说明仅本机有效。 + */ +async function confirmReject(): Promise { const item = rejectTarget.value - if (item === null) return - rejectedKeys.value = [...rejectedKeys.value, item.key] - rejectTarget.value = null - toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现') + 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.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 { + 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 { + 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 { + 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 { - await Promise.all([loadRecords(), loadRules()]) + await Promise.all([loadRecords(), loadRules(), loadBreaks()]) await loadSuspected() } @@ -460,7 +724,7 @@ watch( {{ item.resultText }} {{ item.recordText }} - {{ LEVEL_LABELS[item.level] }} + {{ RECORD_LEVEL_LABELS[item.level] }} {{ item.bonusPoints === null ? '—' : `+${formatPoints(item.bonusPoints)}` }} @@ -495,21 +759,105 @@ watch(
    + +
    +
    + 已批准的破纪录 + {{ approvedBreaks.length }} 条 +
    +
    +
    +
    +
    正在加载已批准的破纪录…
    +
    + +
    +
    +
    {{ breaksForbidden ? '访客只读:看不到破纪录登记' : '读不到破纪录登记' }}
    +
    + 破纪录登记(含待审核的登记与驳回理由)要求录入员及以上权限;登录后即可查看与作废。 +
    +
    + {{ breaksError ?? '接口不可用' }};示例数据模式或后端版本较旧时没有这个接口, + 已批准的破纪录无法在这里作废,请在服务端升级后再试。 +
    + +
    +
    +
    +
    没有已批准的破纪录
    +
    在「疑似破纪录」里点「确认加分」后,登记会出现在这里
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    小项级别成绩加分批准时间说明操作
    + {{ eventNameOf(item.eventId) }} + {{ eventCodeOf(item.eventId) }} + {{ RECORD_LEVEL_LABELS[item.level] }}{{ item.resultText }}{{ breakBonusText(item) }}{{ formatDateTime(item.approvedAt) }}{{ breakNote(item) ?? '—' }} + +
    +
    +
    +
    +
    纪录库
    +
    @@ -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; diff --git a/web/src/admin/pages/dashboard.ts b/web/src/admin/pages/dashboard.ts new file mode 100644 index 0000000..91a8699 --- /dev/null +++ b/web/src/admin/pages/dashboard.ts @@ -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 '连接中断' +} diff --git a/web/src/admin/pages/entryResultActions.ts b/web/src/admin/pages/entryResultActions.ts new file mode 100644 index 0000000..e57c780 --- /dev/null +++ b/web/src/admin/pages/entryResultActions.ts @@ -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 = { + void: '作废本条', + reopen: '重新录入', +} + +export const RESULT_ROW_ACTION_TITLE: Record = { + void: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新', + reopen: '退回草稿后可继续修改,改完再发布本组', +} + +/** 示例数据模式下拦下写操作时的动作名(拼进「示例数据模式不能X」)。 */ +export const RESULT_ROW_ACTION_WORD: Record = { + void: '作废成绩', + reopen: '退回草稿', +} + +/** 确认框文案:后果必须写在弹窗里,不能只让操作员凭经验猜(07 §8)。 */ +export const RESULT_ACTION_CONFIRM: Record = { + void: { + title: '确认作废本条成绩?', + message: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。', + confirmText: '确认作废', + reasonLabel: '作废原因', + }, + reopen: { + title: '确认重新录入本条成绩?', + message: '该条成绩将退回草稿(可编辑),不再计入名次与积分;改完请再发布本组。', + confirmText: '退回草稿', + reasonLabel: '退回原因', + }, +} + +/** 成功提示:作废与退回的后续动作不同,提示要各说各的。 */ +export const RESULT_ACTION_SUCCESS: Record = { + void: { + title: '已作废该条成绩,名次与积分已重算', + detail: '大屏已更新;需要改回来时用同一行的「重新录入」退回草稿', + }, + reopen: { + title: '已退回草稿,该行可以继续修改', + detail: '改完记得再点一次「发布本批」,名次与积分会按新成绩重算', + }, +} diff --git a/web/src/admin/pages/recordBreaks.ts b/web/src/admin/pages/recordBreaks.ts new file mode 100644 index 0000000..4ff2ee2 --- /dev/null +++ b/web/src/admin/pages/recordBreaks.ts @@ -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 = { + 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 { + const keys = new Set() + 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 = '现场判定不是破纪录' diff --git a/web/src/admin/pages/recordForm.ts b/web/src/admin/pages/recordForm.ts new file mode 100644 index 0000000..90cde6a --- /dev/null +++ b/web/src/admin/pages/recordForm.ts @@ -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', + }, + } +} diff --git a/web/src/admin/tests/dashboard-cards.test.ts b/web/src/admin/tests/dashboard-cards.test.ts new file mode 100644 index 0000000..d463b9d --- /dev/null +++ b/web/src/admin/tests/dashboard-cards.test.ts @@ -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 { + 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(`${label}
    `), `缺少卡片:${label}`) + } + for (const label of ['领先单位', '已发布项目', '纪录条目', '实时连接']) { + assert.match(DASH_SOURCE, new RegExp(`${label}
    `), `旧卡片被删了:${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('