feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
(回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
名次/流水/榜单与「一开始就填对」逐字节一致。
## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
`POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
**撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
「拒绝」改为持久化(先登记再驳回,刷新不再复现)。
## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
(删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。
## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。
## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。
## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。
自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
This commit is contained in:
1 parent
6841cf49aa
commit
ed2c0b3f3c
35 files changed
+4003
-115
No files matched your search
@@ -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 / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
|
||||
|
||||
Reference in new issue
Block a user