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

+178
View File
@@ -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>): 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(`</span>${label}</div>`), `缺少卡片:${label}`)
}
for (const label of ['领先单位', '已发布项目', '纪录条目', '实时连接']) {
assert.match(DASH_SOURCE, new RegExp(`</span>${label}</div>`), `旧卡片被删了:${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('<template>'))
assert.doesNotMatch(page, /scheduledAt/, '模板里不该直接比较排期字符串')
})
@@ -0,0 +1,188 @@
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">/)
})
@@ -0,0 +1,206 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 新增接口的契约(P2 A16 / A41 / C11):
* 作废成绩、把作废的成绩退回草稿、破纪录登记列表 / 驳回、历史纪录的登记与删除。
*
* 这些路径都由服务端契约冻结,前端只负责「发对地址、传对字段、别自作主张降级」。
* 尤其是 recordBreaks:它**不能**在连不上服务端时退化成空数组——
* 那会把「读不到」显示成「没有已批准的破纪录」,现场据此以为加分从来没批过。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就绑定了当时的 fetch。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function sentBody(): Record<string, unknown> {
return JSON.parse(String(lastRequest.body)) as Record<string, unknown>
}
const { ApiError, adminApi } = await import('../api/client')
const BREAK = {
id: 12,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'approved',
approvedBy: 1,
approvedAt: '2026-09-12T09:00:00Z',
note: null,
}
// ── 成绩:作废 / 退回草稿 ───────────────────────────────────────────────────
test('作废一条成绩:POST /api/results/{id}/void,原因随请求体发出', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 9001, status: 'withdrawn', state: 'published' } }))
const result = await adminApi.voidResult(9001, { reason: '成绩抄错' })
assert.equal(lastRequest.url, '/api/results/9001/void')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '成绩抄错')
// 幂等键由客户端注入,服务端靠它做重试去重。
assert.equal(typeof sentBody().requestId, 'string')
assert.equal(result.status, 'withdrawn')
})
test('退回草稿:POST /api/results/{id}/reopen,后端的业务错误原样抛出', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 9001, status: 'ok', state: 'draft' } }))
const result = await adminApi.reopenResult(9001, { reason: '改回来' })
assert.equal(lastRequest.url, '/api/results/9001/reopen')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '改回来')
assert.equal(result.state, 'draft')
// 「这条没被作废」是服务端的状态判断,不能让前端吞成成功。
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'STATE_INVALID', message: '该成绩未作废,无需重新录入' } }, 409),
)
await assert.rejects(
() => adminApi.reopenResult(9001, { reason: '改回来' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'STATE_INVALID')
assert.equal(error.message, '该成绩未作废,无需重新录入')
return true
},
)
})
// ── 破纪录登记 ──────────────────────────────────────────────────────────────
test('登记列表按 state 过滤,字段(含新增的 note)原样透传', async () => {
respondWith(() => jsonResponse({ ok: true, data: [BREAK, { ...BREAK, id: 11, note: '误判' }] }))
const list = await adminApi.recordBreaks('approved')
assert.equal(lastRequest.url, '/api/record-breaks?state=approved')
assert.equal(lastRequest.method, 'GET')
assert.equal(list.length, 2)
assert.equal(list[0]?.bonusPoints, 5)
assert.equal(list[0]?.note, null)
assert.equal(list[1]?.note, '误判')
})
test('登记列表读不到时抛错,不退化成空数组', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => adminApi.recordBreaks('approved'),
(error: unknown) => error instanceof ApiError && error.code === 'NETWORK',
)
})
test('驳回 / 作废登记:POST /api/record-breaks/{id}/reject,原因必填', async () => {
respondWith(() => jsonResponse({ ok: true, data: { ...BREAK, state: 'rejected', note: '现场判定不是破纪录' } }))
const updated = await adminApi.rejectBreak(12, '现场判定不是破纪录')
assert.equal(lastRequest.url, '/api/record-breaks/12/reject')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '现场判定不是破纪录')
assert.equal(updated.state, 'rejected')
assert.equal(updated.note, '现场判定不是破纪录')
})
// ── 纪录库:新增历史纪录 / 删除 ─────────────────────────────────────────────
test('新增历史纪录:POST /api/records,返回体里的 source 原样带回', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { id: 3, eventId: 101, level: 'meet', resultText: '11.20', source: 'history', isCurrent: false },
}),
)
const created = await adminApi.createRecord({
eventId: 101,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
holderName: '张三',
holderTeamId: 1,
achievedAt: '2025-09-01',
source: 'history',
})
assert.equal(lastRequest.url, '/api/records')
assert.equal(lastRequest.method, 'POST')
const body = sentBody()
assert.equal(body.eventId, 101)
assert.equal(body.level, 'meet')
assert.equal(body.resultValue, 11.2)
assert.equal(body.holderName, '张三')
assert.equal(body.holderTeamId, 1)
assert.equal(body.achievedAt, '2025-09-01')
assert.equal(created.source, 'history')
})
test('删除纪录:DELETE /api/records/{id},空响应也算成功', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.removeRecord(3)
assert.equal(lastRequest.url, '/api/records/3')
assert.equal(lastRequest.method, 'DELETE')
assert.equal(lastRequest.body, undefined)
})
test('删不存在 / 已被别人删掉的纪录时 404 原文原样抛出', async () => {
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '纪录不存在' } }, 404))
await assert.rejects(
() => adminApi.removeRecord(99),
(error: unknown) => error instanceof ApiError && error.status === 404 && error.message === '纪录不存在',
)
})
// ── 仪表盘的两个计数 ────────────────────────────────────────────────────────
test('草稿待发布数取分页接口的 total,不拉全量列表', async () => {
respondWith(() => jsonResponse({ ok: true, data: { items: [], total: 17, page: 1, size: 1 } }))
const total = await adminApi.draftResultCount()
assert.equal(lastRequest.url, '/api/results?state=draft&page=1&size=1')
assert.equal(total, 17)
})
test('计数接口读不到时抛错,由卡片显示 —(不用 0 冒充)', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(() => adminApi.draftResultCount())
})
+196
View File
@@ -0,0 +1,196 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { RecordBreak } from '@/shared'
import {
IGNORE_BREAK_REASON,
breakBonusText,
breakKey,
breakNote,
handledBreakKeys,
RECORD_LEVEL_LABELS,
RECORD_LEVEL_OPTIONS,
recordSourceLabel,
} from '../pages/recordBreaks'
/**
* 破纪录的驳回 / 作废(P2 A41)。
*
* 以前只有「登记 + 批准」两条路:误判的纪录在系统内无法纠正,「忽略」还只存在浏览器内存里
* (刷新就回来)。现在驳回与作废都落服务端登记表,「已处理」的键用来抑制前端推导出来的疑似列表。
*
* 项目没有组件测试工具,纯逻辑直接测,界面接线读源码断言。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.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)
}
function makeBreak(overrides: Partial<RecordBreak>): RecordBreak {
return {
id: 1,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'pending',
approvedBy: null,
approvedAt: null,
...overrides,
}
}
// ── 已处理登记的键(抑制前端推导出的疑似列表) ───────────────────────────────
test('疑似条目的键与服务端登记行的键必须能对上(对不上就抑制不掉)', () => {
assert.equal(breakKey(101, 7001, 'meet'), '101-7001-meet')
})
test('approved / rejected 的登记会被抑制,pending 的不会', () => {
const keys = handledBreakKeys([
makeBreak({ id: 1, state: 'approved' }),
makeBreak({ id: 2, state: 'rejected', entryId: 7002 }),
makeBreak({ id: 3, state: 'pending', entryId: 7003 }),
])
assert.equal(keys.has(breakKey(101, 7001, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7002, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7003, 'meet')), false)
})
test('同一报名项的不同档位是两条独立的登记', () => {
const keys = handledBreakKeys([makeBreak({ state: 'approved', level: 'province' })])
assert.equal(keys.has(breakKey(101, 7001, 'province')), true)
assert.equal(keys.has(breakKey(101, 7001, 'meet')), false)
})
test('待确认列表按三层过滤(本机标记 + 服务端已处理)', () => {
const pending = section(RECORDS_SOURCE, 'const pendingBreaks = computed(', 'function levelRank(')
assert.match(pending, /!rejectedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!approvedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!handledKeys\.value\.has\(item\.key\)/)
})
test('推导疑似条目时用的就是 breakKey,两边不会各算一套', () => {
assert.match(RECORDS_SOURCE, /key: breakKey\(event\.id, entry\.id, best\.level\)/)
})
// ── 字段容错 ────────────────────────────────────────────────────────────────
test('来源:history / current 各有文案,缺字段显示 —(不冒充「历史」)', () => {
assert.equal(recordSourceLabel('history'), '历史')
assert.equal(recordSourceLabel('current'), '本届')
assert.equal(recordSourceLabel(null), '—')
assert.equal(recordSourceLabel(undefined), '—')
})
test('登记说明缺字段时不渲染', () => {
assert.equal(breakNote({ note: '现场判定不是破纪录' }), '现场判定不是破纪录')
assert.equal(breakNote({ note: ' 驳回 ' }), '驳回')
assert.equal(breakNote({ note: '' }), null)
assert.equal(breakNote({ note: null }), null)
assert.equal(breakNote({}), null)
})
test('加分缺字段时显示 —,不渲染成「+—」', () => {
assert.equal(breakBonusText({ bonusPoints: 5 }), '+5')
assert.equal(breakBonusText({ bonusPoints: 2.5 }), '+2.5')
assert.equal(breakBonusText({ bonusPoints: null }), '—')
assert.equal(breakBonusText({}), '—')
})
test('四个档位的文案与下拉选项齐全', () => {
assert.deepEqual(RECORD_LEVEL_OPTIONS.map((option) => option.value), [
'meet',
'city',
'province',
'national',
])
assert.equal(RECORD_LEVEL_LABELS.meet, '赛会')
assert.equal(RECORD_LEVEL_LABELS.national, '全国')
})
// ── 已批准的破纪录卡片 ──────────────────────────────────────────────────────
test('卡片数据来自 GET /api/record-breaks?state=approved', () => {
const loadBreaks = section(RECORDS_SOURCE, 'async function loadBreaks(', 'async function loadSuspected(')
assert.match(loadBreaks, /adminApi\.recordBreaks\('approved'\)/)
assert.match(loadBreaks, /adminApi\.recordBreaks\('rejected'\)/)
// 两态分开取、任一失败都只影响这张卡片:用 allSettled,不用 Promise.all(一个挂全挂)。
assert.match(loadBreaks, /Promise\.allSettled\(\[/)
assert.match(loadBreaks, /breaksUnavailable\.value = failure !== undefined/)
assert.match(RECORDS_SOURCE, /<span class="mx-card-title">已批准的破纪录<\/span>/)
})
test('接口读不到时卡片显示空态说明,不让整页报错', () => {
assert.match(RECORDS_SOURCE, /v-else-if="breaksUnavailable"/)
assert.match(RECORDS_SOURCE, /读不到破纪录登记/)
assert.match(RECORDS_SOURCE, /示例数据模式或后端版本较旧时没有这个接口/)
// 空态在「没有已批准的破纪录」之前判断:读不到不能被说成「没有」。
assert.ok(
RECORDS_SOURCE.indexOf('v-else-if="breaksUnavailable"') <
RECORDS_SOURCE.indexOf('approvedBreaks.length === 0'),
)
})
test('访客 403 与后端太旧分开说:权限问题不能显示成「接口不可用」', () => {
assert.match(RECORDS_SOURCE, /breaksForbidden\.value = failures\.some\(\(item\) => isForbidden\(item\.reason\)\)/)
assert.match(RECORDS_SOURCE, /访客只读:看不到破纪录登记/)
assert.match(RECORDS_SOURCE, /要求录入员及以上权限/)
})
test('每条登记一个「作废」按钮,调用 reject 并带上必填原因', () => {
assert.match(RECORDS_SOURCE, />\s*作废\s*<\/button>/)
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await adminApi\.rejectBreak\(item\.id, reason\)/)
assert.match(RECORDS_SOURCE, /:reason-required="true"/)
assert.match(RECORDS_SOURCE, /@confirm="confirmRevoke"/)
})
test('作废成功后刷新纪录库与榜单,并提示加分已撤回', () => {
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await Promise\.all\(\[loadRecords\(\), data\.refreshStandings\(\)\]\)/)
assert.match(revoke, /'加分已撤回,该级别纪录已恢复上一条'/)
assert.ok(
revoke.indexOf('rejectBreak') < revoke.indexOf('loadRecords()'),
'先作废再刷新,顺序反了会把旧数据当新数据',
)
})
test('访客只读时「作废」按钮置灰', () => {
const card = section(RECORDS_SOURCE, '<span class="mx-card-title">已批准的破纪录</span>', '</section>')
assert.match(card, /:disabled="!canWrite \|\| revoking"/)
})
// ── 「忽略」改成持久 ────────────────────────────────────────────────────────
test('忽略先登记再驳回,原因是固定的「现场判定不是破纪录」', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /adminApi\.client\.records\.createBreak\(/)
assert.match(reject, /await adminApi\.rejectBreak\(created\.id, IGNORE_BREAK_REASON\)/)
assert.equal(IGNORE_BREAK_REASON, '现场判定不是破纪录')
assert.ok(
reject.indexOf('createBreak') < reject.indexOf('rejectBreak(created.id'),
'必须先拿到 pending 行才能驳回',
)
})
test('写不进服务端时才退回本机标记,并如实说明刷新后会回来', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /toast\.warning\('已在本机忽略(未连接服务端)', '没有写入服务端,刷新页面后该条会重新出现'\)/)
// 不能出现「只在本地隐藏」这种旧说法:现在默认是落服务端的。
assert.doesNotMatch(RECORDS_SOURCE, /接口未提供拒绝动作/)
})
test('忽略确认框的正文按模式说清后果(示例数据下不能承诺「不会再出现」)', () => {
const message = section(RECORDS_SOURCE, 'const rejectMessage = computed(', 'async function confirmReject(')
assert.match(message, /示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。/)
assert.match(message, /会在服务端登记为一条破纪录记录并立即驳回/)
assert.match(RECORDS_SOURCE, /:message="rejectMessage"/)
})
+139
View File
@@ -0,0 +1,139 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { emptyRecordForm, planRecordInput, type RecordFormState } from '../pages/recordForm'
/**
* 纪录库的历史纪录录入与删除(P2 C11)。
*
* `records.source='history'` 以前没有任何写入路径,所以「来源」这一列恒为「本届」——
* 界面上承诺的「历史 / 本届」实际只有一半。现在接上 POST /api/records(固定写 history)
* 与 DELETE /api/records/{id}(删当前纪录时服务端恢复上一条)。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.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)
}
function form(overrides: Partial<RecordFormState>): RecordFormState {
return { ...emptyRecordForm(101), ...overrides }
}
// ── 表单口径 ────────────────────────────────────────────────────────────────
test('没选小项时不提交', () => {
const plan = planRecordInput(form({ eventId: null, resultText: '11.20' }))
assert.equal(plan.ok, false)
assert.equal(plan.ok === false ? plan.error : '', '请选择小项')
})
test('成绩值与成绩文本至少填一个(历史资料常常只有手抄文本)', () => {
const empty = planRecordInput(form({ resultValue: '', resultText: ' ' }))
assert.equal(empty.ok, false)
assert.equal(empty.ok === false ? empty.error : '', '成绩值或成绩文本至少填一个')
const textOnly = planRecordInput(form({ resultText: '11.20' }))
assert.equal(textOnly.ok, true)
assert.equal(textOnly.ok === true ? textOnly.input.resultText : '', '11.20')
assert.equal(textOnly.ok === true ? textOnly.input.resultValue : 1, null)
const valueOnly = planRecordInput(form({ resultValue: '11.2' }))
assert.equal(valueOnly.ok, true)
assert.equal(valueOnly.ok === true ? valueOnly.input.resultValue : 0, 11.2)
assert.equal(valueOnly.ok === true ? valueOnly.input.resultText : 'x', null)
})
test('成绩值写错时明确报错,不静默丢成空值', () => {
const plan = planRecordInput(form({ resultValue: '11.2秒' }))
assert.equal(plan.ok, false)
assert.match(plan.ok === false ? plan.error : '', /数字/)
})
test('来源固定 history;空字符串归一成 null(服务端不该存空串)', () => {
const plan = planRecordInput(
form({ resultValue: '11.2', holderName: ' 张三 ', achievedAt: '', holderTeamId: null }),
)
assert.equal(plan.ok, true)
if (plan.ok !== true) return
assert.equal(plan.input.source, 'history')
assert.equal(plan.input.holderName, '张三')
assert.equal(plan.input.achievedAt, null)
assert.equal(plan.input.holderTeamId, null)
assert.equal(plan.input.level, 'meet')
})
test('保持单位与创造日期有值时原样提交', () => {
const plan = planRecordInput(form({ resultValue: '11.2', holderTeamId: 3, achievedAt: '2025-09-01' }))
assert.equal(plan.ok, true)
if (plan.ok !== true) return
assert.equal(plan.input.holderTeamId, 3)
assert.equal(plan.input.achievedAt, '2025-09-01')
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('纪录库卡片有「新增历史纪录」入口,表单字段齐全', () => {
assert.match(RECORDS_SOURCE, /新增历史纪录\s*<\/button>/)
const modal = section(RECORDS_SOURCE, '新增历史纪录表单', 'class="mx-modal-foot"')
assert.match(modal, /v-for="event in data\.events"/)
assert.match(modal, /v-for="option in RECORD_LEVEL_OPTIONS"/)
assert.match(modal, /createForm\.resultValue/)
assert.match(modal, /createForm\.resultText/)
assert.match(modal, /createForm\.holderName/)
assert.match(modal, /createForm\.holderTeamId/)
assert.match(modal, /type="date"/)
})
test('提交走 POST /api/records 并刷新列表与疑似推导', () => {
const submit = section(RECORDS_SOURCE, 'async function submitCreate(', 'function requestDelete(')
assert.match(submit, /const plan = planRecordInput\(createForm\.value\)/)
assert.match(submit, /await adminApi\.createRecord\(plan\.input\)/)
assert.match(submit, /await loadRecords\(\)/)
assert.match(submit, /await loadSuspected\(\)/)
assert.ok(
submit.indexOf('createRecord') < submit.indexOf('loadRecords()'),
'先写入再刷新,顺序反了会把旧数据当新数据',
)
})
test('表单校验不通过时把错误显示出来,不发请求', () => {
const submit = section(RECORDS_SOURCE, 'async function submitCreate(', 'function requestDelete(')
assert.match(submit, /if \(!plan\.ok\) \{\n\s*createError\.value = plan\.error\n\s*return\n\s*\}/)
assert.match(RECORDS_SOURCE, /v-if="createError !== ''"/)
})
test('每行一个「删除」,确认框说清「当前纪录会恢复上一条」', () => {
assert.match(RECORDS_SOURCE, />\s*删除\s*<\/button>/)
assert.match(RECORDS_SOURCE, /await adminApi\.removeRecord\(record\.id\)/)
assert.match(RECORDS_SOURCE, /若它是当前纪录,会恢复上一条/)
assert.match(RECORDS_SOURCE, /@confirm="confirmDelete"/)
})
test('列表把 source 显示出来(历史 / 本届),缺字段显示 —', () => {
assert.match(RECORDS_SOURCE, /\{\{ recordSourceLabel\(record\.source\) \}\}/)
assert.match(RECORDS_SOURCE, /record\.source === 'history' \? 'mx-badge-neutral' : 'mx-badge-info'/)
})
test('guest 只读:新增与删除都置灰,写操作前置检查先拦示例数据', () => {
const create = section(RECORDS_SOURCE, 'function openCreate(', 'function closeCreate(')
assert.match(create, /if \(blockSampleData\('新增历史纪录'\)\) return/)
assert.match(create, /if \(!canWrite\.value\) \{/)
const remove = section(RECORDS_SOURCE, 'function requestDelete(', 'async function confirmDelete(')
assert.match(remove, /if \(blockSampleData\('删除纪录'\)\) return/)
assert.match(remove, /if \(!canWrite\.value\) \{/)
const head = section(RECORDS_SOURCE, '<span class="mx-card-title">纪录库</span>', '<div class="mx-tabs">')
assert.match(head, /:disabled="!canWrite"/)
assert.match(RECORDS_SOURCE, /:disabled="!canWrite \|\| deleting"/)
})
test('示例数据拦截用统一口径(不是各写一句提示)', () => {
const block = section(RECORDS_SOURCE, 'function blockSampleData(', 'function requestRevoke(')
assert.match(block, /if \(!data\.isMock\) return false/)
assert.match(block, /sampleDataBlockedNotice\(action\)/)
assert.match(block, /toast\.error\(notice\.title, notice\.detail\)/)
})