用户给出的 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。
118 lines
4.8 KiB
TypeScript
118 lines
4.8 KiB
TypeScript
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 个小项/)
|
|
})
|