feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发) - 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、 手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。 - 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错 (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。 ## 实时赛况(新大屏页 + 定向接口) - 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩 (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。 设置键 display.live_events / display.live_source。 - 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面 (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。 - 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only), 故对非管理员整组置灰 + 提示条(不新增角色)。 ## 显式批次层(组别之下) - 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果 完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 + 一键均分(按 N 人切片,已有批次号接着往后排)。 ## 成绩输入框实时悬浮提示 - 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻; 合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文; 与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。 ## 窄屏 / 手机端布局 - 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针, 四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框 塌成 22px 这类真问题。 ## 版本号单一真相 - 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json 的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。 测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
This commit is contained in:
1 parent
e5e0944d26
commit
08a5740fb2
64 files changed
+4968
-270
No files matched your search
@@ -133,3 +133,42 @@ test('后端连不上时实例列表给空数组,不抛错', async () => {
|
||||
|
||||
assert.deepEqual(await adminApi.displayInstances(), [])
|
||||
})
|
||||
|
||||
// 「实时赛况」当前项目由服务端判定,前端只消费结论:source / eventIds / events 原样透传。
|
||||
test('实时赛况接口透传判定结果与入选原因', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
source: 'auto',
|
||||
eventIds: [7],
|
||||
events: [
|
||||
{
|
||||
event: { id: 7, name: '100米跑' },
|
||||
reason: 'recent',
|
||||
groups: [{ eventId: 7, categoryId: 1, categoryName: '男子', rows: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const live = await adminApi.displayLive()
|
||||
|
||||
assert.equal(lastRequest.url, '/api/display/live')
|
||||
assert.equal(lastRequest.method, 'GET')
|
||||
assert.equal(live.source, 'auto')
|
||||
assert.deepEqual(live.eventIds, [7])
|
||||
assert.equal(live.events[0]?.event.name, '100米跑')
|
||||
assert.equal(live.events[0]?.reason, 'recent')
|
||||
assert.equal(live.events[0]?.groups.length, 1)
|
||||
})
|
||||
|
||||
// 判定依赖排期与最近发布,示例数据里没有这套时序,连不上时给空结论而不是抛错。
|
||||
test('后端连不上时实时赛况给空结论', async () => {
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
assert.deepEqual(await adminApi.displayLive(), { source: 'auto', eventIds: [], events: [] })
|
||||
})
|
||||
@@ -1,13 +1,20 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
|
||||
import { createLeaveGuard, judgeEntryRow, resultCellIssue } from '../pages/entryGuards'
|
||||
|
||||
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
|
||||
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
|
||||
test('空格子:文案说明成绩为空并带出「(空)」', () => {
|
||||
// 现场分批比赛后,空格子只是「还没录」,归为提示、不阻塞发布。
|
||||
test('空格子:文案说明还没录并带出「(空)」,且不阻塞发布', () => {
|
||||
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
|
||||
assert.equal(issue?.kind, 'empty')
|
||||
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
|
||||
assert.equal(issue?.blocking, false)
|
||||
assert.equal(issue?.message, '第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行')
|
||||
})
|
||||
|
||||
test('写错的成绩阻塞发布:秒位越界与写法不认识都是 blocking', () => {
|
||||
assert.equal(resultCellIssue('第 3 行 张三', '1:70', 's')?.blocking, true)
|
||||
assert.equal(resultCellIssue('第 3 行 张三', '约1分06秒', 's')?.blocking, true)
|
||||
})
|
||||
|
||||
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
|
||||
@@ -53,6 +60,61 @@ test('距离 / 计数项目的文案不套用时间格式提示', () => {
|
||||
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
|
||||
})
|
||||
|
||||
// 三类行徽标:已填 / 未录 / 有错。空成绩不再算错误,但要有提示。
|
||||
test('行状态三类互斥:空成绩=未录(提示),写错=有错(阻塞)', () => {
|
||||
const missing = judgeEntryRow({
|
||||
label: '第 22 行 赵六', resultText: ' ', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(missing.state, 'missing')
|
||||
assert.equal(missing.blocker, null)
|
||||
assert.ok(missing.warning?.includes('还没录成绩'), missing.warning ?? '')
|
||||
|
||||
const error = judgeEntryRow({
|
||||
label: '第 3 行 张三', resultText: '1:70', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(error.state, 'error')
|
||||
assert.ok(error.blocker?.includes('秒位不能 ≥ 60'), error.blocker ?? '')
|
||||
|
||||
const filled = judgeEntryRow({
|
||||
label: '第 1 行 李四', resultText: '66.33', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(filled.state, 'filled')
|
||||
assert.equal(filled.blocker, null)
|
||||
})
|
||||
|
||||
test('非 ok 状态(DNS/DQ/DNF/退赛)没有数值也算已填', () => {
|
||||
for (const status of ['dns', 'dq', 'dnf', 'withdrawn']) {
|
||||
const row = judgeEntryRow({
|
||||
label: '第 2 行 王五', resultText: '', unit: 's', status, rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(row.state, 'filled', status)
|
||||
assert.equal(row.warning, null)
|
||||
}
|
||||
})
|
||||
|
||||
test('手动名次项目:只填名次即可发布,缺名次才阻塞', () => {
|
||||
const rankOnly = judgeEntryRow({
|
||||
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: 1, needsRank: true,
|
||||
})
|
||||
assert.equal(rankOnly.state, 'filled')
|
||||
assert.equal(rankOnly.blocker, null)
|
||||
|
||||
const noRank = judgeEntryRow({
|
||||
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: null, needsRank: true,
|
||||
})
|
||||
assert.equal(noRank.state, 'error')
|
||||
assert.ok(noRank.blocker?.includes('缺少名次'), noRank.blocker ?? '')
|
||||
})
|
||||
|
||||
test('成绩 ≤ 0 只提示不挡:状态仍是已填', () => {
|
||||
const row = judgeEntryRow({
|
||||
label: '第 5 行 田径', resultText: '0', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(row.state, 'filled')
|
||||
assert.equal(row.blocker, null)
|
||||
assert.ok(row.warning?.includes('≤ 0'), row.warning ?? '')
|
||||
})
|
||||
|
||||
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
|
||||
test('未保存时离开被拦下,确认后重放放行', () => {
|
||||
let dirty = true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createApiClient } from '@/shared'
|
||||
|
||||
/** 真打一遍客户端的请求构造:路径、方法、请求体形状必须与服务端契约一致。 */
|
||||
test('setHeat 走 PUT /entries/heat 并带上 entryIds + heat', async () => {
|
||||
const captured: { url?: string; method?: string; body?: unknown } = {}
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
requestId: () => 'req-fixed',
|
||||
fetchImpl: async (input, init) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method
|
||||
captured.body = init?.body
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, data: { updated: 3, heat: 2, entryIds: [4, 5, 6] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.entries.setHeat({ entryIds: [4, 5, 6], heat: 2 })
|
||||
|
||||
assert.equal(captured.url, '/api/entries/heat')
|
||||
assert.equal(captured.method, 'PUT')
|
||||
// 写操作由客户端补 requestId(幂等重放),其余字段原样。
|
||||
assert.equal(captured.body, JSON.stringify({ entryIds: [4, 5, 6], heat: 2, requestId: 'req-fixed' }))
|
||||
assert.deepEqual(result, { updated: 3, heat: 2, entryIds: [4, 5, 6] })
|
||||
})
|
||||
|
||||
test('setHeat 传 null 表示清除批次', async () => {
|
||||
const captured: { body?: unknown } = {}
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
requestId: () => 'req-fixed',
|
||||
fetchImpl: async (_input, init) => {
|
||||
captured.body = init?.body
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, data: { updated: 2, heat: null, entryIds: [1, 2] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.entries.setHeat({ entryIds: [1, 2], heat: null })
|
||||
|
||||
assert.equal(captured.body, JSON.stringify({ entryIds: [1, 2], heat: null, requestId: 'req-fixed' }))
|
||||
assert.equal(result.heat, null)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
applyHeatToRows,
|
||||
filterRowsByHeat,
|
||||
heatLabel,
|
||||
heatOptions,
|
||||
maxHeat,
|
||||
planHeatSplit,
|
||||
type HeatEntryRow,
|
||||
} from '../pages/entryHeat'
|
||||
|
||||
type Row = HeatEntryRow & { name: string }
|
||||
|
||||
function rows(spec: Array<[string, number | null]>): Row[] {
|
||||
return spec.map(([name, heat], index) => ({ entryId: index + 1, name, heat }))
|
||||
}
|
||||
|
||||
test('heatLabel:未分批与第 N 批', () => {
|
||||
assert.equal(heatLabel(null), '未分批')
|
||||
assert.equal(heatLabel(1), '第 1 批')
|
||||
assert.equal(heatLabel(12), '第 12 批')
|
||||
})
|
||||
|
||||
test('heatOptions:全部 → 已有批次去重升序 → 未分批', () => {
|
||||
const options = heatOptions(rows([['甲', 2], ['乙', null], ['丙', 1], ['丁', 2]]))
|
||||
assert.deepEqual(options, [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 1, label: '第 1 批' },
|
||||
{ value: 2, label: '第 2 批' },
|
||||
{ value: 'none', label: '未分批' },
|
||||
])
|
||||
})
|
||||
|
||||
test('heatOptions:没有未分批的行时不给「未分批」选项,避免选中得到空表', () => {
|
||||
assert.deepEqual(heatOptions(rows([['甲', 1], ['乙', 3]])), [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 1, label: '第 1 批' },
|
||||
{ value: 3, label: '第 3 批' },
|
||||
])
|
||||
})
|
||||
|
||||
test('heatOptions:一条报名都没有时只有「全部」', () => {
|
||||
assert.deepEqual(heatOptions([]), [{ value: 'all', label: '全部' }])
|
||||
})
|
||||
|
||||
test('filterRowsByHeat:全部 / 指定批次 / 未分批,顺序不变', () => {
|
||||
const list = rows([['甲', 1], ['乙', null], ['丙', 2], ['丁', 1]])
|
||||
assert.deepEqual(
|
||||
filterRowsByHeat(list, 'all').map((row) => row.name),
|
||||
['甲', '乙', '丙', '丁'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
filterRowsByHeat(list, 1).map((row) => row.name),
|
||||
['甲', '丁'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
filterRowsByHeat(list, 'none').map((row) => row.name),
|
||||
['乙'],
|
||||
)
|
||||
assert.deepEqual(filterRowsByHeat(list, 9), [])
|
||||
// 返回新数组,不改动入参。
|
||||
assert.equal(list.length, 4)
|
||||
})
|
||||
|
||||
test('maxHeat:取已有最大批次号,没有批次时是 0', () => {
|
||||
assert.equal(maxHeat([]), 0)
|
||||
assert.equal(maxHeat(rows([['甲', null], ['乙', null]])), 0)
|
||||
assert.equal(maxHeat(rows([['甲', 2], ['乙', null], ['丙', 5]])), 5)
|
||||
})
|
||||
|
||||
test('planHeatSplit:未分批的行按每批人数顺序切片,批次号从 1 开始', () => {
|
||||
const list = rows([
|
||||
['甲', null], ['乙', null], ['丙', null], ['丁', null], ['戊', null],
|
||||
['己', null], ['庚', null], ['辛', null], ['壬', null], ['癸', null],
|
||||
])
|
||||
const plan = planHeatSplit(list, 8)
|
||||
assert.ok(plan !== null)
|
||||
assert.equal(plan.rowCount, 10)
|
||||
assert.equal(plan.batchCount, 2)
|
||||
assert.equal(plan.startHeat, 1)
|
||||
assert.equal(plan.perHeat, 8)
|
||||
assert.deepEqual(plan.allocations, [
|
||||
{ heat: 1, entryIds: [1, 2, 3, 4, 5, 6, 7, 8] },
|
||||
{ heat: 2, entryIds: [9, 10] },
|
||||
])
|
||||
})
|
||||
|
||||
test('planHeatSplit:已经分批的行不动,新批次号接着最大号往后排', () => {
|
||||
const list = rows([
|
||||
['甲', 3], ['乙', null], ['丙', null], ['丁', 1], ['戊', null],
|
||||
])
|
||||
const plan = planHeatSplit(list, 2)
|
||||
assert.ok(plan !== null)
|
||||
assert.equal(plan.startHeat, 4, '接着最大批次号 3 往后排,而不是从 1 覆盖')
|
||||
assert.deepEqual(plan.allocations, [
|
||||
{ heat: 4, entryIds: [2, 3] },
|
||||
{ heat: 5, entryIds: [5] },
|
||||
])
|
||||
// 已分批的甲 / 丁不在任何一批里。
|
||||
const touched = plan.allocations.flatMap((item) => item.entryIds)
|
||||
assert.equal(touched.includes(1), false)
|
||||
assert.equal(touched.includes(4), false)
|
||||
})
|
||||
|
||||
test('planHeatSplit:每批人数大于剩余人数时只出一批', () => {
|
||||
const plan = planHeatSplit(rows([['甲', null], ['乙', null], ['丙', null]]), 8)
|
||||
assert.deepEqual(plan?.allocations, [{ heat: 1, entryIds: [1, 2, 3] }])
|
||||
})
|
||||
|
||||
test('planHeatSplit:没有未分批的行、或每批人数非法时返回 null', () => {
|
||||
assert.equal(planHeatSplit(rows([['甲', 1]]), 8), null, '全都分过批就不该有编排')
|
||||
assert.equal(planHeatSplit(rows([['甲', null]]), 0), null)
|
||||
assert.equal(planHeatSplit(rows([['甲', null]]), -3), null)
|
||||
assert.equal(planHeatSplit(rows([['甲', null]]), 2.5), null)
|
||||
})
|
||||
|
||||
test('applyHeatToRows:只改命中的行,其余行(含正在编辑的成绩)原样保留', () => {
|
||||
const list = rows([['甲', null], ['乙', 1], ['丙', null]])
|
||||
const next = applyHeatToRows(list, [1, 3], 7)
|
||||
assert.deepEqual(next.map((row) => row.heat), [7, 1, 7])
|
||||
assert.deepEqual(list.map((row) => row.heat), [null, 1, null], '不能改到入参')
|
||||
|
||||
const cleared = applyHeatToRows(next, [1], null)
|
||||
assert.deepEqual(cleared.map((row) => row.heat), [null, 1, 7])
|
||||
// 未命中的批次筛选值不受影响。
|
||||
assert.deepEqual(applyHeatToRows(list, [], 5).map((row) => row.heat), [null, 1, null])
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resultCellHint, resultCellIssue } from '../pages/entryGuards'
|
||||
|
||||
// 聚焦成绩格的实时提示:三档(✅解析结果 / ⚠️可疑 / 🚫不合法)+ 空格子不弹。
|
||||
// 文案与 resultCellIssue 同源,避免「输入时说没问题、发布时被拦下」这种自相矛盾的提示。
|
||||
|
||||
test('合法时间:把解析结果显示出来(1:06 → = 66 秒)', () => {
|
||||
const hint = resultCellHint('1:06', 's')
|
||||
assert.equal(hint?.level, 'ok')
|
||||
assert.equal(hint?.message, '= 66 秒')
|
||||
})
|
||||
|
||||
test('合法小数与全角写法也按秒数显示', () => {
|
||||
assert.equal(resultCellHint('66.33', 's')?.message, '= 66.33 秒')
|
||||
assert.equal(resultCellHint('1:06.33', 's')?.message, '= 66.33 秒')
|
||||
assert.equal(resultCellHint('1:06', 's')?.message, '= 66 秒')
|
||||
})
|
||||
|
||||
test('非时间单位:显示对应数值与单位', () => {
|
||||
assert.equal(resultCellHint('12.34', 'm')?.message, '= 12.34 米')
|
||||
assert.equal(resultCellHint('12', '次')?.message, '= 12 次')
|
||||
assert.equal(resultCellHint('96.5', 'pts')?.message, '= 96.5 分')
|
||||
})
|
||||
|
||||
test('空成绩不弹提示', () => {
|
||||
assert.equal(resultCellHint('', 's'), null)
|
||||
assert.equal(resultCellHint(' ', 's'), null)
|
||||
})
|
||||
|
||||
test('成绩 ≤ 0:可疑(warn),不阻塞但提醒疑似错误', () => {
|
||||
const hint = resultCellHint('0', 's')
|
||||
assert.equal(hint?.level, 'warn')
|
||||
assert.equal(hint?.message, '成绩 ≤ 0,疑似录入错误')
|
||||
})
|
||||
|
||||
test('秒位 ≥ 60:不合法(error),带出那一格的原文', () => {
|
||||
const hint = resultCellHint('1:70', 's')
|
||||
assert.equal(hint?.level, 'error')
|
||||
assert.ok(hint?.message.includes('秒位不能 ≥ 60'), hint?.message ?? '')
|
||||
assert.ok(hint?.message.includes('「1:70」'), hint?.message ?? '')
|
||||
// 边界:恰好 60 也算越界(60 秒要写成 1:00)。
|
||||
assert.equal(resultCellHint('1:60', 's')?.level, 'error')
|
||||
})
|
||||
|
||||
test('写法不认识:不合法(error),带出原文与可填写法', () => {
|
||||
const hint = resultCellHint('约1分06秒', 's')
|
||||
assert.equal(hint?.level, 'error')
|
||||
assert.ok(hint?.message.includes('成绩写法无法识别'), hint?.message ?? '')
|
||||
assert.ok(hint?.message.includes('「约1分06秒」'), hint?.message ?? '')
|
||||
})
|
||||
|
||||
test('非时间单位不套用时间格式提示', () => {
|
||||
const hint = resultCellHint('1.2.3', 'm')
|
||||
assert.equal(hint?.level, 'error')
|
||||
assert.ok(hint?.message.includes('米'), hint?.message ?? '')
|
||||
assert.ok(!hint?.message.includes('1:06'), '不该拿时间格式去教距离项目')
|
||||
})
|
||||
|
||||
test('实时提示与发布前防呆同源:错误文案去掉行标签后完全一致', () => {
|
||||
for (const text of ['1:70', '约1分06秒', '1.2.3', '快点跑']) {
|
||||
const hint = resultCellHint(text, 's')
|
||||
const issue = resultCellIssue('第 3 行 张三', text, 's')
|
||||
assert.equal(hint?.message, issue?.message.replace('第 3 行 张三:', ''), text)
|
||||
}
|
||||
})
|
||||
@@ -29,22 +29,24 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
||||
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
|
||||
})
|
||||
|
||||
test('表头含证件号 / 性别 / 年龄可选列,必填列不含这三列', () => {
|
||||
test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => {
|
||||
assert.deepEqual(
|
||||
[...REQUIRED_COLUMNS],
|
||||
[
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
|
||||
'bib', 'seed', 'status', 'member_names',
|
||||
'bib', 'seed', 'heat', 'status', 'member_names',
|
||||
],
|
||||
)
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age'])
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat'])
|
||||
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
|
||||
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件号 / 号码布', () => {
|
||||
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
@@ -58,13 +60,13 @@ test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 名运动员')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', 'registered', ''])
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', ''])
|
||||
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', 'registered', ''])
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', ''])
|
||||
// 只有证件号时 bib 列留空。
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||
@@ -76,8 +78,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||
@@ -90,7 +92,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
||||
athletes: [{ name: '王芳' }],
|
||||
memberNames: ['刘洋', '王芳'],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
|
||||
@@ -101,7 +103,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
|
||||
mode: 'individual',
|
||||
athletes: [{ name: '无标识' }],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
|
||||
@@ -114,7 +116,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', ()
|
||||
athletes: [],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
|
||||
@@ -137,13 +139,13 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 个单位')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
for (const row of rows.slice(1)) {
|
||||
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
|
||||
}
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', 'registered', '张伟'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟'])
|
||||
})
|
||||
|
||||
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
|
||||
@@ -155,7 +157,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可
|
||||
status: 'scratched',
|
||||
teams: [{ teamCode: 'F1', memberNames: [] }],
|
||||
})
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'scratched', ''])
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', ''])
|
||||
})
|
||||
|
||||
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
|
||||
@@ -273,8 +275,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行',
|
||||
const lines = toCsv(rows).split('\n')
|
||||
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
|
||||
assert.equal(lines.length, 3, '表头 + 两个单位')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,registered,', '只报单位时 member_names 留空')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空')
|
||||
})
|
||||
|
||||
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
|
||||
|
||||
@@ -30,9 +30,9 @@ function csv(rows: string[][]): File {
|
||||
return new File([toCsv([[...REQUIRED_COLUMNS], ...rows])], 'import.csv', { type: 'text/csv' })
|
||||
}
|
||||
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / status / member_names */
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', 'registered', '']
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
|
||||
|
||||
Reference in new issue
Block a user