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

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

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

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

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

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

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

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

No files matched your search

+43
View File
@@ -34,7 +34,10 @@ import type {
PublishPayload,
RecordBreak,
RecordBreakInput,
RecordBreakState,
RecordEntry,
RecordEntryInput,
ReopenResultPayload,
Result,
ResultQuery,
RulePack,
@@ -324,6 +327,14 @@ export interface ResultsApi {
publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]>
void(id: number, payload: VoidResultPayload): Promise<Result>
/**
* 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。
*
* 与 void 成对:作废只把 `status` 改成 withdrawn 并撤掉这一条的积分,
* 想把这一条改回来只能先 reopen 退回草稿,再用常规的存草稿 / 发布流程重新发。
* 服务端在同一事务里重算推榜,返回更新后的成绩。
*/
reopen(id: number, payload: ReopenResultPayload): Promise<Result>
}
export interface StandingsApi {
@@ -342,6 +353,27 @@ export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak>
/**
* 破纪录登记列表(`GET /api/record-breaks?state=...`,recorder 及以上可读)。
*
* 不传 state 时由服务端决定(当前返回全部);按 id 降序。
* 「已批准的破纪录」卡片要 state=approved,仪表盘的「待确认」计数要 state=pending。
*/
listBreaks(state?: RecordBreakState): Promise<RecordBreak[]>
/**
* 驳回 / 作废一条登记(`POST /api/record-breaks/{id}/reject`,原因必填)。
*
* 原本是 approved 的登记被驳回时,服务端会撤掉那笔加分并把该级别纪录恢复成上一条。
*/
rejectBreak(id: number, reason: string): Promise<RecordBreak>
/** 登记一条历史纪录(`POST /api/records`);服务端固定把 source 写成 history。 */
createRecord(input: RecordEntryInput): Promise<RecordEntry>
/**
* 删除一条纪录(`DELETE /api/records/{id}`)。
*
* 删掉的若是当前纪录(isCurrent),服务端会把同级别的上一条恢复成当前。
*/
removeRecord(id: number): Promise<void>
}
/**
@@ -590,6 +622,8 @@ function createResultsApi(http: Http): ResultsApi {
unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
reopen: (id, payload) =>
http.request<Result>(`/results/${id}/reopen`, { method: 'POST', body: payload }),
}
}
@@ -831,6 +865,15 @@ export class ApiClient {
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) =>
http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }),
// state 缺省时不带查询串:让服务端用自己的默认口径,前端不替它决定「全部」怎么写。
listBreaks: (state) =>
http.request<RecordBreak[]>('/record-breaks', {
query: state === undefined ? undefined : { state },
}),
rejectBreak: (id, reason) =>
http.request<RecordBreak>(`/record-breaks/${id}/reject`, { method: 'POST', body: { reason } }),
createRecord: (input) => http.request<RecordEntry>('/records', { method: 'POST', body: input }),
removeRecord: (id) => http.requestVoid(`/records/${id}`, { method: 'DELETE' }),
}
this.display = {