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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user