## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `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 例。
189 lines
9.2 KiB
TypeScript
189 lines
9.2 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import {
|
|
RESULT_ACTION_CONFIRM,
|
|
RESULT_ACTION_SUCCESS,
|
|
RESULT_ROW_ACTION_LABEL,
|
|
RESULT_ROW_ACTION_TITLE,
|
|
RESULT_ROW_ACTION_WORD,
|
|
resultActionTarget,
|
|
resultRowAction,
|
|
} from '../pages/entryResultActions'
|
|
|
|
/**
|
|
* 成绩录入页「作废 / 重新录入」(P2 A16)。
|
|
*
|
|
* 后端早有 `POST /api/results/{id}/void`,管理端却没有任何入口:现场改一个人填错的成绩,
|
|
* 只能「整组撤销发布 → 改 → 重发」,同组其它人的名次与积分会被整体回退再重算。
|
|
*
|
|
* 项目没有组件测试工具(无 @vue/test-utils / jsdom),判定逻辑抽在 entryResultActions 里直接测,
|
|
* 界面接线按仓库既有做法读源码断言。
|
|
*/
|
|
|
|
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
|
const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
|
|
|
|
function section(source: string, start: string, end: string): string {
|
|
const from = source.indexOf(start)
|
|
const to = source.indexOf(end, from)
|
|
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
|
|
return source.slice(from, to)
|
|
}
|
|
|
|
// ── 哪一行显示哪个按钮 ──────────────────────────────────────────────────────
|
|
|
|
test('已发布且未作废的行显示「作废本条」', () => {
|
|
assert.equal(resultRowAction({ resultState: 'published', status: 'ok' }), 'void')
|
|
assert.equal(resultRowAction({ resultState: 'published', status: 'dq' }), 'void')
|
|
assert.equal(resultRowAction({ resultState: 'published', status: 'dns' }), 'void')
|
|
})
|
|
|
|
test('已作废(published + withdrawn)的行显示「重新录入」', () => {
|
|
// 服务端 void 只改 status、不动 state,所以「已作废」等于「已发布 + withdrawn」。
|
|
assert.equal(resultRowAction({ resultState: 'published', status: 'withdrawn' }), 'reopen')
|
|
})
|
|
|
|
test('草稿行(含退回草稿后的行)不显示任何行操作:本来就是可编辑的', () => {
|
|
assert.equal(resultRowAction({ resultState: 'draft', status: 'withdrawn' }), null)
|
|
assert.equal(resultRowAction({ resultState: 'draft', status: 'ok' }), null)
|
|
assert.equal(resultRowAction({ resultState: null, status: 'ok' }), null)
|
|
})
|
|
|
|
test('按钮文案逐字:作废本条 / 重新录入', () => {
|
|
assert.equal(RESULT_ROW_ACTION_LABEL.void, '作废本条')
|
|
assert.equal(RESULT_ROW_ACTION_LABEL.reopen, '重新录入')
|
|
assert.match(RESULT_ROW_ACTION_TITLE.void, /不再计入名次与积分/)
|
|
assert.match(RESULT_ROW_ACTION_WORD.void, /作废/)
|
|
})
|
|
|
|
// ── 动的是哪一条成绩 ────────────────────────────────────────────────────────
|
|
|
|
test('操作目标是成绩 id(接口按它定位),不是报名项 id', () => {
|
|
const target = resultActionTarget({
|
|
entryId: 7001,
|
|
resultId: 9001,
|
|
resultState: 'published',
|
|
status: 'ok',
|
|
resultText: '11.58',
|
|
})
|
|
assert.deepEqual(target, {
|
|
action: 'void',
|
|
resultId: 9001,
|
|
entryId: 7001,
|
|
resultText: '11.58',
|
|
})
|
|
})
|
|
|
|
test('没有成绩 id 或不是已发布行时,不产生可提交的目标', () => {
|
|
const base = { entryId: 7001, resultState: 'published' as const, status: 'ok' as const, resultText: '11.58' }
|
|
assert.equal(resultActionTarget({ ...base, resultId: null }), null)
|
|
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }), null)
|
|
})
|
|
|
|
// ── 界面接线 ────────────────────────────────────────────────────────────────
|
|
|
|
test('确认框文案写清了后果,且作废原因必填', () => {
|
|
assert.equal(RESULT_ACTION_CONFIRM.void.title, '确认作废本条成绩?')
|
|
assert.equal(
|
|
RESULT_ACTION_CONFIRM.void.message,
|
|
'作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
|
|
)
|
|
assert.equal(RESULT_ACTION_CONFIRM.reopen.title, '确认重新录入本条成绩?')
|
|
assert.match(RESULT_ACTION_CONFIRM.reopen.message, /退回草稿(可编辑)/)
|
|
assert.match(ENTRY_SOURCE, /:reason-required="true"/)
|
|
assert.match(ENTRY_SOURCE, /@confirm="confirmResultAction"/)
|
|
})
|
|
|
|
test('作废调用 void、退回草稿调用 reopen,提交的都是成绩 id', () => {
|
|
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
|
assert.match(confirm, /await adminApi\.voidResult\(target\.resultId, \{ reason \}\)/)
|
|
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
|
|
assert.doesNotMatch(confirm, /(voidResult|reopenResult)\(target\.entryId/)
|
|
})
|
|
|
|
test('作废 / 退回成功后刷新本页数据与榜单', () => {
|
|
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
|
// 名次与积分由服务端重算:必须整表重拉 + 刷新榜单,不能只在本地行上打补丁。
|
|
assert.match(confirm, /await reloadAfterSettlementChange\(\)/)
|
|
assert.ok(
|
|
confirm.indexOf('reloadAfterSettlementChange') < confirm.indexOf('toast.success'),
|
|
'刷新要发生在成功提示之前,否则提示先说成功、页面还没变',
|
|
)
|
|
const reload = section(ENTRY_SOURCE, 'async function reloadAfterSettlementChange(', 'async function confirmSettle(')
|
|
assert.match(reload, /await reload\(\)/)
|
|
assert.match(reload, /data\.refreshStandings\(\)/)
|
|
})
|
|
|
|
test('成功后给 toast,失败时原样报错', () => {
|
|
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
|
assert.match(confirm, /const success = RESULT_ACTION_SUCCESS\[target\.action\]/)
|
|
assert.match(confirm, /toast\.success\(success\.title, success\.detail\)/)
|
|
assert.match(confirm, /toast\.error\(target\.action === 'void' \? '作废失败' : '退回草稿失败'/)
|
|
assert.match(RESULT_ACTION_SUCCESS.void.title, /名次与积分已重算/)
|
|
})
|
|
|
|
test('示例数据模式在发请求前拦下,提示与既有口径一致', () => {
|
|
const request = section(ENTRY_SOURCE, 'function requestResultAction(', 'async function confirmResultAction(')
|
|
assert.match(request, /if \(data\.isMock\) \{/)
|
|
assert.match(request, /sampleDataBlockedNotice\(RESULT_ROW_ACTION_WORD\[target\.action\]\)/)
|
|
assert.ok(
|
|
request.indexOf('data.isMock') < request.indexOf('resultAction.value = target'),
|
|
'isMock 判断要发生在打开确认框之前,示例数据下不弹一个点了也没用的框',
|
|
)
|
|
})
|
|
|
|
test('访客只读时按钮保留但置灰(与既有 data.canWrite 同一口径)', () => {
|
|
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
|
|
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
|
|
assert.match(cell, /\{\{ rowActionLabel\(row\) \}\}/)
|
|
assert.match(cell, /@click="requestResultAction\(row\)"/)
|
|
})
|
|
|
|
test('行能不能改只看服务端返回的 state:退回草稿后必须重新可编辑', () => {
|
|
// 退回草稿(reopen)由服务端把 state 改回 draft;页面只有按 state 判定锁,行才会重新可编辑。
|
|
assert.match(
|
|
ENTRY_SOURCE,
|
|
/function rowLocked\(row: EditableRow\): boolean \{\n\s*return row\.resultState === 'published'\n\}/,
|
|
)
|
|
assert.match(ENTRY_SOURCE, /resultState: result\.state \?\? null|resultState: result\?\.state \?\? null/)
|
|
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| rowLocked\(row\)"/)
|
|
})
|
|
|
|
test('已作废的行有独立徽标,不会被显示成「已发布」', () => {
|
|
assert.match(
|
|
ENTRY_SOURCE,
|
|
/v-if="row\.resultState === 'published' && row\.status === 'withdrawn'"/,
|
|
)
|
|
assert.match(ENTRY_SOURCE, />已作废<\/span>/)
|
|
})
|
|
|
|
test('表格多了一列「操作」,空状态行的 colspan 跟着改', () => {
|
|
assert.match(ENTRY_SOURCE, /<th class="center action-col">操作<\/th>/)
|
|
assert.match(ENTRY_SOURCE, /<td colspan="12">/)
|
|
assert.doesNotMatch(ENTRY_SOURCE, /<td colspan="11">/)
|
|
})
|
|
|
|
// ── ConfirmDialog 的必填原因 ────────────────────────────────────────────────
|
|
|
|
test('原因为空时不提交(emit confirm),只亮出提示', () => {
|
|
const submit = section(DIALOG_SOURCE, 'function requestConfirm(', 'function onKeydown(')
|
|
assert.match(submit, /if \(reasonMissing\.value\) \{/)
|
|
assert.match(submit, /reasonTouched\.value = true/)
|
|
assert.ok(
|
|
submit.indexOf('reasonMissing.value') < submit.indexOf("emit('confirm'"),
|
|
'必填判断要在 emit 之前',
|
|
)
|
|
assert.match(submit, /emit\('confirm', reason\.value\.trim\(\)\)/)
|
|
})
|
|
|
|
test('每次打开弹窗都清空上一次的原因', () => {
|
|
assert.match(DIALOG_SOURCE, /watch\(\n\s*\(\) => props\.open,/)
|
|
assert.match(DIALOG_SOURCE, /reason\.value = ''\n\s*reasonTouched\.value = false/)
|
|
})
|
|
|
|
test('没有必填原因的老用法不受影响:reasonRequired 默认 false', () => {
|
|
assert.match(DIALOG_SOURCE, /reasonRequired: false/)
|
|
assert.match(DIALOG_SOURCE, /<label v-if="reasonRequired" class="confirm-reason">/)
|
|
})
|