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

@@ -21,14 +21,15 @@ export const REQUIRED_COLUMNS = [
'athlete_name',
'id_card',
'gender',
'age',
'bib',
'seed',
'status',
'member_names',
] as const
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender'] as const
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age'] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
@@ -44,8 +45,8 @@ export interface SelectionInput {
teamCode: string
status?: string
mode: SelectionMode
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号才能命中证件号优先的匹配规则。 */
athletes: { name: string; idCard?: string | null }[]
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。 */
athletes: { name: string; idCard?: string | null; bib?: string | null }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
memberNames?: string[]
}
@@ -69,6 +70,7 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
athlete.idCard ?? '',
'',
'',
athlete.bib ?? '',
'',
status,
'',
@@ -77,7 +79,19 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', '', '', status, names.join('/')])
rows.push([
input.eventCode,
input.categoryName,
input.teamCode,
'',
'',
'',
'',
'',
'',
status,
names.join('/'),
])
return rows
}
@@ -96,8 +110,10 @@ function escapeField(field: string): string {
const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
name: '姓名',
gender: '性别',
age: '年龄',
teamCode: '单位',
employeeNo: '工号 / 学号',
bib: '号码布',
}
/** 一条冲突里某个字段的两侧取值,changed 为 true 时界面高亮。 */
@@ -120,32 +136,48 @@ export function conflictDiffs(conflict: ImportConflict): ConflictFieldDiff[] {
}))
}
function formatFieldValue(value: string | null): string {
return value === null || value === '' ? '—' : value
function formatFieldValue(value: string | number | null | undefined): string {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
/** 证件号 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
/**
* 冲突的决策键:证件号优先,没有证件号时用号码布。
*
* 与服务端 repo_import.go 的 decisionKey() 是同一个约定,两边必须保持一致;
* 只有号码布的行证件号为空串,直接拿证件号当键会让多条冲突互相覆盖。
*/
export function decisionKeyOf(conflict: ImportConflict): string {
return conflict.idCard !== '' ? conflict.idCard : conflict.bib
}
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
export function conflictLabel(conflict: ImportConflict): string {
const key = decisionKeyOf(conflict)
return key === '' ? '(无证件号 / 号码布)' : key
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
export type DecisionMap = Record<string, ConflictAction>
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏证件号混进载荷。 */
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏键混进载荷。 */
export function recordDecisions(
current: DecisionMap,
conflicts: readonly ImportConflict[],
idCards: readonly string[],
keys: readonly string[],
action: ConflictAction,
): DecisionMap {
const known = new Set(conflicts.map((conflict) => conflict.idCard))
const known = new Set(conflicts.map(decisionKeyOf))
const next: DecisionMap = { ...current }
for (const idCard of idCards) {
if (known.has(idCard)) next[idCard] = action
for (const key of keys) {
if (known.has(key)) next[key] = action
}
return next
}
/** 丢弃已不存在的证件号,换文件或 resolve 之后调用。 */
/** 丢弃已不存在的决策键,换文件或 resolve 之后调用。 */
export function pruneDecisions(current: DecisionMap, conflicts: readonly ImportConflict[]): DecisionMap {
const known = new Set(conflicts.map((conflict) => conflict.idCard))
return Object.fromEntries(Object.entries(current).filter(([idCard]) => known.has(idCard)))
const known = new Set(conflicts.map(decisionKeyOf))
return Object.fromEntries(Object.entries(current).filter(([key]) => known.has(key)))
}
/** 按冲突在结果里的顺序生成 resolve 载荷,服务端回显顺序与界面一致。 */
@@ -154,8 +186,12 @@ export function decisionsPayload(
conflicts: readonly ImportConflict[],
): ImportConflictDecision[] {
return conflicts
.filter((conflict) => current[conflict.idCard] !== undefined)
.map((conflict) => ({ idCard: conflict.idCard, action: current[conflict.idCard] as ConflictAction }))
.filter((conflict) => current[decisionKeyOf(conflict)] !== undefined)
.map((conflict) => ({
idCard: conflict.idCard,
bib: conflict.bib,
action: current[decisionKeyOf(conflict)] as ConflictAction,
}))
}
export interface DecisionSummary {
@@ -171,7 +207,7 @@ export function summarizeDecisions(
): DecisionSummary {
const summary: DecisionSummary = { keepExisting: 0, keepNew: 0, undecided: 0 }
for (const conflict of conflicts) {
const action = current[conflict.idCard]
const action = current[decisionKeyOf(conflict)]
if (action === 'keep-existing') summary.keepExisting += 1
else if (action === 'keep-new') summary.keepNew += 1
else summary.undecided += 1