feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员
大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)
图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮
关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
(用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)
安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认
自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
1 parent
24fff8dd08
commit
6483acf9ad
50 files changed
+2980
-395
No files matched your search
@@ -17,33 +17,42 @@ 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', 'bib', 'seed', 'status', 'member_names'],
|
||||
[
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
|
||||
'bib', 'seed', 'status', 'member_names',
|
||||
],
|
||||
)
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender'])
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age'])
|
||||
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:一人一行,共 10 列并带上证件号', () => {
|
||||
test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件号 / 号码布', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
teamCode: 'F1',
|
||||
mode: 'individual',
|
||||
athletes: [
|
||||
{ name: '张伟', idCard: '110101199001011234' },
|
||||
{ name: '李强', idCard: null },
|
||||
{ name: '张伟', idCard: '110101199001011234', bib: '101' },
|
||||
{ name: '李强', idCard: null, bib: '102' },
|
||||
{ name: '王芳', idCard: '110101199001011235', bib: null },
|
||||
],
|
||||
})
|
||||
assert.equal(rows.length, 3, '表头 + 2 名运动员')
|
||||
assert.equal(rows.length, 4, '表头 + 3 名运动员')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 10)
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '', 'registered', ''])
|
||||
// 证件号为空时留空列,服务端按「有姓名的行必须填证件号」报错。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '', 'registered', ''])
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', 'registered', ''])
|
||||
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', 'registered', ''])
|
||||
// 只有证件号时 bib 列留空。
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||
@@ -55,8 +64,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||
assert.equal(rows[1]?.length, 10)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||
@@ -69,5 +78,16 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
||||
athletes: [{ name: '王芳' }],
|
||||
memberNames: ['刘洋', '王芳'],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
teamCode: 'F1',
|
||||
mode: 'individual',
|
||||
athletes: [{ name: '无标识' }],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveAssetUrl } from '../components/system/imagePath'
|
||||
|
||||
// 服务端上传接口返回的路径已带 /uploads/ 前缀,这里主要保证手填的历史值也能加载。
|
||||
test('resolveAssetUrl 原样保留已带前缀的路径与绝对地址', () => {
|
||||
assert.equal(resolveAssetUrl('/uploads/a.png'), '/uploads/a.png')
|
||||
assert.equal(resolveAssetUrl('https://cdn.example.com/a.png'), 'https://cdn.example.com/a.png')
|
||||
assert.equal(resolveAssetUrl('HTTP://cdn.example.com/a.png'), 'HTTP://cdn.example.com/a.png')
|
||||
assert.equal(resolveAssetUrl('data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA')
|
||||
})
|
||||
|
||||
test('resolveAssetUrl 给相对路径补上前导斜杠', () => {
|
||||
assert.equal(resolveAssetUrl('uploads/a.png'), '/uploads/a.png')
|
||||
assert.equal(resolveAssetUrl('logos/team1.png'), '/logos/team1.png')
|
||||
assert.equal(resolveAssetUrl(' logos/team1.png '), '/logos/team1.png')
|
||||
})
|
||||
|
||||
// 空值必须返回空串:调用方据此判断「不渲染缩略图」,而不是给出一个坏掉的 <img src>。
|
||||
test('resolveAssetUrl 对空值返回空串', () => {
|
||||
assert.equal(resolveAssetUrl(null), '')
|
||||
assert.equal(resolveAssetUrl(undefined), '')
|
||||
assert.equal(resolveAssetUrl(' '), '')
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ImportConflict } from '@/shared'
|
||||
import type { AthleteInput, ImportConflict } from '@/shared'
|
||||
import { createApiClient } from '@/shared'
|
||||
import {
|
||||
REQUIRED_COLUMNS,
|
||||
@@ -17,10 +17,11 @@ import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
|
||||
return {
|
||||
row: 12,
|
||||
bib: '',
|
||||
athleteId: 42,
|
||||
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: '1001' },
|
||||
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
|
||||
changedFields: ['teamCode'],
|
||||
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
|
||||
changedFields: ['age', 'teamCode', 'bib'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -29,32 +30,49 @@ 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 / bib / seed / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, '', '', 'registered', '']
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', 'registered', '']
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段', () => {
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
|
||||
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.field),
|
||||
['name', 'gender', 'teamCode', 'employeeNo'],
|
||||
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.label),
|
||||
['姓名', '性别', '单位', '工号 / 学号'],
|
||||
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.changed),
|
||||
[false, false, true, false],
|
||||
[false, false, true, true, false, true],
|
||||
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
|
||||
)
|
||||
assert.equal(diffs[3]?.incoming, '—', 'null 展示为占位符')
|
||||
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
|
||||
assert.equal(diffs[2]?.incoming, '27')
|
||||
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
|
||||
assert.equal(diffs[5]?.existing, '101')
|
||||
assert.equal(diffs[5]?.incoming, '102')
|
||||
})
|
||||
|
||||
test('recordDecisions 只接受仍存在的证件号,且不改原 map', () => {
|
||||
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
|
||||
const diffs = conflictDiffs(
|
||||
conflict({
|
||||
idCard: '110101199001011234',
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
changedFields: [],
|
||||
}),
|
||||
)
|
||||
const age = diffs.find((diff) => diff.field === 'age')
|
||||
assert.deepEqual([age?.existing, age?.incoming, age?.changed], ['—', '—', false])
|
||||
})
|
||||
|
||||
test('recordDecisions 只接受仍存在的决策键,且不改原 map', () => {
|
||||
const conflicts = [conflict({ idCard: 'A' }), conflict({ idCard: 'B' })]
|
||||
const current = recordDecisions({}, conflicts, ['A', '不存在的证件号'], 'keep-existing')
|
||||
const current = recordDecisions({}, conflicts, ['A', '不存在的键'], 'keep-existing')
|
||||
assert.deepEqual(current, { A: 'keep-existing' })
|
||||
const next = recordDecisions(current, conflicts, ['B'], 'keep-new')
|
||||
assert.deepEqual(next, { A: 'keep-existing', B: 'keep-new' })
|
||||
@@ -63,20 +81,44 @@ test('recordDecisions 只接受仍存在的证件号,且不改原 map', () =>
|
||||
|
||||
test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收敛', () => {
|
||||
const conflicts = [conflict({ idCard: 'B' }), conflict({ idCard: 'A' })]
|
||||
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的证件号: 'keep-new' }
|
||||
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的键: 'keep-new' }
|
||||
assert.deepEqual(pruneDecisions(decisions, conflicts), { A: 'keep-new', B: 'keep-existing' })
|
||||
assert.deepEqual(
|
||||
decisionsPayload(decisions, conflicts),
|
||||
[
|
||||
{ idCard: 'B', action: 'keep-existing' },
|
||||
{ idCard: 'A', action: 'keep-new' },
|
||||
{ idCard: 'B', bib: '', action: 'keep-existing' },
|
||||
{ idCard: 'A', bib: '', action: 'keep-new' },
|
||||
],
|
||||
'载荷顺序与冲突列表一致',
|
||||
'载荷顺序与冲突列表一致,证件号与号码布都带上',
|
||||
)
|
||||
assert.deepEqual(summarizeDecisions(decisions, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
|
||||
assert.deepEqual(summarizeDecisions({}, conflicts), { keepExisting: 0, keepNew: 0, undecided: 2 })
|
||||
})
|
||||
|
||||
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
|
||||
|
||||
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
|
||||
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
|
||||
assert.deepEqual(first, { 101: 'keep-existing' })
|
||||
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
|
||||
|
||||
const both = recordDecisions(first, conflicts, ['102'], 'keep-new')
|
||||
assert.deepEqual(both, { 101: 'keep-existing', 102: 'keep-new' }, '两条号码布各留各的决策')
|
||||
assert.deepEqual(
|
||||
decisionsPayload(both, conflicts),
|
||||
[
|
||||
{ idCard: '', bib: '101', action: 'keep-existing' },
|
||||
{ idCard: '', bib: '102', action: 'keep-new' },
|
||||
],
|
||||
'载荷两条都在,且分别带自己的号码布供服务端算决策键',
|
||||
)
|
||||
assert.deepEqual(summarizeDecisions(both, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
|
||||
assert.deepEqual(pruneDecisions({ 101: 'keep-existing', 102: 'keep-new' }, [conflicts[1] as ImportConflict]), {
|
||||
102: 'keep-new',
|
||||
})
|
||||
})
|
||||
|
||||
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
@@ -99,7 +141,7 @@ test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', as
|
||||
const mock = new MockBaseData()
|
||||
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
||||
assert.equal(missing.skipped, 1)
|
||||
assert.match(missing.errors[0] ?? '', /必须填证件号/)
|
||||
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
|
||||
|
||||
const duplicated = await mock.importEntries(
|
||||
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
|
||||
@@ -121,9 +163,10 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
|
||||
assert.deepEqual(conflicted.conflicts[0], {
|
||||
row: 2,
|
||||
idCard,
|
||||
bib: '',
|
||||
athleteId,
|
||||
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: null },
|
||||
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
|
||||
changedFields: ['teamCode'],
|
||||
})
|
||||
|
||||
@@ -132,7 +175,7 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
|
||||
assert.equal(again.conflicts.length, 1)
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||
{ idCard, action: 'keep-existing' },
|
||||
{ idCard, bib: '', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.conflicts.length, 0)
|
||||
assert.equal(keptExisting.created, 1)
|
||||
@@ -140,7 +183,7 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
|
||||
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||
{ idCard, action: 'keep-new' },
|
||||
{ idCard, bib: '', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.conflicts.length, 0)
|
||||
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
||||
@@ -161,7 +204,7 @@ test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async
|
||||
},
|
||||
})
|
||||
const file = new File(['a'], 'entries.csv', { type: 'text/csv' })
|
||||
const decisions = [{ idCard: '110101199001010001', action: 'keep-new' as const }]
|
||||
const decisions = [{ idCard: '110101199001010001', bib: '101', action: 'keep-new' as const }]
|
||||
const result = await client.entries.resolveImport(file, decisions)
|
||||
|
||||
assert.equal(captured.url, '/api/entries/import/resolve')
|
||||
@@ -177,3 +220,204 @@ test('导入:gender 只认男 / 女,其它值忽略', async () => {
|
||||
const athlete = mock.listAthletes().find((item) => item.idCard === '110101199001010005')
|
||||
assert.equal(athlete?.gender, null)
|
||||
})
|
||||
|
||||
test('导入:有姓名的行只填号码布也能建人员', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([row('号布甲', '', 'F1', '男', '8001')]))
|
||||
assert.equal(result.created, 1)
|
||||
assert.deepEqual(result.conflicts, [])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
const athlete = mock.listAthletes().find((item) => item.bib === '8001')
|
||||
assert.equal(athlete?.name, '号布甲')
|
||||
assert.equal(athlete?.idCard, null)
|
||||
})
|
||||
|
||||
test('导入:号码布命中已有人员,不再新建', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
// 种子数据里 JG 的「张伟」号码布为 101。
|
||||
const result = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '101')]))
|
||||
assert.equal(result.created, 1)
|
||||
assert.deepEqual(result.conflicts, [])
|
||||
assert.equal(mock.listAthletes().length, before, '号码布命中已有人员,不新增')
|
||||
})
|
||||
|
||||
test('导入:姓名 + 单位命中且证件号为空时回填证件号并保留已有号码布', async () => {
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 F1 的「陈晨」证件号为空、号码布为 202。
|
||||
const athlete = mock.listAthletes().find((item) => item.name === '陈晨')
|
||||
assert.equal(athlete?.bib, '202')
|
||||
const result = await mock.importEntries(csv([row('陈晨', '110101199001010006', 'F1', '女')]))
|
||||
assert.equal(result.created, 1)
|
||||
assert.equal(result.conflicts.length, 0)
|
||||
const after = mock.listAthletes().find((item) => item.id === athlete?.id)
|
||||
assert.equal(after?.idCard, '110101199001010006')
|
||||
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
|
||||
})
|
||||
|
||||
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
|
||||
const idCard = '110101199001010007'
|
||||
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)
|
||||
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
|
||||
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'])
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
||||
{ idCard, bib: '999', action: 'keep-existing' },
|
||||
])
|
||||
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')
|
||||
})
|
||||
|
||||
test('导入:同一文件里号码布重复按行报错', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const result = await mock.importEntries(
|
||||
csv([row('号布甲', '', 'F1', '男', '8101'), row('号布乙', '', 'F2', '男', '8101')]),
|
||||
)
|
||||
assert.equal(result.created, 1, '第一行仍导入')
|
||||
assert.equal(result.skipped, 1)
|
||||
assert.match(result.errors[0] ?? '', /号码布 8101 在文件中重复出现/)
|
||||
})
|
||||
|
||||
test('导入(mock):只有号码布的两条冲突各按自己的号码布决策', async () => {
|
||||
const mock = new MockBaseData()
|
||||
await mock.importEntries(csv([row('号布甲', '', 'F1', '男', '9101'), row('号布乙', '', 'F2', '男', '9102')]))
|
||||
|
||||
// 号码布命中同一人但单位对调 → 两条冲突,证件号都是空串。
|
||||
const conflicted = await mock.importEntries(
|
||||
csv([row('号布甲', '', 'F2', '男', '9101'), row('号布乙', '', 'F1', '男', '9102')]),
|
||||
)
|
||||
assert.deepEqual(
|
||||
conflicted.conflicts.map((item) => [item.idCard, item.bib]),
|
||||
[
|
||||
['', '9101'],
|
||||
['', '9102'],
|
||||
],
|
||||
)
|
||||
|
||||
const resolved = await mock.resolveImportConflicts(
|
||||
csv([row('号布甲', '', 'F2', '男', '9101'), row('号布乙', '', 'F1', '男', '9102')]),
|
||||
[{ idCard: '', bib: '9101', action: 'keep-new' }],
|
||||
)
|
||||
assert.equal(resolved.created, 1, '只处理已决策的那条')
|
||||
assert.deepEqual(
|
||||
resolved.conflicts.map((item) => item.bib),
|
||||
['9102'],
|
||||
'没决策的号码布仍留在冲突里,没被另一条的决策带走',
|
||||
)
|
||||
const f2 = mock.listTeams().find((team) => team.code === 'F2')?.id
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '9101')?.teamId, f2)
|
||||
})
|
||||
|
||||
test('导入:库里没年龄时表里的年龄只回填,不算冲突;库里已有年龄则不覆盖', async () => {
|
||||
const idCard = '110101199001010009'
|
||||
const mock = new MockBaseData()
|
||||
await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男')]))
|
||||
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, null)
|
||||
|
||||
// 旧数据没有年龄列:表里带年龄也不能把所有人标成冲突。
|
||||
const filled = await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男', '', '31')]))
|
||||
assert.deepEqual(filled.conflicts, [], '库中年龄为空不算差异')
|
||||
assert.equal(filled.created, 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 31, '回填到库里')
|
||||
|
||||
// 库里已有年龄:表里留空不得清掉。
|
||||
const kept = await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男')]))
|
||||
assert.deepEqual(kept.conflicts, [])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 31, '空值不清掉已有年龄')
|
||||
})
|
||||
|
||||
test('导入:年龄两侧都有值且不同记为冲突,决策后按所选一侧处理', async () => {
|
||||
const idCard = '110101199001010010'
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 JG 的「张伟」号码布为 101、年龄为 22。
|
||||
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
|
||||
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 22)
|
||||
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '', '30')]))
|
||||
assert.equal(conflicted.created, 0, '年龄冲突同样不落库')
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.deepEqual([conflict?.existing.age, conflict?.incoming.age], [22, 30])
|
||||
assert.deepEqual(conflict?.changedFields, ['age'])
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '', '30')]), [
|
||||
{ idCard, bib: '', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.created, 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 22, '保留已有年龄')
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '', '30')]), [
|
||||
{ idCard, bib: '', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.created, 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 30, '使用新数据时年龄更新')
|
||||
})
|
||||
|
||||
test('导入:年龄非整数 / 超出 0–120 的行报错并跳过', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
const bad = await mock.importEntries(
|
||||
csv([
|
||||
row('年龄非整数', '110101199001010011', 'F1', '男', '', '26.5'),
|
||||
row('年龄超范围', '110101199001010012', 'F1', '男', '', '121'),
|
||||
row('年龄非数字', '110101199001010013', 'F1', '男', '', '二十'),
|
||||
]),
|
||||
)
|
||||
assert.equal(bad.skipped, 3)
|
||||
assert.equal(bad.created, 0)
|
||||
assert.equal(mock.listAthletes().length, before, '非法行不建人员')
|
||||
for (const error of bad.errors) assert.match(error, /年龄必须是 0–120 的整数/)
|
||||
|
||||
// 团体 / 对抗行没有姓名,年龄不参与校验。
|
||||
const team = await mock.importEntries(csv([row('', '', 'F1', '', '', '999')]))
|
||||
assert.equal(team.created, 1)
|
||||
assert.deepEqual(team.errors, [])
|
||||
})
|
||||
|
||||
test('MockBaseData 新增 / 更新人员时校验年龄范围', () => {
|
||||
const mock = new MockBaseData()
|
||||
const base: AthleteInput = {
|
||||
name: '年龄校验', gender: null, age: 26, teamId: 1, employeeNo: null, idCard: null, bib: null, active: true,
|
||||
}
|
||||
mock.createAthlete(base)
|
||||
const id = mock.listAthletes().find((athlete) => athlete.name === '年龄校验')?.id
|
||||
assert.ok(id !== undefined)
|
||||
assert.throws(() => mock.createAthlete({ ...base, age: 121 }), /年龄必须是 0–120 的整数/)
|
||||
assert.throws(() => mock.updateAthlete(id, { ...base, age: 26.5 }), /年龄必须是 0–120 的整数/)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === id)?.age, 26, '校验失败不改动原数据')
|
||||
assert.equal(mock.updateAthlete(id, { ...base, age: null }).age, null, '年龄可以清空')
|
||||
})
|
||||
|
||||
test('MockBaseData 新增人员时证件号、号码布重复分别报错', () => {
|
||||
const mock = new MockBaseData()
|
||||
const base = { name: '唯一性测试', gender: null, age: null, teamId: 1, employeeNo: null, active: true } as const
|
||||
mock.createAthlete({ ...base, idCard: '110101199001010008', bib: '9001' })
|
||||
assert.throws(
|
||||
() => mock.createAthlete({ ...base, idCard: '110101199001010008', bib: null }),
|
||||
/证件号 .* 已被其他人员使用/,
|
||||
)
|
||||
assert.throws(
|
||||
() => mock.createAthlete({ ...base, idCard: null, bib: '9001' }),
|
||||
/号码布 .* 已被其他人员使用/,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createApiClient } from '@/shared/api'
|
||||
|
||||
// 上传端点的契约由服务端定义(multipart 字段名 file,响应 {ok,data:{path,url}}),
|
||||
// 这里钉住前端这一侧:字段名或路径写错时,图片会在真机上静默 400。
|
||||
test('uploads.upload 以 multipart 字段 file POST /api/uploads 并返回路径', async () => {
|
||||
const captured: { url?: string; method?: string; body?: FormData } = {}
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
fetchImpl: async (input, init) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method
|
||||
captured.body = init?.body as FormData
|
||||
return new Response(JSON.stringify({ ok: true, data: { path: '/uploads/ab12.png', url: '/uploads/ab12.png' } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const file = new File(['x'], 'team.png', { type: 'image/png' })
|
||||
const result = await client.uploads.upload(file)
|
||||
|
||||
assert.equal(captured.url, '/api/uploads')
|
||||
assert.equal(captured.method, 'POST')
|
||||
assert.equal(captured.body?.get('file'), file)
|
||||
assert.deepEqual(result, { path: '/uploads/ab12.png', url: '/uploads/ab12.png' })
|
||||
})
|
||||
|
||||
test('uploads.upload 把服务端的错误文案原样抛出', async () => {
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
JSON.stringify({ ok: false, error: { code: 'VALIDATION_FAILED', message: '图片超过 5 MiB 上限' } }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => client.uploads.upload(new File(['x'], 'big.png', { type: 'image/png' })),
|
||||
(error: Error) => error.message === '图片超过 5 MiB 上限',
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user