feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)

用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
AskaEth committed 2026-09-11 17:14:02 +08:00
1 parent 8dd599bd97
commit 631c8dd8e1
80 files changed
+12468 -519

No files matched your search

@@ -0,0 +1,135 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 运动员批量动作的前端契约。
*
* 这是绕开写限流的正解:逐个 DELETE 是「一人一条请求」,现场清理几十个人就会撞上配额;
* 因此这里钉住三件事——一个请求完成整批、逐条结果按顺序保留、缺失的逐条失败不能变成整批成功。
*/
interface Captured {
url?: string
method?: string
body?: string
}
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: { items: [] } })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
body: typeof init?.body === 'string' ? init.body : undefined,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
function requestBody(): Record<string, unknown> {
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
delete parsed['requestId']
return parsed
}
const { adminApi } = await import('../api/client')
function item(id: number, ok = true, extra: Record<string, unknown> = {}): Record<string, unknown> {
return { id, ok, ...extra }
}
test('批量停用是一个请求:POST /api/athletes/batch,body 带 action 与 ids', async () => {
respondWith(() => jsonResponse({ ok: true, data: { action: 'disable', items: [item(1), item(2)] } }))
await adminApi.client.athletes.batch('disable', [1, 2])
assert.equal(lastRequest.url, '/api/athletes/batch')
assert.equal(lastRequest.method, 'POST')
assert.deepEqual(requestBody(), { action: 'disable', ids: [1, 2] })
})
test('dryRun 才显式带 dryRun: true,普通请求不带(老服务端不能被静默降级成预览)', async () => {
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', dryRun: true, items: [item(1)] } }))
const preview = await adminApi.client.athletes.batch('purge', [1], true)
assert.deepEqual(requestBody(), { action: 'purge', ids: [1], dryRun: true })
assert.equal(preview.dryRun, true)
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', items: [item(1)] } }))
await adminApi.client.athletes.batch('purge', [1])
assert.deepEqual(requestBody(), { action: 'purge', ids: [1] })
})
test('逐条成败与合计按 items 还原,purge 统计原样带出', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
action: 'purge',
requested: 3,
succeeded: 2,
failed: 1,
items: [item(1), item(2), item(99, false, { code: 'NOT_FOUND', message: '运动员不存在' })],
purge: {
deletedEntries: 2,
deletedResults: 2,
deletedLedger: 3,
deletedBreaks: 0,
recomputedEvents: [5],
},
},
}),
)
const result = await adminApi.client.athletes.batch('purge', [1, 2, 99])
assert.equal(result.requested, 3)
assert.equal(result.succeeded, 2)
assert.equal(result.failed, 1)
assert.deepEqual(result.items[2], { id: 99, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
assert.deepEqual(result.purge, {
deletedEntries: 2,
deletedResults: 2,
deletedLedger: 3,
deletedBreaks: 0,
recomputedEvents: [5],
})
})
test('逐条结果缺失时按失败处理:删不掉不能报成删掉了', async () => {
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge' } }))
const result = await adminApi.client.athletes.batch('purge', [1, 2])
assert.deepEqual(result.items, [])
assert.equal(result.succeeded, 0)
assert.equal(result.failed, 0)
assert.equal(result.purge, undefined)
})
test('响应里没有 ok: true 的条目按失败处理,不猜成成功', async () => {
respondWith(() =>
jsonResponse({ ok: true, data: { action: 'disable', items: [{ id: 1 }, { id: 2, ok: true }] } }),
)
const result = await adminApi.client.athletes.batch('disable', [1, 2])
assert.equal(result.succeeded, 1)
assert.equal(result.failed, 1)
assert.equal(result.items[0]?.ok, false)
assert.equal(result.items[1]?.ok, true)
})
@@ -0,0 +1,202 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import {
downloadBaseDataXLSX,
downloadEntriesXLSX,
entriesExportPath,
importBaseDataXLSX,
} from '@/shared/basedata'
/**
* 报名名单导出与基础数据包导入的前端契约:路径、筛选参数、dryRun 标志、解析出的差异。
*
* 路径或参数写错时现场拿到的是 404 或「筛了却导出全量」;dryRun 标志丢了则会
* 让「预览差异」直接写库。这里用 stub fetch 钉住真正发出去的请求。
*/
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' },
})
}
function xlsxResponse(): Response {
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
status: 200,
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
})
}
let reply: () => Response = () => xlsxResponse()
let lastRequest: Captured = {}
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(next: () => Response): void {
lastRequest = {}
reply = next
}
function formValue(body: unknown, key: string): FormDataEntryValue | null {
assert.ok(body instanceof FormData, '上传必须是 multipart FormData')
return body.get(key)
}
test('报名名单导出路径按筛选条件拼 query,留空的维度不出现', () => {
assert.equal(entriesExportPath(), '/export/entries.xlsx')
assert.equal(entriesExportPath({ eventId: 3 }), '/export/entries.xlsx?eventId=3')
assert.equal(
entriesExportPath({ eventId: 3, categoryId: 9, teamId: 1, heat: 2 }),
'/export/entries.xlsx?eventId=3&categoryId=9&teamId=1&heat=2',
)
})
test('导出报名名单走 GET /api/export/entries.xlsx,文件名是「报名名单.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await downloadEntriesXLSX({ eventId: 3, heat: 2 })
assert.equal(lastRequest.url, '/api/export/entries.xlsx?eventId=3&heat=2')
assert.equal(lastRequest.method, 'GET')
assert.equal(result.filename, '报名名单.xlsx')
assert.equal(result.blob.size, 4)
})
test('导出基础数据包走 GET /api/export/basedata.xlsx,文件名是「基础数据包.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await downloadBaseDataXLSX()
assert.equal(lastRequest.url, '/api/export/basedata.xlsx')
assert.equal(result.filename, '基础数据包.xlsx')
})
test('预览差异带 dryRun=1,结果里的三类计数与逐行差异都解析出来', async () => {
respondWith(() => jsonResponse({
ok: true,
data: {
dryRun: true,
added: 1,
updated: 2,
unchanged: 5,
errorCount: 1,
errors: ['单位 第 3 行:单位编码必填'],
changes: [
{
sheet: '单位',
line: 2,
action: 'updated',
key: 'T1',
fields: [{ field: '单位名称', from: '第一分公司', to: '第一分公司(改)' }],
},
],
},
}))
const file = new File(['x'], 'basedata.xlsx')
const result = await importBaseDataXLSX(file, { dryRun: true })
assert.equal(lastRequest.url, '/api/export/basedata/import')
assert.equal(lastRequest.method, 'POST')
assert.equal(formValue(lastRequest.body, 'dryRun'), '1')
assert.equal(formValue(lastRequest.body, 'file'), file)
assert.equal(result.dryRun, true)
assert.equal(result.added, 1)
assert.equal(result.updated, 2)
assert.equal(result.unchanged, 5)
assert.equal(result.errorCount, 1)
assert.deepEqual(result.errors, ['单位 第 3 行:单位编码必填'])
assert.equal(result.changes.length, 1)
assert.equal(result.changes[0].sheet, '单位')
assert.equal(result.changes[0].line, 2)
assert.equal(result.changes[0].fields[0].field, '单位名称')
assert.equal(result.changes[0].fields[0].from, '第一分公司')
assert.equal(result.changes[0].fields[0].to, '第一分公司(改)')
})
test('确认写入不带 dryRun,避免预览之后又走一次只读分支', async () => {
respondWith(() => jsonResponse({
ok: true,
data: { dryRun: false, added: 3, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] },
}))
const result = await importBaseDataXLSX(new File(['x'], 'basedata.xlsx'), { dryRun: false })
assert.equal(formValue(lastRequest.body, 'dryRun'), null)
assert.equal(result.dryRun, false)
assert.equal(result.added, 3)
})
test('服务端 400 的行级错误原样抛出,页面能拿到那句中文', async () => {
respondWith(() => jsonResponse({
ok: false,
error: { code: 'VALIDATION_FAILED', message: '导出链接参数不对' },
}, 400))
await assert.rejects(
() => downloadEntriesXLSX({ heat: 0 }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 400)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.equal(error.message, '导出链接参数不对')
return true
},
)
})
test('导入 403 / 500 原样抛出,不降级成示例数据', async () => {
respondWith(() => jsonResponse({
ok: false,
error: { code: 'FORBIDDEN', message: '只有管理员可以导入基础数据' },
}, 403))
await assert.rejects(
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'FORBIDDEN')
assert.equal(error.message, '只有管理员可以导入基础数据')
return true
},
)
})
test('响应不是 {ok,data} 结构时报解析错误,而不是假装导入成功', async () => {
respondWith(() => jsonResponse({ hello: 'world' }))
await assert.rejects(
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'INTERNAL')
return true
},
)
})
test('连不上服务端时报 NETWORK,页面据此区分「后端未就绪」', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => downloadEntriesXLSX(),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
return true
},
)
})
@@ -0,0 +1,122 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 组别「就地更新」的前端契约:方法 / 路径 / 请求体,以及响应是否按组别列表回传。
*
* 这条路径是有报名数据的小项唯一能改名的通道,路径拼错(比如漏掉 categoryId)
* 会静默走回整表替换的 409,现场只会看到「已有报名数据不能增删组别」。
*/
interface Captured {
url?: string
method?: string
body?: string
}
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: [] })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
body: typeof init?.body === 'string' ? init.body : undefined,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
function requestBody(): Record<string, unknown> {
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
delete parsed['requestId']
return parsed
}
const { adminApi } = await import('../api/client')
const input = {
name: '男子甲组',
multiplier: 2,
gender: '男' as const,
ageMin: 18,
ageMax: 35,
sortOrder: 1,
}
test('就地更新走 PUT /api/events/{id}/categories/{categoryId},请求体是单个组别', async () => {
respondWith(() => jsonResponse({ ok: true, data: [] }))
await adminApi.client.events.updateCategory(7, 12, input)
assert.equal(lastRequest.url, '/api/events/7/categories/12')
assert.equal(lastRequest.method, 'PUT')
assert.deepEqual(requestBody(), input)
})
test('响应按组别列表回传,直接拿到更新后的名字', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: [
{ id: 12, eventId: 7, name: '男子甲组', multiplier: 2, gender: '男', ageMin: 18, ageMax: 35, sortOrder: 1 },
],
}),
)
const categories = await adminApi.client.events.updateCategory(7, 12, input)
assert.equal(categories.length, 1)
assert.equal(categories[0]?.name, '男子甲组')
assert.equal(categories[0]?.multiplier, 2)
})
test('服务端 409 的原文要能被页面拿到(提示里那句话是现场唯一的线索)', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '组别名称重复:男子' } }, 409),
)
await assert.rejects(
() => adminApi.client.events.updateCategory(7, 12, input),
(error: unknown) => (error as { message?: string }).message === '组别名称重复:男子',
)
})
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
const { mockBaseData } = await import('../components/baseData/mockBaseData')
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
respondWith(() => {
throw new TypeError('Failed to fetch')
})
const event = mockBaseData.listEvents()[0]
assert.ok(event !== undefined)
const category = (mockBaseData.getEvent(event.id).categories ?? [])[0]
assert.ok(category !== undefined)
const categories = await baseDataApi.events.updateCategory(event.id, category.id, {
...input,
name: '降级改名',
})
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
assert.equal(baseDataFallback.value, true)
const athlete = mockBaseData.listAthletes()[0]
assert.ok(athlete !== undefined)
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
assert.equal(result.succeeded, 1)
assert.equal(result.failed, 0)
})
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import { ENTRY_BATCH_PATH, submitEntryBatch } from '../pages/entryBatch'
/**
* 报名项批量操作的请求契约:方法 / 路径 / 请求体字段必须与服务端
* internal/httpapi/entries_batch.go 的 entryBatchPayload 一致;
* 写请求漏掉 X-CSRF-Token 会被中间件 403,现场只会看到「删不掉」。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: unknown
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function emptySummary() {
return {
ok: 0,
failed: 0,
skipped: 0,
changed: 0,
removedEntries: 0,
removedResults: 0,
removedLedger: 0,
removedBreaks: 0,
recomputedEvents: [],
}
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
test('批量操作走 POST /api/entries/batch,body 带 action + ids', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
items: [{ id: 1, ok: true }],
summary: { ...emptySummary(), ok: 1, changed: 1, recomputedEvents: [3] },
},
}),
)
const result = await submitEntryBatch({ action: 'scratch', ids: [1, 1, 2] })
assert.equal(lastRequest.url, ENTRY_BATCH_PATH)
assert.equal(lastRequest.method, 'POST')
assert.equal(lastRequest.credentials, 'include')
assert.equal((lastRequest.body as { action: string }).action, 'scratch')
assert.deepEqual((lastRequest.body as { ids: number[] }).ids, [1, 1, 2])
// 写请求必须有 requestId(幂等)与 JSON 内容类型。
assert.equal(typeof (lastRequest.body as { requestId?: unknown }).requestId, 'string')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
assert.deepEqual(result.summary.recomputedEvents, [3])
})
// 已登录写请求必须带 CSRF:漏掉就是 403,退赛 / 移出都不会生效。
test('带 csrf_token Cookie 时补上 X-CSRF-Token', async () => {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-123; b=2' }
try {
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
await submitEntryBatch({ action: 'restore', ids: [2] })
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-123')
} finally {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
}
})
test('没有 document(非浏览器环境)时不带 CSRF,也不报错', async () => {
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
await submitEntryBatch({ action: 'remove', ids: [3] })
assert.equal(lastRequest.headers?.['X-CSRF-Token'], undefined)
})
// 服务端的中文原文要原样传给页面;批量里的逐条失败也在 data 里,不能被当成请求失败。
test('服务端报错时原样抛出 code 与中文 message', async () => {
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '一次最多处理 500 条报名项,请分批提交' } }, 400))
await assert.rejects(
() => submitEntryBatch({ action: 'remove', ids: [1] }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /分批提交/)
return true
},
)
})
test('逐条失败(ok:false)仍算成功响应,由页面逐条提示', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
items: [
{ id: 1, ok: true },
{ id: 999999, ok: false, error: '报名项不存在' },
],
summary: { ...emptySummary(), ok: 1, failed: 1 },
},
}),
)
const result = await submitEntryBatch({ action: 'remove', ids: [1, 999999] })
assert.equal(result.summary.failed, 1)
assert.equal(result.items[1]?.error, '报名项不存在')
})
test('网络不可达时报「没有生效」,不能让操作员以为已经删掉了', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntryBatch({ action: 'remove', ids: [1] }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有生效/)
return true
},
)
})
+117
View File
@@ -0,0 +1,117 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
MAX_ENTRY_BATCH,
dedupeEntryIds,
entryBatchOutcome,
entryBatchSizeError,
entryBatchSkipNote,
planEntryBatch,
type EntryBatchRow,
} from '../pages/entryBatch'
function row(overrides: Partial<EntryBatchRow> & { entryId: number }): EntryBatchRow {
return {
name: '张三',
bib: '1042',
team: '第一分公司',
categoryId: 7,
entryStatus: 'registered',
...overrides,
}
}
test('dedupeEntryIds:去重、丢非法 id,保持勾选顺序', () => {
assert.deepEqual(dedupeEntryIds([3, 1, 3, 0, -2, 1, 2.5, 2]), [3, 1, 2])
assert.deepEqual(dedupeEntryIds([]), [])
})
test('entryBatchSizeError:空选与超上限都给出中文提示', () => {
assert.equal(entryBatchSizeError(0), '请先勾选要操作的报名项')
assert.equal(entryBatchSizeError(3), null)
assert.equal(entryBatchSizeError(MAX_ENTRY_BATCH), null)
assert.match(entryBatchSizeError(MAX_ENTRY_BATCH + 1) ?? '', /分批提交/)
})
// dq / dns 是别人的判定:退赛不能覆盖,跳过时必须说清原因。
test('entryBatchSkipNote:只动 registered / scratched,其余跳过并说明', () => {
assert.equal(entryBatchSkipNote('scratch', 'registered'), null)
assert.equal(entryBatchSkipNote('scratch', 'scratched'), '已是退赛状态,跳过')
assert.equal(entryBatchSkipNote('scratch', 'dq'), '已被判罚取消资格(dq),跳过,不覆盖原判定')
assert.equal(entryBatchSkipNote('scratch', 'dns'), '已标记未出场(dns),跳过,不覆盖原判定')
assert.equal(entryBatchSkipNote('restore', 'scratched'), null)
assert.equal(entryBatchSkipNote('restore', 'registered'), '当前正常参赛,无需恢复')
// 移出报名不看状态:报名项本身就是要被物理删除的。
assert.equal(entryBatchSkipNote('remove', 'dq'), null)
})
test('planEntryBatch:退赛只改 registered,跳过的人在确认框里列出来', () => {
const plan = planEntryBatch('scratch', [
row({ entryId: 1 }),
row({ entryId: 2, entryStatus: 'dq', name: '李四' }),
row({ entryId: 1 }),
])
// 请求仍带上全部勾选 id:跳过与否由服务端逐条回报,前端不替它过滤。
assert.deepEqual(plan.ids, [1, 2])
assert.equal(plan.changed, 1)
assert.equal(plan.scopeCount, 1)
assert.deepEqual(plan.skipped, [{ name: '李四', note: '已被判罚取消资格(dq),跳过,不覆盖原判定' }])
assert.ok(plan.details.some((line) => line.includes('李四')))
assert.match(plan.message, /0 分且不占名次/)
})
test('planEntryBatch:恢复只改已退赛的人', () => {
const plan = planEntryBatch('restore', [
row({ entryId: 1, entryStatus: 'scratched' }),
row({ entryId: 2, entryStatus: 'registered', name: '王五' }),
])
assert.deepEqual(plan.ids, [1, 2])
assert.equal(plan.changed, 1)
assert.deepEqual(plan.skipped, [{ name: '王五', note: '当前正常参赛,无需恢复' }])
})
// 移出报名必须让操作员看到「删谁」与「重算几个组别」,规模大时还要明说别人的名次会变。
test('planEntryBatch:移出报名列出被删的人与重算范围', () => {
const plan = planEntryBatch('remove', [
row({ entryId: 1, name: '张三', bib: '1042', team: '第一分公司' }),
row({ entryId: 2, name: '李四', bib: null, team: '第二分公司', categoryId: 8 }),
])
assert.deepEqual(plan.ids, [1, 2])
assert.equal(plan.changed, 2)
assert.equal(plan.scopeCount, 2)
assert.deepEqual(plan.skipped, [])
assert.ok(plan.details.some((line) => line.includes('张三|号码 1042|第一分公司')))
assert.ok(plan.details.some((line) => line.includes('李四|号码 —|第二分公司')))
assert.ok(plan.details.some((line) => line.includes('会重算 1 个小项(当前项目)、2 个组别')))
assert.match(plan.message, /不可恢复/)
})
test('planEntryBatch:超过 20 人时只列前 20 并追加「别人的名次会变」', () => {
const rows = Array.from({ length: 25 }, (_, index) => row({ entryId: index + 1, name: `选手${index + 1}` }))
const plan = planEntryBatch('remove', rows)
assert.equal(plan.changed, 25)
assert.ok(plan.details.some((line) => line.includes('还有 5 人未列出')))
assert.match(plan.message, /别人的名次会变/)
})
test('entryBatchOutcome:汇总逐条成败与删除规模', () => {
const text = entryBatchOutcome({
items: [],
summary: {
ok: 2,
failed: 1,
skipped: 1,
changed: 1,
removedEntries: 1,
removedResults: 1,
removedLedger: 2,
removedBreaks: 0,
recomputedEvents: [3],
},
})
assert.match(text, /成功 2 条(跳过 1 条)/)
assert.match(text, /失败 1 条/)
assert.match(text, /删除报名 1 条/)
assert.match(text, /删除流水 2 条/)
assert.match(text, /重算 1 个小项/)
})
+122
View File
@@ -0,0 +1,122 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { entryProgress, progressScopeLabel, type ProgressRow } from '../pages/entryProgress'
import { judgeEntryRow } from '../pages/entryGuards'
function row(heat: number | null, resultText: string, over: Partial<ProgressRow> = {}): ProgressRow {
return { heat, resultText, rank: null, status: 'ok', ...over }
}
test('分母是当前筛选范围:选中某一批时只数这一批', () => {
const rows = [row(1, '66'), row(1, ''), row(2, '70.1'), row(2, '71.2')]
const heat2 = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
assert.equal(heat2.total, 2)
assert.equal(heat2.filled, 2)
assert.equal(heat2.done, true)
assert.equal(heat2.text, '第 2 批 已录完 2/2')
assert.equal(heat2.percent, 100)
const heat1 = entryProgress({ rows, filter: 1, unit: 's', needsRank: false })
assert.equal(heat1.total, 2)
assert.equal(heat1.filled, 1)
assert.equal(heat1.missing, 1)
assert.equal(heat1.text, '第 1 批 已录 1/2')
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
assert.equal(all.total, 4)
assert.equal(all.filled, 3)
assert.equal(all.text, '全部 已录 3/4')
assert.equal(all.percent, 75)
assert.equal(all.done, false)
})
test('筛选「未分批」只数没有批次号的行', () => {
const rows = [row(null, '66'), row(null, ''), row(1, '70')]
const progress = entryProgress({ rows, filter: 'none', unit: 's', needsRank: false })
assert.equal(progress.total, 2)
assert.equal(progress.filled, 1)
assert.equal(progress.text, '未分批 已录 1/2')
})
// 关键约束:进度条的「已录」必须与发布防呆 / 行徽标完全同源,否则会出现
// 「进度条说录完了、发布说缺 8 条」。这里直接拿 judgeEntryRow 的结果对账。
test('已录判据与 entryGuards 的 judgeEntryRow 同源(逐行对账)', () => {
const rows = [
row(1, '66.33'),
row(1, ''),
row(1, '1:70'),
row(1, '', { status: 'dns' }),
row(1, '0'),
row(1, '', { rank: 1 }),
row(1, ' '),
]
for (const needsRank of [false, true]) {
const expected = rows.filter((item, index) => {
const judged = judgeEntryRow({
label: `第 ${index + 1} 行`,
resultText: item.resultText,
unit: 's',
status: item.status,
rank: item.rank,
needsRank,
})
return judged.state === 'filled'
}).length
const progress = entryProgress({ rows, filter: 'all', unit: 's', needsRank })
assert.equal(progress.filled, expected, `needsRank=${needsRank}`)
}
// DNS 这类现场结果没有数值也是「已录」,手动名次项目只填名次也算「已录」。
const progress = entryProgress({ rows: [row(1, '', { status: 'dns' })], filter: 'all', unit: 's', needsRank: false })
assert.equal(progress.filled, 1)
const rankOnly = entryProgress({ rows: [row(1, '', { rank: 1 })], filter: 'all', unit: 's', needsRank: true })
assert.equal(rankOnly.filled, 1)
})
test('有错的行不计入已录,并在文案里报数', () => {
const rows = [
row(2, '70.1'),
row(2, '70.2'),
row(2, '70.3'),
row(2, '70.4'),
row(2, '70.5'),
row(2, '1:70'),
row(2, '约1分06秒'),
row(2, '快点跑'),
]
const progress = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
assert.equal(progress.total, 8)
assert.equal(progress.filled, 5)
assert.equal(progress.error, 3)
assert.equal(progress.missing, 0)
assert.equal(progress.done, false, '有错时永远不是完成态')
assert.equal(progress.text, '第 2 批 已录 5/8 · 3 条有错')
assert.equal(progress.percent, 63)
})
test('全部录完是完成态:文案改成已录完、进度条满格、变绿由 done 驱动', () => {
const rows = [row(3, '70.1'), row(3, '', { status: 'dq' }), row(3, '1:06')]
const progress = entryProgress({ rows, filter: 3, unit: 's', needsRank: false })
assert.equal(progress.done, true)
assert.equal(progress.filled, 3)
assert.ok(progress.text.includes('已录完'), progress.text)
assert.equal(progress.percent, 100)
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
assert.equal(all.text, '全部 已录完 3/3')
})
test('筛选范围内没有行时不算完成,也不会出现 NaN 百分比', () => {
const progress = entryProgress({ rows: [row(1, '66')], filter: 'none', unit: 's', needsRank: false })
assert.equal(progress.total, 0)
assert.equal(progress.percent, 0)
assert.equal(progress.done, false)
assert.equal(progress.text, '未分批 已录 0/0')
})
test('作用域称谓:全部 / 第 N 批 / 未分批', () => {
assert.equal(progressScopeLabel('all'), '全部')
assert.equal(progressScopeLabel(2), '第 2 批')
assert.equal(progressScopeLabel('none'), '未分批')
})
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { athleteCellName, inlineBibLabel } from '../pages/entryRowDisplay'
// 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。
test('有号码布:原样展示(去掉首尾空白)', () => {
assert.equal(inlineBibLabel('1042'), '1042')
assert.equal(inlineBibLabel(' 1042 '), '1042')
assert.equal(inlineBibLabel('A-07'), 'A-07')
})
// 号码布为空时模板必须不渲染那个 span,否则这一列会被一段空白推歪。
test('没有号码布(null / 空串 / 只有空白):返回空串,不占位', () => {
assert.equal(inlineBibLabel(null), '')
assert.equal(inlineBibLabel(undefined), '')
assert.equal(inlineBibLabel(''), '')
assert.equal(inlineBibLabel(' '), '')
})
test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时占位', () => {
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
assert.equal(athleteCellName({ athleteName: '', memberNames: [] }), '—')
})
// 模板契约:号码布必须紧跟在姓名后面、且带 v-if;团体行(athleteName 为空)也要显示。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => {
const cell = ENTRY_SOURCE.match(/<td>\{\{ athleteCellName\(row\) \}\}([\s\S]*?)<\/td>/)?.[1]
assert.ok(cell !== undefined, '找不到姓名单元格')
assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
})
test('原来的「号码」列保留,没有被这次改动删掉', () => {
assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/)
})
@@ -0,0 +1,61 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from '../components/baseData/eventRowActions'
import type { BaseDataTabKey } from '../components/baseData/tabs'
// 双击小项条目 → 跳到该小项的组别列表。
// 浏览器在 dblclick 前必然先派发两次 click,这里模拟「先点过别的行、再双击目标行」,
// 确认最终选中项是被双击的那一项,而不是上一次单击留下的那一项。
test('双击:最终选中被双击的那一项,并跳到 categories', () => {
const calls: string[] = []
let selected: number | null = null
const deps = {
selectEvent: (id: number) => {
selected = id
calls.push(`select:${id}`)
},
goTab: (tab: BaseDataTabKey) => {
calls.push(`goTab:${tab}`)
},
}
deps.selectEvent(1)
openEventCategories(7, deps)
assert.equal(selected, 7)
assert.deepEqual(calls.slice(-2), ['select:7', 'goTab:categories'])
})
// 必须先选中再跳分区:反过来会在分区切换(可能重建列表)之后再改选中,跳过去的小项就不对。
test('双击:先选中、后跳转,顺序不能反', () => {
const calls: string[] = []
openEventCategories(3, {
selectEvent: (id) => calls.push(`select:${id}`),
goTab: (tab) => calls.push(`goTab:${tab}`),
})
assert.deepEqual(calls, ['select:3', 'goTab:categories'])
})
// 模板契约:条目要绑 @dblclick,且 title 写明可发现性提示;
// 行内的小按钮(编辑 / 删除)用 @dblclick.stop 挡住,双击按钮不会误跳转。
const SECTION_SOURCE = readFileSync(
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
'utf8',
)
test('小项条目绑定双击跳转与 title 提示', () => {
assert.match(SECTION_SOURCE, /@dblclick="openCategories\(event\.id\)"/)
assert.match(SECTION_SOURCE, /:title="EVENT_ROW_DBLCLICK_TITLE"/)
assert.equal(EVENT_ROW_DBLCLICK_TITLE, '双击查看该小项的组别')
})
test('条目里的小按钮不会被双击误触发跳转', () => {
assert.match(SECTION_SOURCE, /<div class="bd-actions" @dblclick\.stop>/)
})
// 单击仍然只做选中:不能为了双击而给单击加延时(会让人以为界面卡了)。
test('单击没有被加延时,仍是直接选中', () => {
assert.match(SECTION_SOURCE, /@click="data\.selectEvent\(event\.id\)"/)
assert.ok(!/setTimeout/.test(SECTION_SOURCE), '双击实现不应引入延时')
})
+113 -50
View File
@@ -5,6 +5,7 @@ import { createApiClient } from '@/shared'
import {
REQUIRED_COLUMNS,
conflictDiffs,
decisionKeyOf,
decisionsPayload,
pruneDecisions,
recordDecisions,
@@ -119,6 +120,29 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
})
})
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
const shared = '110101199001010001'
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
const first = conflict({ row: 2, idCard: shared, bib: '101' })
const second = conflict({ row: 3, idCard: shared, bib: '102' })
assert.equal(decisionKeyOf(first), '101')
assert.equal(decisionKeyOf(second), '102')
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
assert.deepEqual(decisions, { 101: 'keep-new' })
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
{ idCard: shared, bib: '101', action: 'keep-new' },
])
})
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
const idCard = '110101199001010002'
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
})
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
@@ -137,57 +161,87 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
})
test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', async () => {
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
const mock = new MockBaseData()
const missing = await mock.importEntries(csv([row('无证人员', '')]))
assert.equal(missing.skipped, 1)
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
const before = mock.listAthletes().length
const duplicated = await mock.importEntries(
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
)
assert.equal(duplicated.created, 1, '第一行仍导入')
assert.equal(duplicated.skipped, 1)
assert.match(duplicated.errors[0] ?? '', /重复出现/)
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
assert.equal(duplicated.created, 2, '两行都落库')
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
})
test('导入:同一证件号但单位不同记为冲突且不落库,决策后按所选一侧处理', async () => {
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
const idCard = '110101199001010004'
const mock = new MockBaseData()
await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
const first = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
assert.equal(first.created, 1)
assert.deepEqual(first.conflicts, [])
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
const conflicted = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
assert.equal(again.created, 1)
assert.deepEqual(again.conflicts, [])
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
assert.equal(taken.created, 0)
assert.equal(taken.skipped, 1)
assert.deepEqual(taken.conflicts, [])
assert.equal(
taken.errors[0],
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
'与服务端 idCardTakenError 同一句中文文案',
)
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
})
test('导入:号码布命中同一人但单位不同记为冲突,决策后按所选一侧处理', async () => {
const mock = new MockBaseData()
// 种子数据里 JG 的「张伟」号码布为 101;这一行按号码布命中他,但单位写成 F2。
const conflicted = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
assert.equal(conflicted.created, 0, '冲突行不建报名')
assert.equal(conflicted.conflicts.length, 1)
assert.deepEqual(conflicted.conflicts[0], {
row: 2,
idCard,
bib: '',
athleteId,
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
changedFields: ['teamCode'],
})
const [conflict] = conflicted.conflicts
assert.equal(conflict?.idCard, '')
assert.equal(conflict?.bib, '101')
assert.deepEqual(conflict?.changedFields, ['teamCode'])
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
const again = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
const again = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
assert.equal(again.conflicts.length, 1)
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, bib: '', action: 'keep-existing' },
// 证件号为空时决策键就是号码布(与服务端 decisionKey() 同序)。
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
{ idCard: '', bib: '101', action: 'keep-existing' },
])
assert.equal(keptExisting.conflicts.length, 0)
assert.equal(keptExisting.created, 1)
const afterExisting = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
assert.equal(
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
1,
'保留已有数据时人员单位不变(JG 的 id 为 1)',
)
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, bib: '', action: 'keep-new' },
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
{ idCard: '', bib: '101', action: 'keep-new' },
])
assert.equal(keptNew.conflicts.length, 0)
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
assert.equal(afterNew?.teamId, 3, '使用新数据时人员单位更新为 F2 的 id')
assert.equal(
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
3,
'使用新数据时人员单位更新为 F2 的 id',
)
})
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
@@ -256,34 +310,43 @@ test('导入:姓名 + 单位命中且证件号为空时回填证件号并保
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
})
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
const idCard = '110101199001010007'
test('导入:号码布不同 → 按「同名的另一个人」新建并报告', async () => {
const mock = new MockBaseData()
// JG 的「张伟」证件号为空、号码布为 101,先用姓名 + 单位回填证件号。
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
const athleteId = mock.listAthletes().find((item) => item.idCard === idCard)?.id
assert.ok(athleteId !== undefined)
// 种子数据里 JG 的「张伟」号码布为 101;这一行给了别的号码布 → 同名的另一个人。
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([row('张伟', '110101199001010007', 'JG', '男', '999')]))
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
assert.equal(conflicted.created, 0, '冲突行不建报名')
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
assert.deepEqual(result.sameNameNewPersons, [
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
// 库里那个人一个字段都没被改:同名的另一个人是新档案。
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.idCard, null)
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
})
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
const mock = new MockBaseData()
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
// 先造出第二个「张伟」(都在 JG):号码布不同 → 同名的另一个人。
await mock.importEntries(csv([row('张伟', '', 'JG', '男', '999')]))
// 第三行只给了号码布 777,两个候选都对不上号码布:认不出是哪一个人,不落库。
const conflicted = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '777')]))
assert.equal(conflicted.created, 0)
assert.equal(conflicted.conflicts.length, 1)
const [conflict] = conflicted.conflicts
assert.equal(conflict?.idCard, idCard)
assert.equal(conflict?.existing.bib, '101')
assert.equal(conflict?.incoming.bib, '999')
assert.deepEqual(conflict?.changedFields, ['bib'])
assert.equal(conflicted.conflicts[0]?.athleteId, firstId, '以第一条候选为代表')
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
{ idCard, bib: '999', action: 'keep-existing' },
const decided = await mock.resolveImportConflicts(csv([row('张伟', '', 'JG', '男', '777')]), [
{ idCard: '', bib: '777', action: 'keep-new' },
])
assert.equal(keptExisting.created, 1)
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '101')
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
{ idCard, bib: '999', action: 'keep-new' },
])
assert.equal(keptNew.created, 1)
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '999')
assert.equal(decided.created, 1)
assert.equal(decided.conflicts.length, 0)
assert.equal(mock.listAthletes().find((athlete) => athlete.id === firstId)?.bib, '777')
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.bib, '999', '另一个候选不被动')
})
test('导入:同一文件里号码布重复按行报错', async () => {
@@ -0,0 +1,169 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { MockBaseData } from '../components/baseData/mockBaseData'
/**
* 示例数据模式(服务端连不上)下,组别就地更新与运动员批量的降级行为。
*
* 这一层不接上的话,页面上这两个按钮在示例数据里会直接抛「无法连接服务端」——
* 而示例数据存在的意义就是让每个分区都能走通完整交互。
*/
test('示例数据的组别就地更新保留组别 id,不重建', () => {
const mock = new MockBaseData()
const event = mock.listEvents()[0]
assert.ok(event !== undefined)
const before = mock.getEvent(event.id).categories ?? []
const target = before[0]
assert.ok(target !== undefined)
const after = mock.updateCategory(event.id, target.id, {
name: '男子甲组',
multiplier: 2,
gender: '男',
ageMin: 18,
ageMax: 35,
sortOrder: target.sortOrder,
})
const updated = after.find((category) => category.id === target.id)
assert.ok(updated !== undefined)
assert.equal(updated.name, '男子甲组')
assert.equal(updated.multiplier, 2)
// 其余组别一条不少:就地更新不是整表替换。
assert.equal(after.length, before.length)
})
test('示例数据的组别就地更新:重名与不存在给与服务端一致的文案', () => {
const mock = new MockBaseData()
// 用新建的小项拿两个组别:种子事件各自只有一个组别,没法构造重名。
const seed = mock.listEvents()[0]
assert.ok(seed !== undefined)
const fresh = mock.createEvent({
sportId: seed.sportId,
code: 'DUP_CHECK',
name: '重名检查',
eventType: seed.eventType,
resultMode: seed.resultMode,
resultType: seed.resultType,
compare: seed.compare,
recordEligible: seed.recordEligible,
scored: seed.scored,
multiplier: seed.multiplier,
status: seed.status,
scheduledAt: null,
venue: null,
sortOrder: 99,
remark: null,
avgMode: seed.avgMode,
})
const categories = mock.saveCategories(fresh.id, [
{ name: '男子', multiplier: 1, gender: '男', ageMin: null, ageMax: null, sortOrder: 1 },
{ name: '女子', multiplier: 1, gender: '女', ageMin: null, ageMax: null, sortOrder: 2 },
])
const [first, second] = categories
assert.ok(first !== undefined && second !== undefined)
assert.throws(
() =>
mock.updateCategory(fresh.id, second.id, {
name: first.name,
multiplier: 1,
gender: null,
ageMin: null,
ageMax: null,
sortOrder: second.sortOrder,
}),
/组别名称重复/,
)
assert.throws(
() =>
mock.updateCategory(fresh.id, 999999, {
name: '男子',
multiplier: 1,
gender: null,
ageMin: null,
ageMax: null,
sortOrder: 1,
}),
/组别不存在/,
)
})
test('示例数据有报名数据时仍拒绝增删组别,但文案说清是「不能增删」', () => {
const mock = new MockBaseData()
const catsOf = (eventId: number) => mock.getEvent(eventId).categories ?? []
// 找一个小项:它在示例数据里带报名条数(增删会被拒),改名仍然放行。
const blocked = mock.listEvents().filter((event) => {
const categories = catsOf(event.id)
if (categories.length === 0) return false
try {
mock.saveCategories(event.id, categories.map((category) => ({ ...category })))
return false
} catch (error) {
assert.match(String(error), /不能新增或删除组别/)
return true
}
})
assert.ok(blocked.length > 0, '示例数据里应有带报名条数的小项')
const event = blocked[0]
assert.ok(event !== undefined)
const first = catsOf(event.id)[0]
assert.ok(first !== undefined)
const after = mock.updateCategory(event.id, first.id, {
name: '改个名字',
multiplier: 1,
gender: first.gender,
ageMin: null,
ageMax: null,
sortOrder: first.sortOrder,
})
assert.equal(after.find((category) => category.id === first.id)?.name, '改个名字')
})
test('示例数据批量停用:逐条回报,缺失 id 不整批失败,数据真被停用', () => {
const mock = new MockBaseData()
const athletes = mock.listAthletes()
const [a, b] = athletes
assert.ok(a !== undefined && b !== undefined)
const result = mock.batchAthletes('disable', [a.id, b.id, 999999])
assert.equal(result.action, 'disable')
assert.equal(result.requested, 3)
assert.equal(result.succeeded, 2)
assert.equal(result.failed, 1)
assert.deepEqual(result.items[2], { id: 999999, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
assert.equal(result.purge, undefined)
const after = mock.listAthletes()
assert.equal(after.find((athlete) => athlete.id === a.id)?.active, false)
assert.equal(after.length, athletes.length)
})
test('示例数据批量彻底删除:dryRun 只回报不改数据,真删才把人移出名单', () => {
const mock = new MockBaseData()
const athletes = mock.listAthletes()
const target = athletes[0]
assert.ok(target !== undefined)
const preview = mock.batchAthletes('purge', [target.id], true)
assert.equal(preview.dryRun, true)
assert.equal(preview.succeeded, 1)
assert.deepEqual(preview.purge, {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
})
assert.equal(mock.listAthletes().length, athletes.length)
const real = mock.batchAthletes('purge', [target.id])
assert.equal(real.succeeded, 1)
assert.equal(mock.listAthletes().length, athletes.length - 1)
assert.equal(mock.listAthletes().some((athlete) => athlete.id === target.id), false)
})
@@ -0,0 +1,167 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPinia, setActivePinia } from 'pinia'
/**
* 登录有效期(系统级设置)的契约:
* 前端校验必须与服务端一致(1~720 整数小时),到期时间按本机时区渲染,
* 会话心跳要真的打到 GET /api/auth/me(服务端的滑动续期与 Cookie 同步都靠它)。
*
* adminApi 的 http 实例在模块初始化时就把 globalThis.fetch 绑死了,所以这里在文件顶部
* 装一个按 URL 分发的假 fetch,各用例只改 reply;用例内部再动态 import 被测模块。
*/
interface Captured {
url: string
method: string
body: Record<string, unknown> | null
}
const captured: Captured = { url: '', method: '', body: null }
let reply: (url: string, method: string) => unknown = () => ({ ok: true, data: null })
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = String(input)
const method = init?.method ?? 'GET'
captured.url = url
captured.method = method
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
const payload = reply(url, method)
if (payload instanceof Response) return payload
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
/** systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。 */
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
const ME_DATA = {
id: 1,
username: 'admin',
displayName: '裁判长',
role: 'admin',
mustChangePassword: false,
lastLoginAt: null,
sessionExpiresAt: '2026-09-12T00:31:00Z',
}
const BOUNDS = { minHours: 1, maxHours: 720 }
test('validateSessionHours 只接受 1~720 的整数小时', async () => {
const { validateSessionHours } = await import('../components/system/sessionSettings')
for (const [raw, want] of [
['1', 1],
['24', 24],
['720', 720],
[' 48 ', 48],
[72, 72],
] as const) {
const got = validateSessionHours(raw, BOUNDS)
assert.equal(got.ok, true, `应接受 ${String(raw)}`)
assert.equal(got.ok ? got.hours : 0, want)
}
for (const raw of ['', ' ', '0', '721', '-1', '24.5', 'abc']) {
const got = validateSessionHours(raw, BOUNDS)
assert.equal(got.ok, false, `应拒绝 ${JSON.stringify(raw)}`)
if (!got.ok) assert.match(got.message, /登录有效期/)
}
})
test('describeSessionLifetime 用小时与天数说明「登录后能待多久」', async () => {
const { describeSessionLifetime } = await import('../components/system/sessionSettings')
const day = describeSessionLifetime(24)
assert.match(day, /24 小时/)
assert.match(day, /1 天/)
assert.match(day, /顺延/)
assert.match(describeSessionLifetime(36), /1\.5 天/)
assert.match(describeSessionLifetime(0), /未设置/)
})
test('readSessionExpiresAt 只认带时区的合法时间串', async () => {
const { readSessionExpiresAt } = await import('../components/system/sessionSettings')
assert.equal(readSessionExpiresAt(ME_DATA), '2026-09-12T00:31:00Z')
// 老服务端不下发这个字段、访客、以及字段被写坏的情况,都要当成「没有」。
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: undefined }), null)
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: '不是时间' }), null)
assert.equal(readSessionExpiresAt(null), null)
})
test('formatSessionExpiry 渲染成本机时间的「当前会话将于 … 过期」', async () => {
const { formatSessionExpiry } = await import('../components/system/sessionSettings')
assert.match(formatSessionExpiry('2026-09-12T00:31:00Z'), /^当前会话将于 \d{4}-\d{2}-\d{2} \d{2}:\d{2} 过期$/)
assert.equal(formatSessionExpiry(null), '当前会话的到期时间未知')
assert.equal(formatSessionExpiry(''), '当前会话的到期时间未知')
assert.equal(formatSessionExpiry('2026-13-45'), '当前会话的到期时间未知')
})
test('会话心跳重新拉 /api/auth/me 并刷新到期时间', async () => {
reply = () => ({ ok: true, data: ME_DATA })
const { useAuthStore } = await import('../stores/auth')
setActivePinia(createPinia())
const auth = useAuthStore()
await auth.load()
assert.equal(captured.url, '/api/auth/me')
assert.equal(auth.sessionExpiresAt, '2026-09-12T00:31:00Z')
// 服务端续期后下发新的到期时间,心跳要跟上(用户菜单与设置页显示的就是它)。
captured.url = ''
reply = () => ({ ok: true, data: { ...ME_DATA, sessionExpiresAt: '2026-09-13T00:31:00Z' } })
await auth.refreshSession()
assert.equal(captured.url, '/api/auth/me')
assert.equal(auth.sessionExpiresAt, '2026-09-13T00:31:00Z')
})
test('访客不发起心跳', async () => {
reply = () => ({ ok: true, data: { ...ME_DATA, role: 'guest', sessionExpiresAt: null } })
const { useAuthStore } = await import('../stores/auth')
setActivePinia(createPinia())
const auth = useAuthStore()
await auth.load()
assert.equal(auth.isGuest, true)
assert.equal(auth.sessionExpiresAt, null)
captured.url = ''
await auth.refreshSession()
assert.equal(captured.url, '', '访客不该发起心跳')
})
test('sessionSettingsApi 走 /api/system/session 读写登录有效期', async () => {
reply = () => ({
ok: true,
data: { sessionHours: 72, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: null },
})
const { sessionSettingsApi } = await import('../components/system/systemApi')
const loaded = await sessionSettingsApi.load()
assert.equal(captured.url, '/api/system/session')
assert.equal(captured.method, 'GET')
assert.equal(loaded.sessionHours, 72)
assert.equal(loaded.minHours, 1)
assert.equal(loaded.maxHours, 720)
reply = () => ({
ok: true,
data: { sessionHours: 1, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: '2026-09-11T12:00:00Z' },
})
const saved = await sessionSettingsApi.save(1)
assert.equal(captured.url, '/api/system/session')
assert.equal(captured.method, 'PUT')
assert.equal(captured.body?.sessionHours, 1)
// 写请求带幂等用的 requestId(服务端据此防重复提交)。
assert.equal(typeof captured.body?.requestId, 'string')
assert.equal(saved.sessionHours, 1)
assert.equal(saved.expiresAt, '2026-09-11T12:00:00Z')
})
+171
View File
@@ -0,0 +1,171 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPinia, setActivePinia } from 'pinia'
import type { EventSettings } from '@/shared'
/**
* 分批结算的前端契约:设置读取的默认值、未结算清单的路径与字段名、立即结算的请求体。
*
* 路径或字段名写错时现场看到的是「提示不出现 / 结算按错组生效」这类静默故障,
* 所以这里用 stub fetch 钉住真正发出去的请求。
*
* ⚠️ 共享 ApiClient 在模块初始化时就把 globalThis.fetch 绑死了,所以这里:
* 1. 不在文件顶部静态导入任何会构造 client 的模块(本文件的 import 都是类型或无副作用模块);
* 2. 桩在模块体里先装好,再用动态 import 拿被测模块;
* 3. 桩的响应由每个用例改的 responder 决定,多个用例共用一个桩。
*/
interface Captured {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown> | null
}
const captured: Captured = { url: '', method: '', headers: {}, body: null }
let responder: (request: Captured) => Response = () =>
new Response(JSON.stringify({ ok: true, data: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
captured.url = String(input)
captured.method = init?.method ?? 'GET'
captured.headers = (init?.headers ?? {}) as Record<string, string>
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
return responder(captured)
}) as typeof fetch
// systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const SETTINGS = {
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: null,
requireApproval: false,
}
test('liveOnPublishOf:缺字段 / 旧服务端一律按「实时结算」处理', async () => {
const { liveOnPublishOf } = await import('../components/system/systemApi')
assert.equal(liveOnPublishOf(null), true)
assert.equal(liveOnPublishOf(undefined), true)
assert.equal(liveOnPublishOf(SETTINGS as unknown as EventSettings), true)
assert.equal(
liveOnPublishOf({
...SETTINGS,
standings: { ...SETTINGS.standings, liveOnPublish: true },
} as unknown as EventSettings),
true,
)
assert.equal(
liveOnPublishOf({
...SETTINGS,
standings: { ...SETTINGS.standings, liveOnPublish: false },
} as unknown as EventSettings),
false,
)
})
test('settlementApi.status 读 GET /api/results/settlement 并归一未结算清单', async () => {
const { settlementApi } = await import('../components/system/systemApi')
responder = () =>
jsonResponse({
ok: true,
data: {
liveOnPublish: false,
groups: [
{ eventId: 3, categoryId: 7, categoryName: '男子甲组', required: 8, published: 4 },
{ eventId: 3, categoryId: 8, categoryName: '女子甲组', required: 6, published: 6 },
],
},
})
const status = await settlementApi.status(3, 7)
assert.equal(captured.url, '/api/results/settlement?eventId=3&categoryId=7')
assert.equal(captured.method, 'GET')
assert.equal(status.liveOnPublish, false)
assert.deepEqual(status.groups[0], {
eventId: 3,
categoryId: 7,
categoryName: '男子甲组',
required: 8,
published: 4,
})
assert.equal(status.groups.length, 2)
})
test('settlementApi.status 不带组别时只带 eventId', async () => {
const { settlementApi } = await import('../components/system/systemApi')
responder = () => jsonResponse({ ok: true, data: { liveOnPublish: true, groups: [] } })
const status = await settlementApi.status(9)
assert.equal(captured.url, '/api/results/settlement?eventId=9')
assert.equal(status.liveOnPublish, true)
assert.deepEqual(status.groups, [])
})
test('settlementApi.settle 发 POST /api/results/settle 并带 requestId', async () => {
const { settlementApi } = await import('../components/system/systemApi')
responder = () => jsonResponse({ ok: true, data: [] })
await settlementApi.settle(3, 7)
assert.equal(captured.url, '/api/results/settle')
assert.equal(captured.method, 'POST')
assert.equal(captured.body?.eventId, 3)
assert.equal(captured.body?.categoryId, 7)
// 服务端按 requestId 幂等:不带就会被当成重复提交。
assert.equal(typeof captured.headers['X-Request-Id'], 'string')
})
test('保存结算方式 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
responder = () =>
jsonResponse({
ok: true,
data: { ...SETTINGS, standings: { ...SETTINGS.standings, liveOnPublish: false } },
})
const { liveOnPublishOf } = await import('../components/system/systemApi')
const { useEventStore } = await import('../stores/event')
const { useBaseData } = await import('../components/baseData/useBaseData')
setActivePinia(createPinia())
const event = useEventStore()
event.settings = { ...SETTINGS } as unknown as EventSettings
const data = useBaseData()
const saved = await data.saveLiveOnPublish(false)
assert.equal(captured.url, '/api/settings')
assert.equal(captured.method, 'PUT')
const standings = captured.body?.standings as Record<string, unknown>
assert.equal(standings.liveOnPublish, false)
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
assert.equal(standings.individualToTeamWeight, 1)
assert.equal(standings.rankingMode, 'points')
assert.equal(standings.showIndividualBoard, true)
assert.equal(liveOnPublishOf(saved), false)
// 录入页提示直接读 store 里的设置,不回写就等于「保存成功但提示没变」。
assert.equal(liveOnPublishOf(event.settings), false)
assert.equal(liveOnPublishOf(data.settings.value), false)
})