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

@@ -83,9 +83,11 @@ function isRegistered(athlete: Athlete): boolean {
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
}
/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */
function missingIdCard(athlete: Athlete): boolean {
return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '')
/** 个人项目的行必须带证件号或号码布;团体行运动员为空,不受影响。 */
function missingIdentifier(athlete: Athlete): boolean {
const noIdCard = athlete.idCard === null || athlete.idCard === ''
const noBib = athlete.bib === null || athlete.bib === ''
return mode.value === 'individual' && noIdCard && noBib
}
async function loadEntries(id: number): Promise<void> {
@@ -138,7 +140,7 @@ async function submit(): Promise<void> {
categoryName: category.name,
teamCode: team.code,
mode: mode.value,
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })),
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard, bib: athlete.bib })),
})
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
const result = await data.importEntries(file)
@@ -227,12 +229,12 @@ async function submit(): Promise<void> {
v-model="checked"
type="checkbox"
:value="athlete.id"
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)"
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdentifier(athlete)"
/>
<span class="picker-name">{{ athlete.name }}</span>
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}</span>
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
<span v-else-if="missingIdCard(athlete)" class="mx-badge mx-badge-warning">缺证件号</span>
<span v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
</label>
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
@@ -243,8 +245,8 @@ async function submit(): Promise<void> {
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
</template>