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

+174 -4
View File
@@ -48,6 +48,15 @@ import {
type EntryBatchPlan,
type EntryBatchRow,
} from './entryBatch'
import {
RESULT_ACTION_CONFIRM,
RESULT_ACTION_SUCCESS,
RESULT_ROW_ACTION_LABEL,
RESULT_ROW_ACTION_TITLE,
RESULT_ROW_ACTION_WORD,
resultActionTarget,
type ResultActionTarget,
} from './entryResultActions'
import {
DEFAULT_HEAT_SIZE,
MAX_HEAT,
@@ -60,7 +69,7 @@ import {
type HeatAllocation,
type HeatFilter,
} from './entryHeat'
import { adminApi } from '../api/client'
import { adminApi, sampleDataBlockedNotice } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
@@ -83,6 +92,8 @@ interface EditableRow {
heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
entryStatus: EntryStatus
/** 成绩 id(服务端按它定位作废 / 退回;草稿行也有,未报名时才是 null)。 */
resultId: number | null
resultText: string
rank: number | null
status: ResultStatus
@@ -453,6 +464,34 @@ function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published'
}
/**
* 每行的行操作(作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
*
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
*/
const rowActions = computed<Map<number, ResultActionTarget>>(() => {
const map = new Map<number, ResultActionTarget>()
for (const row of visibleRows.value) {
const target = resultActionTarget(row)
if (target !== null) map.set(row.entryId, target)
}
return map
})
function rowAction(row: EditableRow): ResultActionTarget | null {
return rowActions.value.get(row.entryId) ?? null
}
function rowActionLabel(row: EditableRow): string {
const target = rowAction(row)
return target === null ? '' : RESULT_ROW_ACTION_LABEL[target.action]
}
function rowActionTitle(row: EditableRow): string {
const target = rowAction(row)
return target === null ? '' : RESULT_ROW_ACTION_TITLE[target.action]
}
/**
* 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。
*
@@ -505,7 +544,12 @@ const focusedHint = computed<ResultCellHint | null>(() => {
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
function resultTitle(row: EditableRow): string {
if (rowLocked(row)) return '已发布,请先撤销发布'
if (rowLocked(row)) {
// 已作废的行同样是「锁定」的,但原因是作废而不是发布:提示要指向真正的出路。
return row.status === 'withdrawn'
? '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
: '已发布,请先撤销发布'
}
const hint = resultHintOf(row)
return hint !== null && hint.level !== 'ok' ? hint.message : ''
}
@@ -681,6 +725,7 @@ function toRow(entry: EntryWithResult): EditableRow {
memberNames: entry.memberNames,
heat: entry.heat,
entryStatus: entry.status,
resultId: result?.id ?? null,
resultText: result?.resultText ?? '',
rank: result?.rank ?? null,
status: result?.status ?? 'ok',
@@ -1350,6 +1395,69 @@ const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
remove: '确认移出',
}
// ── 单条作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────────
/** 待确认的行操作;非 null 时确认框打开。 */
const resultAction = ref<ResultActionTarget | null>(null)
const resultActionBusy = ref(false)
/** 确认框文案随动作变(作废 / 退回草稿的后果不同)。 */
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
/**
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
*
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
* 否则刚填的名次会被服务端返回的口径覆盖掉。
*/
function requestResultAction(row: EditableRow): void {
const target = rowAction(row)
if (target === null) return
if (data.isMock) {
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
toast.error(notice.title, notice.detail)
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (dirty.value) {
toast.error('有未保存的修改', '作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
return
}
resultAction.value = target
}
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
async function confirmResultAction(reason: string): Promise<void> {
const target = resultAction.value
if (target === null || resultActionBusy.value) return
resultActionBusy.value = true
try {
if (target.action === 'void') {
await adminApi.voidResult(target.resultId, { reason })
} else {
await adminApi.reopenResult(target.resultId, { reason })
}
resultAction.value = null
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
await reloadAfterSettlementChange()
const success = RESULT_ACTION_SUCCESS[target.action]
toast.success(success.title, success.detail)
} catch (caught) {
toast.error(target.action === 'void' ? '作废失败' : '退回草稿失败', caught instanceof Error ? caught.message : null)
} finally {
resultActionBusy.value = false
}
}
function cancelResultAction(): void {
if (resultActionBusy.value) return
resultAction.value = null
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
function requestSplit(): void {
const plan = splitPlan.value
@@ -1810,6 +1918,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<th>状态</th>
<th class="center">积分(自动)</th>
<th>备注</th>
<th class="center action-col">操作</th>
</tr>
</thead>
<tbody>
@@ -1827,8 +1936,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" />
</td>
<td class="center">
<!--
已作废是独立状态:它仍是 published(所以输入框照样锁着),
但成绩不计入名次与积分,徽标必须先说清这件事,别只显示「已发布」。
-->
<span
v-if="row.resultState === 'published'"
v-if="row.resultState === 'published' && row.status === 'withdrawn'"
class="mx-badge mx-badge-error"
title="本条已作废:不计入名次与积分,可用「重新录入」退回草稿"
>已作废</span>
<span
v-else-if="row.resultState === 'published'"
class="mx-badge mx-badge-success"
title="已发布上屏"
>已发布</span>
@@ -1937,9 +2055,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
</td>
<!--
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能作废」而不是一片空白。
-->
<td class="center action-cell">
<button
v-if="rowAction(row) !== null"
class="mx-btn mx-btn-sm"
:class="rowAction(row)?.action === 'reopen' ? 'mx-btn-secondary' : 'mx-btn-danger'"
type="button"
:disabled="!canWrite || resultActionBusy"
:title="rowActionTitle(row)"
@click="requestResultAction(row)"
>
{{ rowActionLabel(row) }}
</button>
<span v-else class="muted">—</span>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0">
<td colspan="11">
<td colspan="12">
<div class="mx-empty">
<div class="mx-empty-title">
{{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }}
@@ -2099,6 +2235,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@cancel="unpublishDialog = false"
/>
<!--
单条作废 / 重新录入:原因必填(服务端写审计),后果写在弹窗里。
与「撤销发布」分开:这是外科手术式改一条,不动同组其它已发布成绩。
-->
<ConfirmDialog
:open="resultAction !== null"
tone="danger"
:title="resultActionCopy.title"
:message="resultActionCopy.message"
:details="
resultAction === null
? []
: [`${resultAction.resultText === '' ? '(空成绩)' : resultAction.resultText}`, '同组其它人的名次与积分会一起重算并推送大屏']
"
:confirm-text="resultActionCopy.confirmText"
:reason-required="true"
:reason-label="resultActionCopy.reasonLabel"
:pending="resultActionBusy"
@confirm="confirmResultAction"
@cancel="cancelResultAction"
/>
<ConfirmDialog
:open="settleDialog"
tone="warning"
@@ -2426,6 +2584,18 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
.team-cell {
font-weight: 600;
}
/* 行操作区:窄列、不换行,按钮别把整张表撑宽。 */
.action-col,
.action-cell {
width: 96px;
white-space: nowrap;
}
.action-cell .mx-btn {
width: 100%;
}
.action-cell .muted {
color: var(--mx-t3);
}
/*
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。