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:
AskaEth committed 2026-09-10 09:05:06 +08:00
1 parent 24fff8dd08
commit 6483acf9ad
50 files changed
+2980 -395

No files matched your search

+34 -14
View File
@@ -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', ''])
})