From 6483acf9add18a56131be246416be287b56fbc07 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 09:05:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=BA=E5=91=98=E5=B9=B4=E9=BE=84/?= =?UTF-8?q?=E5=8F=B7=E7=A0=81=E5=B8=83=E3=80=81=E5=9B=BE=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E3=80=81=E5=A4=9A=E6=98=BE=E7=A4=BA=E5=99=A8=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E3=80=81Console=20=E4=B8=A4=E6=A0=8F=E3=80=81exe=20?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E7=AD=89=E4=B8=80=E6=89=B9=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人员与报名: - 人员唯一标识改为「证件号 或 号码布」二选一(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 文件通过。 --- .gitignore | 4 +- README.md | 2 +- console/README.md | 8 +- console/config.json | 4 +- console/main/config.js | 4 +- console/main/displays.js | 63 ++ console/main/index.js | 60 +- console/package.json | 3 +- console/renderer/index.html | 233 +++--- console/renderer/shell-preload.js | 10 +- console/renderer/styles.css | 52 +- deploy/build.bat | 12 +- deploy/prepare-extra.mjs | 80 +- docs/上线检查清单.md | 2 +- docs/使用手册.md | 60 +- docs/常见问题.md | 19 +- docs/部署指南.md | 6 +- server/internal/httpapi/dto.go | 2 + server/internal/httpapi/export_handlers.go | 5 +- server/internal/httpapi/handlers_config.go | 215 ++++- .../internal/httpapi/handlers_config_test.go | 772 +++++++++++++++++- server/internal/httpapi/handlers_system.go | 2 + server/internal/httpapi/repo.go | 99 ++- server/internal/httpapi/repo_import.go | 193 +++-- server/internal/httpapi/repo_test.go | 140 ++++ server/internal/httpapi/server.go | 5 + server/internal/httpapi/system_test.go | 46 ++ server/internal/version/version.go | 7 + server/migrations/event/0014_athletes_bib.sql | 6 + server/migrations/event/0015_athletes_age.sql | 2 + .../components/baseData/AthletesSection.vue | 46 +- .../admin/components/baseData/EntryPicker.vue | 20 +- .../components/baseData/ImportSection.vue | 40 +- .../components/baseData/TeamsSection.vue | 5 +- .../components/baseData/entrySelection.ts | 74 +- .../admin/components/baseData/mockBaseData.ts | 116 ++- .../components/system/EventInfoPanel.vue | 33 + .../components/system/ImageUploadField.vue | 187 +++++ web/src/admin/components/system/imagePath.ts | 13 + web/src/admin/pages/AboutPage.vue | 20 +- web/src/admin/pages/DisplayPage.vue | 245 +++++- web/src/admin/tests/entry-selection.test.ts | 48 +- web/src/admin/tests/image-path.test.ts | 24 + web/src/admin/tests/import-conflicts.test.ts | 292 ++++++- web/src/admin/tests/upload-api.test.ts | 45 + web/src/display/components/TopBar.vue | 3 +- web/src/shared/api.ts | 26 + web/src/shared/format.ts | 4 +- web/src/shared/tests/format.test.ts | 2 +- web/src/shared/types.ts | 16 +- 50 files changed, 2980 insertions(+), 395 deletions(-) create mode 100644 server/migrations/event/0014_athletes_bib.sql create mode 100644 server/migrations/event/0015_athletes_age.sql create mode 100644 web/src/admin/components/system/ImageUploadField.vue create mode 100644 web/src/admin/components/system/imagePath.ts create mode 100644 web/src/admin/tests/image-path.test.ts create mode 100644 web/src/admin/tests/upload-api.test.ts diff --git a/.gitignore b/.gitignore index 18d9dbf..ade697b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ rules/* # 内部署名(构建期注入,绝不进仓库) deploy/signature.local.txt deploy/signature.build.txt -# 安装器许可页的署名文本(构建时生成) -console/resources/installer-signature.nsh +# 安装器自定义脚本:许可页署名 + 上次安装目录记忆(构建时生成) +console/resources/installer-custom.nsh # 开发文档(内部设计文档,不进仓库) devdocs/ diff --git a/README.md b/README.md index 0dc4885..800b0b9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## 快速开始 1. 解压部署包 -2. 双击 `Scoreboard Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启) +2. 双击 `Scoreboard_Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启) 3. 浏览器打开终端提示的地址 ``` diff --git a/console/README.md b/console/README.md index f23f3ef..31666c4 100644 --- a/console/README.md +++ b/console/README.md @@ -34,7 +34,7 @@ console/ ├── styles.css ├── app.js ├── preload.js # 管理窗口的 contextBridge 白名单 - └── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单 + └── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单(窗口控制 + 显示器菜单) ``` ## 运行方式 @@ -56,7 +56,7 @@ pnpm start # 等价于 electron . ``` scoreboard-windows-amd64/ -├── Scoreboard Console.exe +├── Scoreboard_Console.exe ├── resources/app.asar # 内含 main/ renderer/ config.json ├── config.json # exe 旁边的可编辑配置 ├── scoreboard.exe @@ -85,6 +85,7 @@ scoreboard-windows-amd64/ | 开机自启 | 默认「已注册但未启用」;在服务管理窗口的「开机自启」开关里控制(`startup.openAtLogin`),仅打包后的 Windows/macOS 生效,开发态不注册 | | 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) | | 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | +| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` | | 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 | | 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) | @@ -133,6 +134,8 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试 - `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。 - `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。 - 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 +- 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。 +- `nsis.include` 指向 `resources/installer-custom.nsh`(构建时由 `deploy/prepare-extra.mjs` 生成,已 gitignore):许可页顶部文案 + 记住上次安装目录(重装时默认沿用,不退回系统默认路径)。 - 已在 Windows 构建机上通过 `deploy\build.bat` 实跑(含 `pnpm dist`,产出 NSIS 安装包 + zip);Linux 开发机未安装 Electron/electron-builder。 ## 尚未实机验证 @@ -140,6 +143,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试 以下内容只做了静态语法检查,需要在 Windows + 真实多显示器环境跑一遍: - 多屏 kiosk 定位与 `display-metrics-changed` 重排(改分辨率 / 缩放 / 插拔 HDMI)。 +- 管理面板显示器菜单:真正的 kiosk 全屏 / 窗口化结果、`screen.getAllDisplays()` 的 `label` 与主屏判定、`display:list` 返回的 scaleFactor 是否与现场一致。 - `powerSaveBlocker` 在长时间后台下的实际效果。 - 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。 - `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。 diff --git a/console/config.json b/console/config.json index 5fae8b2..713af91 100644 --- a/console/config.json +++ b/console/config.json @@ -27,8 +27,8 @@ "openAtLogin": false }, "admin": { - "width": 1040, - "height": 760 + "width": 1280, + "height": 860 }, "log": { "maxLines": 2000, diff --git a/console/main/config.js b/console/main/config.js index 1efbded..e4be3a6 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -43,8 +43,8 @@ const DEFAULTS = { openAtLogin: false }, admin: { - width: 1040, - height: 760 + width: 1280, + height: 860 }, log: { maxLines: 2000, diff --git a/console/main/displays.js b/console/main/displays.js index c5c8b07..b80100d 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -285,6 +285,69 @@ class DisplayManager { this.layout() } + // 窗口的当前全屏状态:以窗口为准,配置改了 kiosk 但窗口没重建时也能报对 + isKioskWindow(win) { + if (!win || win.isDestroyed()) return false + return Boolean(win.isKiosk() || win.isFullScreen()) + } + + // 管理面板手动为某块屏开窗:已经开着就只聚焦,重建会打断正在展示的大屏 + openOn(display, { page, single } = {}) { + const kiosk = !single && this.display.kiosk !== false + const wantedPage = page || this.pageFor(0) + + const existing = this.windows.get(display.id) + if (existing && existing.win && !existing.win.isDestroyed()) { + if (existing.win.isMinimized()) existing.win.restore() + existing.win.show() + existing.win.focus() + // 已有窗口按它的真实状态报模式(可能与本次请求不同),面板才不会说反 + return { created: false, mode: this.isKioskWindow(existing.win) ? 'kiosk' : 'windowed' } + } + // 已销毁的窗口占着 map 会让后续 layout 静默跳过,先摘掉旧 entry 再建 + if (existing) { + if (existing.reloadTimer) clearInterval(existing.reloadTimer) + this.windows.delete(display.id) + } + + // 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走 + this.enabled = true + this.windows.set(display.id, this.createWindow(display, 0, !kiosk, wantedPage)) + return { created: true, mode: kiosk ? 'kiosk' : 'windowed' } + } + + // 关掉某块屏上的大屏窗口:kiosk 窗口刻意忽略鼠标,误开后只能从这里收 + closeDisplay(displayId) { + const entry = this.windows.get(displayId) + if (!entry) return false + this.destroyEntry(displayId, entry) + return true + } + + // 只读枚举显示器与窗口状态,给管理面板的显示器菜单用,不改动任何窗口 + listDisplays() { + const all = screen.getAllDisplays() + const primary = screen.getPrimaryDisplay() + const list = all.map((display, index) => { + const entry = this.windows.get(display.id) + const open = Boolean(entry && entry.win && !entry.win.isDestroyed()) + return { + id: display.id, + label: display.label || `显示器 ${display.id}`, + primary: display.id === primary.id, + // 一律给 DIP,与窗口坐标同一口径;物理像素对不上时看 scaleFactor + width: display.bounds.width, + height: display.bounds.height, + scaleFactor: display.scaleFactor, + open, + page: open ? entry.page : this.pageFor(index), + mode: open ? (this.isKioskWindow(entry.win) ? 'kiosk' : 'windowed') : null + } + }) + // 单屏机器上谈「扩展屏全屏」没有意义,与 pickDisplays 同一口径 + return { single: all.length <= 1, displays: list } + } + getInfo() { return [...this.windows.entries()].map(([displayId, entry]) => ({ displayId, diff --git a/console/main/index.js b/console/main/index.js index e30320c..4daf63e 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -8,7 +8,8 @@ const { Menu, Tray, ipcMain, - powerSaveBlocker + powerSaveBlocker, + screen } = require('electron') const { loadConfig, updateConfig, resolveExecutable } = require('./config') @@ -247,6 +248,57 @@ function main() { }) } + // 服务端管理面板是大屏窗口的第二个入口:页名只收 ASCII slug, + // 其余(含空值)交给 DisplayManager 回落配置里的默认页 + function safeDisplayPage(value) { + return typeof value === 'string' && /^[a-z0-9-]{1,32}$/.test(value) ? value : null + } + + function findDisplay(displayId) { + return screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) || null + } + + // 全屏窗口只允许出现在「多屏 + 非主屏」上:单屏或主屏开全屏会把用户桌面整个盖住, + // 现场又没有键盘能退出来。所以放不放行由主进程判定,页面传什么都不作数。 + function openDisplayWindow(displayId, options) { + if (!displays) return { ok: false, created: false, mode: null, message: '显示器管理尚未就绪' } + // IPC 入参一律当不可信处理 + const payload = options && typeof options === 'object' ? options : {} + const target = findDisplay(displayId) + if (!target) return { ok: false, created: false, mode: null, message: '未找到该显示器,可能已被拔出' } + + const all = screen.getAllDisplays() + const singleMachine = all.length <= 1 + const allowFullscreen = !singleMachine && target.id !== screen.getPrimaryDisplay().id + const wantedFullscreen = payload.fullscreen === true + const downgraded = wantedFullscreen && !allowFullscreen + + const result = displays.openOn(target, { + page: safeDisplayPage(payload.page), + single: !(wantedFullscreen && allowFullscreen) + }) + log.push('console', `管理面板${result.created ? '创建' : '聚焦'}大屏窗口 display=${target.id}(${result.mode === 'kiosk' ? '全屏 kiosk' : '窗口化'})`) + if (downgraded) { + const reason = singleMachine ? '当前只有一个显示器' : '主显示器' + log.push('console', `管理面板请求在 display=${target.id} 上开全屏:${reason}只能窗口化,已降级`) + } + + return { + ok: true, + created: result.created, + mode: result.mode, + message: downgraded ? `${singleMachine ? '当前只有一个显示器' : '主显示器'}只能窗口化` : undefined + } + } + + function closeDisplayWindow(displayId) { + if (!displays) return { ok: false, closed: false, message: '显示器管理尚未就绪' } + const target = findDisplay(displayId) + // 显示器拔了但窗口还在时,也允许按原 id 直接收窗 + const closed = displays.closeDisplay(target ? target.id : Number(displayId)) + return { ok: true, closed } + } + // 渲染层要随时知道最大化状态,才能在「最大化/还原」之间换图标 function bindWindowStateEvents(win) { const notify = () => { @@ -455,6 +507,12 @@ function main() { if (displays && displays.enabled) displays.relayout() return statusSnapshot() }) + ipcMain.handle('display:list', () => { + if (!displays) return { single: true, displays: [] } + return displays.listDisplays() + }) + ipcMain.handle('display:open', (event, displayId, options) => openDisplayWindow(displayId, options)) + ipcMain.handle('display:close', (event, displayId) => closeDisplayWindow(displayId)) ipcMain.handle('app:open-admin-window', () => { showAdmin() return true diff --git a/console/package.json b/console/package.json index 80c9430..b251d14 100644 --- a/console/package.json +++ b/console/package.json @@ -58,6 +58,7 @@ } ], "win": { + "executableName": "Scoreboard_Console", "icon": "resources/icon.ico", "target": [ { @@ -77,7 +78,7 @@ "createDesktopShortcut": true, "shortcutName": "赛事积分榜", "license": "../LICENSE", - "include": "resources/installer-signature.nsh", + "include": "resources/installer-custom.nsh", "language": "2052" } } diff --git a/console/renderer/index.html b/console/renderer/index.html index a2540d2..174f9f6 100644 --- a/console/renderer/index.html +++ b/console/renderer/index.html @@ -46,130 +46,135 @@
-
-
-

服务端状态

-
- - - + +
+
+
+

服务端状态

+
+ + + +
-
-
- - 已停止 - -
+
+ + 已停止 + +
-
-
-
服务地址
-
—
-
-
-
进程 PID
-
—
-
-
-
自动重启
-
—
-
-
-
防熄屏
-
—
-
-
-
开机自启
-
- -
-
-
-
服务端程序
-
—
-
-
-
配置文件
-
—
-
-
+
+
+
服务地址
+
—
+
+
+
进程 PID
+
—
+
+
+
自动重启
+
—
+
+
+
防熄屏
+
—
+
+
+
开机自启
+
+ +
+
+
+
服务端程序
+
—
+
+
+
配置文件
+
—
+
+
- -
+ + -
-
-

大屏窗口

-
- - +
+
+

大屏窗口

+
+ + +
-
-

- 单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。 -

-
    -
  • 尚未打开大屏窗口
  • -
-
+

+ 单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。 +

+
    +
  • 尚未打开大屏窗口
  • +
+ -
-
-

已连接的大屏实例

- -
-

- 来自服务端 GET /api/display/instances,每 5 秒刷新一次; - 沿用服务端管理面板的登录态,需要记录员及以上权限。 -

-
    -
  • 正在读取…
  • -
-
- -
-
-

管理员账号

-
- +
+
+

已连接的大屏实例

+
-
-

- 本机直接调用同目录的 scoreboard.exe passwd,无需登录验证。 - 重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。 -

-
- - - -
- - -
+

+ 来自服务端 GET /api/display/instances,每 5 秒刷新一次; + 沿用服务端管理面板的登录态,需要记录员及以上权限。 +

+
    +
  • 正在读取…
  • +
+ -
-
-

运行日志

-
- - +
+
+

管理员账号

+
+ +
-
-

-      
+

+ 本机直接调用同目录的 scoreboard.exe passwd,无需登录验证。 + 重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。 +

+
+ + + +
+ + + + + +
+
+
+

运行日志

+
+ + +
+
+

+        
+
diff --git a/console/renderer/shell-preload.js b/console/renderer/shell-preload.js index c24da0b..b77105f 100644 --- a/console/renderer/shell-preload.js +++ b/console/renderer/shell-preload.js @@ -2,7 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron') -// 服务端管理面板是 http://127.0.0.1 下的 Web 页面,只放行窗口控制这一组能力, +// 服务端管理面板是 http://127.0.0.1 下的 Web 页面,只放行窗口与显示器控制这两组能力, // 不把 ipcRenderer 交给页面,避免面板页面拿到主进程的任意通道 contextBridge.exposeInMainWorld('desktopShell', { platform: process.platform, @@ -14,5 +14,13 @@ contextBridge.exposeInMainWorld('desktopShell', { const handler = (_event, maximized) => callback(maximized) ipcRenderer.on('window:maximize-changed', handler) return () => ipcRenderer.removeListener('window:maximize-changed', handler) + }, + // 显示器菜单:列屏 / 按屏开窗 / 收窗 / 按设置重建。全屏放行与否由主进程判定, + // 这里只透传,页面拿不到 ipcRenderer 本身 + displays: { + list: () => ipcRenderer.invoke('display:list'), + open: (displayId, options) => ipcRenderer.invoke('display:open', displayId, options), + close: (displayId) => ipcRenderer.invoke('display:close', displayId), + relayout: () => ipcRenderer.invoke('display:relayout') } }) diff --git a/console/renderer/styles.css b/console/renderer/styles.css index 6c1e787..cc00ac9 100644 --- a/console/renderer/styles.css +++ b/console/renderer/styles.css @@ -151,14 +151,28 @@ main { padding: 16px 20px 20px; display: grid; gap: 14px; - grid-template-columns: 1fr 1fr; - /* 五张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */ - grid-template-rows: auto auto auto auto minmax(160px, 1fr); + /* 左栏稍宽放卡片,右栏整栏给运行日志 */ + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + /* 滚动交给两栏各自处理,否则整页滚动时日志也跟着跑 */ + overflow: hidden; +} + +.col-left { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; + /* 左栏卡片总高超出时左栏自己滚,不影响右栏日志 */ overflow: auto; } +.col-right { + display: flex; + flex-direction: column; + min-height: 0; +} + .card { - grid-column: span 2; padding: 14px 16px; border: 1px solid var(--border); border-radius: 10px; @@ -168,6 +182,8 @@ main { .log-card { display: flex; flex-direction: column; + /* 撑满右栏整高,日志区随窗口高度伸缩 */ + flex: 1; min-height: 0; } @@ -256,11 +272,13 @@ h2 { .kv { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + /* 左栏只有半屏宽,4 列会挤成窄条,改 2 列 */ + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; margin: 0; } +/* 2 列下 span 2 正好是一整行,长路径仍然通栏 */ .kv > div.wide { grid-column: span 2; } @@ -410,7 +428,8 @@ h2 { .form-row { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + /* 左栏宽度不定:够宽排 3 个,窄了自动换行成 2 个 / 1 个 */ + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 16px; } @@ -471,3 +490,24 @@ h2 { max-height: 180px; margin: 10px 0 0; } + +/* 窗口拖窄到放不下两栏时退回单栏,日志仍排在最后并保留最小高度 */ +@media (max-width: 900px) { + /* 这里必须换成 flex:栅格下 auto 行会被压缩,左栏内容会被裁掉而不是撑开滚动 */ + main { + display: flex; + flex-direction: column; + overflow: auto; + } + + /* 单栏时整页一起滚,左栏按内容高、不再自成一个滚动区 */ + .col-left { + flex: none; + overflow: visible; + } + + .col-right { + flex: 1; + min-height: 200px; + } +} diff --git a/deploy/build.bat b/deploy/build.bat index 2affac7..45cab03 100644 --- a/deploy/build.bat +++ b/deploy/build.bat @@ -36,20 +36,28 @@ REM deploy\signature.build.txt, and for /f reads that file directly (no re-encod node ..\deploy\prepare-signature.mjs || exit /b 1 set "INTERNAL_SIGNATURE=" if exist "..\deploy\signature.build.txt" for /f "usebackq delims= eol=" %%A in ("..\deploy\signature.build.txt") do if not defined INTERNAL_SIGNATURE set "INTERNAL_SIGNATURE=%%A" +REM build time for the About page. Do NOT use %DATE%/%TIME%: a localized Windows +REM prints non-ASCII weekday names, which cmd mis-parses (see the header note). +set "BUILD_TIME=" +for /f "usebackq delims=" %%T in (`powershell -NoProfile -Command "(Get-Date).ToString('yyyy-MM-dd HH:mm:ss')"`) do if not defined BUILD_TIME set "BUILD_TIME=%%T" +set "BUILD_TIME_LDFLAG=-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.BuildTime=%BUILD_TIME%'" if defined INTERNAL_SIGNATURE ( REM single quotes keep spaces inside the value intact for go's ldflags parser - go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%'" -o ..\dist\scoreboard.exe . || exit /b 1 + go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%' %BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1 node ..\deploy\verify-signature.mjs || exit /b 1 echo internal signature injected ) else ( - go build -o ..\dist\scoreboard.exe . || exit /b 1 + go build -ldflags "%BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1 echo no internal signature ) +echo build time %BUILD_TIME% cd .. echo === [4/4] console package (electron-builder) === REM win.icon is written into the PE resources by electron-builder (via rcedit); REM it needs winCodeSign / nsis, fetched through the gh proxy above. +taskkill /im "Scoreboard_Console.exe" /f >nul 2>&1 +REM builds from before the exe rename used a space in the file name - kill that too taskkill /im "Scoreboard Console.exe" /f >nul 2>&1 taskkill /im scoreboard.exe /f >nul 2>&1 if not exist "rules" ( diff --git a/deploy/prepare-extra.mjs b/deploy/prepare-extra.mjs index f98fe05..2382355 100644 --- a/deploy/prepare-extra.mjs +++ b/deploy/prepare-extra.mjs @@ -1,8 +1,9 @@ // 构建前准备两样东西: // 1) 把 docs/ 下的使用说明暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录; -// 2) 生成安装器许可页顶部文案(console/resources/installer-signature.nsh),署名来自构建机本地文件。 +// 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件) +// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)。 // 用 Node 而不是批处理:中文文件名/中文内容在 .bat 里会被 cmd 解析错乱。 -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' const root = dirname(import.meta.dirname) @@ -20,15 +21,80 @@ if (usageSource) { console.log(' [docs] no usage doc, skip') } -// ---- 2. installer license page text ---------------------------------------- +// ---- 2. installer custom script --------------------------------------------- +// electron-builder 的 nsis.include 只收一个文件,所以许可页文案与安装目录记忆写在同一个 .nsh 里。 const signatureFile = join(root, 'deploy', 'signature.local.txt') -const nshFile = join(root, 'console', 'resources', 'installer-signature.nsh') -mkdirSync(dirname(nshFile), { recursive: true }) +const nshDir = join(root, 'console', 'resources') +const nshFile = join(nshDir, 'installer-custom.nsh') +const legacyNshFile = join(nshDir, 'installer-signature.nsh') +mkdirSync(nshDir, { recursive: true }) let topText = '请阅读并接受许可协议' if (existsSync(signatureFile)) { const signature = readFileSync(signatureFile, 'utf8').trim() if (signature !== '') topText = `内部署名:${signature}` } -writeFileSync(nshFile, `!macro customHeader\n !define MUI_LICENSEPAGE_TEXT_TOP "${topText}"\n!macroend\n`, 'utf8') -console.log(` installer license text ready (${existsSync(signatureFile) ? 'with' : 'without'} internal signature)`) + +// 换行用 CRLF:.gitattributes 里 *.nsh 就是 CRLF,NSIS 在 Windows 上按CRLF 读最稳。 +const nsh = [ + '!include "LogicLib.nsh"', + '', + '; 安装器自定义脚本:由 deploy/prepare-extra.mjs 生成,不要手改。', + '; 内容只有三块:许可页文案、上次安装目录记忆、卸载钩子。', + '', + '; 自己记的安装目录(per-user 落 HKCU,per-all-users 落 HKLM)。', + '; electron-builder 自带的 ${INSTALL_REGISTRY_KEY} 在卸载时会被整键删掉,', + '; 「先卸载再装新版」或换过安装模式就会退回系统默认路径,所以另存一份不会被卸载器清理的。', + '!define SCOREBOARD_INSTALL_KEY "Software\\Scoreboard Console"', + '', + '!macro customHeader', + ` !define MUI_LICENSEPAGE_TEXT_TOP "${topText}"`, + '!macroend', + '', + '!macro customInit', + ' ; 在 .onInit 里、initMultiUser 之后执行:此时改 $INSTDIR 还来得及影响后面的目录页。', + ' ReadRegStr $R9 HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"', + ' ${If} $R9 != ""', + ' StrCpy $INSTDIR $R9', + ' ; 安装模式页(per-user / per-all-users)离开时,electron-builder 会用自己那个键', + ' ; 重新算一遍 $INSTDIR;卸载后它已经被删了,把记忆值补写回同一个 hive 里。', + ' ; 不跨 hive 写:否则会把 per-machine 装的机器悄悄带成默认 per-user 模式(反之亦然)。', + ' WriteRegStr HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"', + ' ${Else}', + ' ReadRegStr $R9 HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"', + ' ${If} $R9 != ""', + ' StrCpy $INSTDIR $R9', + ' WriteRegStr HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"', + ' ${Else}', + ' ; 没有自己的记忆时,退回 electron-builder 的键(老版本装过、没卸载过的情况)', + ' ReadRegStr $R9 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation"', + ' ${If} $R9 == ""', + ' ReadRegStr $R9 HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation"', + ' ${EndIf}', + ' ; 只认非空值;旧目录已经被删掉也照样用(这就是「记住我的路径」的语义)。', + ' ${If} $R9 != ""', + ' StrCpy $INSTDIR $R9', + ' ${EndIf}', + ' ${EndIf}', + ' ${EndIf}', + ' ClearErrors', + '!macroend', + '', + '!macro customInstall', + ' ; SHELL_CONTEXT 跟随安装模式:per-user → HKCU,per-all-users → HKLM。', + ' WriteRegStr SHELL_CONTEXT "${SCOREBOARD_INSTALL_KEY}" "InstallLocation" "$INSTDIR"', + '!macroend', + '', + '!macro customUnInstall', + ' ; 故意什么都不删:需求就是「卸载后重装仍沿用上次的目录」。', + ' ; 若改成卸载即遗忘,在这里补两行(卸载时 SHELL_CONTEXT 不一定已设定,要写死根键):', + ' ; DeleteRegValue HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"', + ' ; DeleteRegValue HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"', + '!macroend', + '', +].join('\r\n') + +writeFileSync(nshFile, nsh, 'utf8') +// 改名前生成的旧脚本已经没人引用,构建机上顺手清掉,免得看起来还在用。 +rmSync(legacyNshFile, { force: true }) +console.log(` installer custom script ready (${existsSync(signatureFile) ? 'with' : 'without'} internal signature)`) diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index faabb7c..1677654 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -22,7 +22,7 @@ ## 3. 首次启动 -- [ ] 双击 `Scoreboard Console.exe`,启动管理窗口出现 +- [ ] 双击 `Scoreboard_Console.exe`,启动管理窗口出现 - [ ] 服务端状态变为「运行中 · 就绪」 - [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录 - [ ] **管理员初始密码**在 `logs\console.log` 里搜 `INITIAL ADMIN CREATED`,密码就在该行上方;也可以直接打开 Console 的「运行日志」面板往上翻。首次登录会要求改密 diff --git a/docs/使用手册.md b/docs/使用手册.md index b272674..c9262fd 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -220,6 +220,32 @@ **在线看板**:当前版本后端未提供大屏实例上报接口,因此在线状态恒显示「未知」,「最近下发」只是本机下发指令的时间,**不代表大屏在线**。 +### 9.1 显示器菜单(重建被关掉的大屏窗口) + +扩展屏上的大屏窗口平时由服务管理程序(Console)按配置自动开出来,只有在**有人手动关掉了某块屏的大屏窗口**时才需要这一节。 + +这张「显示器」卡片只在**从服务管理窗口点「打开服务端管理面板」打开的面板**里可用;用浏览器直接打开管理面板时,卡片只显示一行提示,这是正常的。 + +1. **窗口页面**:先选好新窗口锁定显示哪一页(默认「总积分榜」); +2. **显示器列表**:逐块屏列出名称、分辨率、缩放、是否主显示器,以及当前是否已开窗口(「已开窗口(全屏)」「已开窗口(窗口化)」或「未开窗口」); +3. **每行的按钮**: + - 该屏已经有窗口 → **聚焦窗口**:把窗口提到最前,不重建、不打断正在展示的比分; + - 该屏还没有窗口,且允许多屏全屏 → **创建全屏窗口**; + - 该屏还没有窗口,但不允许全屏 → **创建窗口(窗口化)**,行内会注明「单显示器 / 主显示器只能窗口化」; + - 已开窗口的行还有一个 **关闭窗口**:全屏大屏窗口是锁定鼠标的,万一开到了错误的屏上,从大屏本身退不出来,用这个按钮收掉; +4. **重新排布(按设置重建全部窗口)**:按服务管理程序里的选屏配置重建所有大屏窗口。不在该配置范围内的窗口会被关掉,所以日常只想恢复某一块屏时,请优先用上面的「创建」按钮。 + +**为什么单显示器 / 主显示器只能创建窗口化窗口**:全屏窗口会盖住整块屏,而且没有标题栏和关闭按钮。单显示器上开全屏,等于把操作人员的桌面整个盖住;主显示器上开全屏,会把服务管理窗口、管理面板这些工作窗口全部遮住——现场又没有键盘可以退出来。所以这两种情况下按钮只提供窗口化:窗口约占屏幕 80%、居中显示、带标题栏,随时可关。只有「本机有多于一块显示器,且目标不是主显示器」时才放行全屏。 + +**副屏全屏窗口被误关后,一键恢复**: + +1. 在服务管理窗口点「打开服务端管理面板」; +2. 进入管理端「大屏控制」页; +3. 在「显示器」卡片里找到那块副屏(那一行没有「主显示器」标记,且分辨率/缩放与副屏一致),点它的 **创建全屏窗口**; +4. 列表刷新后该行显示「已开窗口(全屏)」,副屏上即出现全屏积分榜。 + +多块副屏都被关掉时,在同一张卡片里逐块点「创建全屏窗口」即可;改用「重新排布」也行,但前提是这些屏都在 Console 的选屏配置里。 + --- ## 10. 基础数据 @@ -229,7 +255,7 @@ ### 10.1 参赛单位 1. 点「新增单位」; -2. 填写代码 *(唯一,字母 / 数字 / `_` / `-`,最长 16 位)、全称 *、简称、主题色、排序、logo 路径; +2. 填写代码 *(唯一,字母 / 数字 / `_` / `-`,最长 16 位)、全称 *、简称、主题色、排序、logo(可点输入框右侧「上传图片」直接传,png / jpg / webp / gif,单张不超过 5 MiB;也能手填已有路径,留空则不显示); 3. 「编辑」修改;「停用」为软删除——历史成绩与积分流水保留,但新报名不再可选该单位,可随时「启用」。 ### 10.2 大项 / 小项 @@ -263,8 +289,8 @@ ### 10.6 运动员 -1. 点「新增运动员」,填写姓名 *、证件号、性别、工号 / 学号、所属单位等; -2. **证件号是人员的唯一标识**(如身份证号),用于区分重名;同一个证件号不允许重复,重复会被拒绝。已有历史数据可以留空; +1. 点「新增运动员」,填写姓名 *、证件号、号码布、性别、工号 / 学号、所属单位等; +2. **证件号(如身份证号)与号码布二者取其一**即可唯一标识一个人,用于区分重名;两个人员不能共用同一个证件号,也不能共用同一个号码布,重复会被拒绝;没有身份证信息的人可以只填号码布。已有历史数据两项可以都为空; 3. 「编辑」修改,「停用」后不再出现在新报名选择中; 4. 个人项目的报名项会关联到运动员,团体项目可以留空。 @@ -276,7 +302,7 @@ 1. 依次选择小项、组别、单位; 2. 在运动员列表里按姓名 / 工号搜索并勾选: - - 个人项目:勾选的人各生成一条报名;已报名的运动员显示「已报名」且不可重复勾选; + - 个人项目:勾选的人各生成一条报名;已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员无法勾选,请先到「运动员」分区补齐其中一项; - 团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;该单位已报名时再次提交会更新队员名单; 3. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量。 @@ -284,24 +310,29 @@ 1. 点「下载 CSV 模板」获取表头样例; 2. 表头共 10 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`bib`、`seed`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)与 `gender`(性别)为可选列,缺失不影响导入,其余列必须包含; -3. **只要有姓名的行必须填 `id_card`**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;同一份文件里同一个证件号重复出现时,重复的行会报错; -4. 人员匹配顺序:先按证件号匹配已有人员;没匹配到,再按「姓名 + 单位」匹配且该人员证件号为空,视为同一人并回填证件号(不算冲突);都没匹配到则新建人员; -5. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); -6. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 +3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;同一份文件里同一个证件号或同一个号码布重复出现时,重复的行会报错; +4. 人员匹配顺序: + 1. 先按 `id_card` 匹配已有人员; + 2. 没匹配到,再按 `bib`(号码布)匹配; + 3. 还没匹配到,按「姓名 + 单位」匹配且该人员证件号为空,视为同一人,把表里给的证件号 / 号码布补进去(不算冲突);如果该人员已有号码布且与表里不同,则算冲突; + 4. 都没匹配到则新建人员(姓名、性别、单位、证件号、号码布); +5. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布; +6. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); +7. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 -**证件号冲突的处理** +**人员信息冲突的处理** -导入时如果某个证件号在系统里已经存在,但表里的姓名、性别或单位与已有人员不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法: +导入时如果某个证件号或号码布在系统里已经存在,但表里的姓名、性别、单位或号码布与已有人员不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法: -1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段; +1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段(含号码布); 2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」: - **保留已有数据**:人员字段沿用系统里已有的,按这个人的信息建 / 更新报名; - - **使用新数据**:用表里的姓名、性别、单位等更新该人员,再建 / 更新报名; + - **使用新数据**:用表里的姓名、性别、单位、号码布等更新该人员,再建 / 更新报名; 3. **没有做决定的冲突不会被写入**,可以稍后再处理;全部处理完后冲突列表清空。 > 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。 -号码布、种子等字段无法在勾选界面填写,仍需通过表格导入。 +种子等字段无法在勾选界面填写,仍需通过表格导入;号码布取自人员档案,勾选时会自动写入报名。 --- @@ -389,7 +420,10 @@ | --- | --- | | 赛事名称 * | 必填 | | 副标题 / 主办方 | 可选,显示在大屏顶部 | +| 赛事会标 | 图片,显示在大屏左上角。点「上传图片」选文件即可(png / jpg / webp / gif,单张不超过 5 MiB);上传的图片存在赛事项目目录的 `uploads/` 里,会随赛事包一起导出/导入 | | 大屏标题 / 大屏副标题 | 大屏顶部标题区 | +| 大屏会标 | 图片,只对大屏生效;留空则用上面的「赛事会标」 | +| 大屏背景图 | 图片,铺满大屏底层;留空则用内置深色背景 | | 轮播间隔(秒) | 控制大屏底部滚动条(奖牌榜 Top3 / 最新完成项目 / 公告)的切换间隔,5–120 秒 | | 主题色 | 大屏主色,如 `#3482FF` | | 个人积分榜上屏 | 开关;关闭后大屏不展示个人积分榜 | diff --git a/docs/常见问题.md b/docs/常见问题.md index 9fb4ffe..5ee74c8 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -119,25 +119,34 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。 **Q33:导入时提示证件号重复怎么办?** -证件号是人员的唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。 +证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。 - **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。 - **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 +**Q34:人员没有身份证,只有号码布,能报名吗?** +可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。 + +**Q35:号码布重复了怎么办?** +号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。 + +- **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。 +- **表格导入时出现重复**:同一份文件里同一个号码布重复出现,重复的行会报错并跳过,改好后重新上传;号码布在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 + --- ## 其他 -**Q34:系统需要联网吗?** +**Q36:系统需要联网吗?** 不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。 -**Q35:页面提示「示例数据」是什么意思?** +**Q37:页面提示「示例数据」是什么意思?** 表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 > 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。 -**Q36:为什么登录 / 写操作偶尔被拒绝?** +**Q38:为什么登录 / 写操作偶尔被拒绝?** 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 -**Q37:数据目录在哪?** +**Q39:数据目录在哪?** 由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 5c4bd0e..6f37c53 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -56,7 +56,7 @@ deploy\build.bat ### 3.2 使用免安装包 1. 把 `Scoreboard Console-0.1.0-x64.zip` 解压到任意目录(建议使用纯英文路径)。 -2. 双击目录里的 `Scoreboard Console.exe` 启动。 +2. 双击目录里的 `Scoreboard_Console.exe` 启动。 ### 3.3 首次启动会自动完成 @@ -71,7 +71,7 @@ deploy\build.bat ``` 安装目录\ -├── Scoreboard Console.exe # 桌面宿主,双击启动 +├── Scoreboard_Console.exe # 桌面宿主,双击启动 ├── resources\app.asar # Console 自身资源 ├── config.json # Console 配置(可编辑) ├── scoreboard.exe # Go 服务端 @@ -217,7 +217,7 @@ scoreboard.exe passwd -config config.yaml | `display.waitForServerMs` | `20000` | 服务端未就绪时,最多等这么久也先把大屏开出来 | | `display.backgroundColor` | `#0b0f1a` | 大屏窗口底色,减少加载白闪 | | `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制 | -| `admin.width` / `admin.height` | `1040` / `760` | 启动管理窗口初始尺寸 | +| `admin.width` / `admin.height` | `1280` / `860` | 启动管理窗口初始尺寸(服务管理窗口与服务端管理面板共用) | | `log.maxLines` | `2000` | 内存中保留的日志行数 | | `log.file` | `logs/console.log` | 日志文件,相对 `config.json` 所在目录 | | `log.maxFileBytes` | `4194304` | 单个日志文件上限,超出后轮转为 `.1` | diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index 1e17b38..8301957 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -21,6 +21,8 @@ type athleteDTO struct { TeamID int64 `json:"teamId"` EmployeeNo *string `json:"employeeNo"` IDCard *string `json:"idCard"` + Bib *string `json:"bib"` + Age *int `json:"age"` Active bool `json:"active"` } diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index 469f401..c6faebf 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -107,9 +107,8 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, } opts := export.ProjectExportOptions{Name: name, AppVersion: s.version} - if s.projectDir != "" { - // uploads 可能还没建过;只在目录存在时打包,否则 copyTree 会报错。 - uploads := filepath.Join(s.projectDir, "uploads") + // uploads 与图片上传共用同一路径约定;目录可能还没建过,只在存在时打包,否则 copyTree 会报错。 + if uploads, err := s.uploadsDir(); err == nil { if info, err := os.Stat(uploads); err == nil && info.IsDir() { opts.UploadsDir = uploads } diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 983c111..6dc801e 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -2,12 +2,17 @@ package httpapi import ( "context" + "crypto/rand" "encoding/csv" + "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" + "os" "path/filepath" + "strconv" "strings" "github.com/xuri/excelize/v2" @@ -534,11 +539,12 @@ type entryImportResult struct { Conflicts []importConflict `json:"conflicts"` } -// importConflict 是「证件号已存在、但姓名/性别/单位与库中不一致」的一行。 +// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。 // 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。 type importConflict struct { Row int `json:"row"` IDCard string `json:"idCard"` + Bib string `json:"bib"` AthleteID int64 `json:"athleteId"` Existing importAthleteView `json:"existing"` Incoming importAthleteView `json:"incoming"` @@ -551,11 +557,16 @@ type importAthleteView struct { Gender *string `json:"gender"` TeamCode string `json:"teamCode"` EmployeeNo *string `json:"employeeNo"` + IDCard *string `json:"idCard"` + Bib *string `json:"bib"` + Age *int `json:"age"` } // importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。 +// 证件号与号码布二选一作为决策键,证件号优先。 type importDecision struct { IDCard string `json:"idCard"` + Bib string `json:"bib"` Action string `json:"action"` } @@ -610,14 +621,18 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a if err := json.Unmarshal([]byte(raw), &decisions); err != nil { return nil, badRequest("decisions 不是合法 JSON", err.Error()) } - byCard := make(map[string]string, len(decisions)) + byKey := make(map[string]string, len(decisions)) for _, d := range decisions { if d.Action != decisionKeepExisting && d.Action != decisionKeepNew { return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action) } - byCard[strings.TrimSpace(d.IDCard)] = d.Action + key := strings.TrimSpace(d.IDCard) + if key == "" { + key = strings.TrimSpace(d.Bib) + } + byKey[key] = d.Action } - result, err := s.importEntriesWith(r.Context(), rows, byCard) + result, err := s.importEntriesWith(r.Context(), rows, byKey) if err != nil { return nil, err } @@ -655,7 +670,7 @@ func readTable(ext string, file io.Reader) ([][]string, error) { } // importColumns 是报名表必须包含的表头(大小写不敏感); -// id_card、gender 为可选列,缺列不报错,按空值处理。 +// id_card、gender、age 为可选列,缺列不报错,按空值处理。 var importColumns = []string{ "event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names", } @@ -665,7 +680,7 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor } // importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库; -// 非 nil 时按决策处理证件号命中的冲突,未出现在 decisions 里的冲突继续收集。 +// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。 func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) { result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}} if len(rows) < 2 { @@ -681,8 +696,9 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio } } - // 同一文件里证件号只能出现一次,否则无法判断是不是同一个人。 + // 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。 seenIDCards := make(map[string]bool) + seenBibs := make(map[string]bool) for n, row := range rows[1:] { line := n + 2 cell := func(name string) string { @@ -735,17 +751,35 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio var athleteID *int64 if athleteName != "" { idCard := cell("id_card") - if idCard == "" { + bib := cell("bib") + // 证件号与号码布二选一即可,都没有就无法区分同名的两个人。 + if idCard == "" && bib == "" { result.Skipped++ - result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card", line)) + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line)) continue } - if seenIDCards[idCard] { + age, ok := parseImportAge(cell("age")) + if !ok { result.Skipped++ - result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard)) + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line)) continue } - seenIDCards[idCard] = true + if idCard != "" { + if seenIDCards[idCard] { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard)) + continue + } + seenIDCards[idCard] = true + } + if bib != "" { + if seenBibs[bib] { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, bib)) + continue + } + seenBibs[bib] = true + } matched, err := s.matchImportedAthlete(ctx, athleteImportRow{ Line: line, @@ -754,6 +788,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio TeamID: teamID, TeamCode: teamCode, IDCard: idCard, + Bib: bib, + Age: age, }, decisions) if err != nil { result.Skipped++ @@ -792,6 +828,18 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio return result, nil } +// parseImportAge 解析可选的 age 列:空值合法(返回 nil),非整数或超出 0-120 视为非法。 +func parseImportAge(raw string) (*int, bool) { + if raw == "" { + return nil, true + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 || n > 120 { + return nil, false + } + return &n, true +} + // normalizeGender 只认 男/女,其它值(含空)一律忽略。 func normalizeGender(raw string) string { switch raw { @@ -814,3 +862,146 @@ func splitNames(raw string) []string { } return out } + +// ---------- 图片上传与静态访问 ---------- + +// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节, +// 解析上限相应放宽,真实大小由 uploadHandler 里的 LimitReader 兜住。 +const ( + maxUploadBytes = 5 << 20 + maxUploadFormBytes = maxUploadBytes + 1<<20 +) + +// allowedUploadImageTypes 是允许上传的图片扩展名与对应 Content-Type。 +// 收 SVG 会让匿名可访问的同源静态资源变成存储型 XSS 入口,故白名单里没有 .svg。 +var allowedUploadImageTypes = map[string]string{ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +} + +// uploadsCacheControl 命中随机文件名即可长缓存:同名文件内容不会变。 +const uploadsCacheControl = "public, max-age=86400" + +// uploadResult 是上传成功后的访问路径;path 与 url 同值,前端可直接拿去当 。 +type uploadResult struct { + Path string `json:"path"` + URL string `json:"url"` +} + +// uploadsDir 返回当前赛事项目的上传目录(赛事包导出会带上它,跨设备转移靠的就是这里)。 +// 未配置项目目录时没有落盘位置,返回 503 而不是 panic。 +func (s *server) uploadsDir() (string, error) { + dir := strings.TrimSpace(s.projectDir) + if dir == "" { + return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID", + message: "当前未配置赛事项目目录,无法上传图片"} + } + return filepath.Join(dir, "uploads"), nil +} + +// uploadHandler 保存 multipart file 字段里的图片。 +// 客户端文件名只用来取扩展名,落盘名是随机十六进制,避免重名覆盖与路径注入。 +func (s *server) uploadHandler(w http.ResponseWriter, r *http.Request) (any, error) { + dir, err := s.uploadsDir() + if err != nil { + return nil, err + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadFormBytes) + if err := r.ParseMultipartForm(maxUploadFormBytes); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return nil, badRequest("图片超过 5 MiB 上限", nil) + } + return nil, badRequest("上传内容解析失败", err.Error()) + } + defer func() { _ = r.MultipartForm.RemoveAll() }() + + file, header, err := r.FormFile("file") + if err != nil { + return nil, badRequest("缺少 file 字段", nil) + } + defer func() { _ = file.Close() }() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if _, ok := allowedUploadImageTypes[ext]; !ok { + return nil, badRequest("只支持 png/jpg/jpeg/webp/gif 图片", ext) + } + // header.Size 由客户端声明,不可信,按真实读到的字节数判限。 + body, err := io.ReadAll(io.LimitReader(file, maxUploadBytes+1)) + if err != nil { + return nil, badRequest("读取上传内容失败", err.Error()) + } + if len(body) > maxUploadBytes { + return nil, badRequest("图片超过 5 MiB 上限", nil) + } + if len(body) == 0 { + return nil, badRequest("上传内容为空", nil) + } + + name, err := newUploadName(ext) + if err != nil { + return nil, internal(err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, internal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil { + return nil, internal(err) + } + + s.audit(r.Context(), "uploads.create", "upload", 0, nil, + map[string]any{"file": name, "size": len(body)}) + path := "/uploads/" + name + return uploadResult{Path: path, URL: path}, nil +} + +// newUploadName 生成随机文件名:不用客户端文件名,既防重名覆盖也防路径注入。 +func newUploadName(ext string) (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf) + ext, nil +} + +// serveUpload 匿名提供上传目录里的图片:大屏与 不带会话,不能要求登录。 +func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + ctype, ok := uploadContentType(name) + if !ok { + writeAPIError(w, s.logger, notFound("图片不存在")) + return + } + dir, err := s.uploadsDir() + if err != nil { + writeAPIError(w, s.logger, notFound("图片不存在")) + return + } + // 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。 + file := filepath.Join(dir, filepath.Base(name)) + if info, err := os.Stat(file); err != nil || info.IsDir() { + writeAPIError(w, s.logger, notFound("图片不存在")) + return + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Cache-Control", uploadsCacheControl) + http.ServeFile(w, r, file) +} + +// uploadContentType 校验文件名并给出 Content-Type: +// 只接受「单层文件名 + 白名单扩展名」,含分隔符、.. 或只有扩展名的名字一律拒绝。 +func uploadContentType(name string) (string, bool) { + if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) { + return "", false + } + ext := strings.ToLower(filepath.Ext(name)) + if name == ext { + return "", false + } + ctype, ok := allowedUploadImageTypes[ext] + return ctype, ok +} diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index 972296e..f4789f6 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -8,14 +8,21 @@ import ( "io" "mime/multipart" "net/http" + "os" + "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) // importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。 -const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names" +const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names" + +// importHeaderNoAge 是升级前的 10 列表头,用来验证缺 age 列不报错也不误报冲突。 +const importHeaderNoAge = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names" // postImport 以 multipart 上传报名表;decisions 非空时作为 resolve 接口的 decisions 字段。 func (e *testEnv) postImport(t *testing.T, client *http.Client, path, content, decisions string) (int, envelope) { @@ -153,7 +160,7 @@ func TestChangePasswordRejectsTooLong(t *testing.T) { func TestImportCreatesAthleteWithIDCard(t *testing.T) { env, ids, client := recorderWithSeed(t) - body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n" + body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n" status, env2 := env.postImport(t, client, "/api/entries/import", body, "") if status != http.StatusOK || !env2.OK { t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) @@ -194,7 +201,7 @@ func TestImportCreatesAthleteWithIDCard(t *testing.T) { func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) { env, ids, client := recorderWithSeed(t) - body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n" + body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,,registered,\n" status, env2 := env.postImport(t, client, "/api/entries/import", body, "") if status != http.StatusOK || !env2.OK { t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) @@ -229,7 +236,7 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) { env, ids, client := recorderWithSeed(t) env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA) - body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n" + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" status, env2 := env.postImport(t, client, "/api/entries/import", body, "") if status != http.StatusOK || !env2.OK { t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) @@ -283,8 +290,8 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) { env, _, client := recorderWithSeed(t) body := importHeader + "\n" + - "M100,男子,A,张三,110101199001011234,男,,,registered,\n" + - "M100,男子,A,张三,110101199001011234,男,,,registered,\n" + "M100,男子,A,张三,110101199001011234,男,,,,registered,\n" + + "M100,男子,A,张三,110101199001011234,男,,,,registered,\n" status, env2 := env.postImport(t, client, "/api/entries/import", body, "") if status != http.StatusOK || !env2.OK { t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) @@ -307,22 +314,22 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) { } } -// 有 athlete_name 的行必须给 id_card。 -func TestImportRequiresIDCardWhenNameGiven(t *testing.T) { +// 有 athlete_name 的行必须给 id_card 或 bib,两个都缺才报错。 +func TestImportRequiresIDCardOrBibWhenNameGiven(t *testing.T) { env, _, client := recorderWithSeed(t) - body := importHeader + "\nM100,男子,A,张三,,男,,,registered,\n" + body := importHeader + "\nM100,男子,A,张三,,男,,,,registered,\n" status, env2 := env.postImport(t, client, "/api/entries/import", body, "") if status != http.StatusOK || !env2.OK { t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) } result := decodeData[map[string]any](t, env2) if result["created"] != float64(0) || result["skipped"] != float64(1) { - t.Fatalf("缺证件号应跳过: %+v", result) + t.Fatalf("缺证件号与号码布应跳过: %+v", result) } errs, _ := result["errors"].([]any) if len(errs) != 1 || !strings.Contains(errs[0].(string), "id_card") { - t.Fatalf("应报缺少 id_card,得到 %+v", errs) + t.Fatalf("应报缺少 id_card 或 bib,得到 %+v", errs) } var athletes int @@ -334,7 +341,7 @@ func TestImportRequiresIDCardWhenNameGiven(t *testing.T) { } } -// id_card / gender 是可选列:表头缺失不报错(团体行不涉及人员)。 +// id_card / gender / age 是可选列:表头缺失不报错(团体行不涉及人员)。 func TestImportToleratesMissingOptionalColumns(t *testing.T) { env, _, client := recorderWithSeed(t) @@ -355,7 +362,7 @@ func TestImportResolveKeepExisting(t *testing.T) { env, ids, client := recorderWithSeed(t) env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA) - body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n" + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" // 未给决策时,冲突原样返回。 status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]") @@ -410,7 +417,7 @@ func TestImportResolveKeepNew(t *testing.T) { env, ids, client := recorderWithSeed(t) env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA) - body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n" + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]` status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions) if status != http.StatusOK || !env2.OK { @@ -444,7 +451,7 @@ func TestImportResolveKeepNew(t *testing.T) { // decisions 非法时返回 400。 func TestImportResolveRejectsBadDecisions(t *testing.T) { env, _, client := recorderWithSeed(t) - body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n" + body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n" for _, decisions := range []string{"不是 JSON", `[{"idCard":"1","action":"keep-both"}]`} { status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions) @@ -453,3 +460,738 @@ func TestImportResolveRejectsBadDecisions(t *testing.T) { } } } + +// 只给号码布(没有证件号)也能新建人员,人员与报名都写入号码布。 +func TestImportCreatesAthleteWithBibOnly(t *testing.T) { + env, _, client := recorderWithSeed(t) + + body := importHeader + "\nM100,男子,A,赵六,,男,,B1001,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(1) || result["skipped"] != float64(0) { + t.Fatalf("仅给号码布应新建 1 条报名: %+v", result) + } + + var ( + idCard sql.NullString + bib string + ) + if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE name = '赵六'`).Scan(&idCard, &bib); err != nil { + t.Fatalf("查询新建人员: %v", err) + } + if idCard.Valid || bib != "B1001" { + t.Fatalf("人员应只有号码布: id_card=%+v bib=%s", idCard, bib) + } + + var entryBib sql.NullString + if err := env.ev.QueryRow( + `SELECT bib FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B1001')`).Scan(&entryBib); err != nil { + t.Fatalf("查询报名号码布: %v", err) + } + if !entryBib.Valid || entryBib.String != "B1001" { + t.Fatalf("报名也应写号码布: %+v", entryBib) + } +} + +// 号码布精确命中已有人员 → 视为同一人,只更新报名。 +func TestImportMatchesAthleteByBib(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["updated"] != float64(1) || result["created"] != float64(0) { + t.Fatalf("号码布命中应更新已有报名: %+v", result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("号码布命中不算冲突: %+v", conflicts) + } + + var athletes int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil { + t.Fatalf("统计人员: %v", err) + } + if athletes != 2 { + t.Fatalf("号码布命中不应新建人员,得到 %d 个", athletes) + } +} + +// 姓名 + 单位命中且库中证件号为空 → 回填号码布,不新建人员。 +func TestImportBackfillsBibForExistingAthlete(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["updated"] != float64(1) || result["created"] != float64(0) { + t.Fatalf("回填应更新已有报名: %+v", result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("回填不算冲突: %+v", conflicts) + } + + var bib sql.NullString + if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil { + t.Fatalf("查询人员号码布: %v", err) + } + if !bib.Valid || bib.String != "A100" { + t.Fatalf("应回填号码布,得到 %+v", bib) + } + + var athletes int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil { + t.Fatalf("统计人员: %v", err) + } + if athletes != 2 { + t.Fatalf("回填不应新建人员,得到 %d 个", athletes) + } +} + +// 号码布命中时,表里多给的证件号也回填进库中为空的人员。 +func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A100,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["updated"] != float64(1) || result["created"] != float64(0) { + t.Fatalf("号码布命中应更新已有报名: %+v", result) + } + + var idCard sql.NullString + if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil { + t.Fatalf("查询人员证件号: %v", err) + } + if !idCard.Valid || idCard.String != "110101199001011234" { + t.Fatalf("应回填证件号,得到 %+v", idCard) + } + + var athletes int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil { + t.Fatalf("统计人员: %v", err) + } + if athletes != 2 { + t.Fatalf("回填不应新建人员,得到 %d 个", athletes) + } +} + +// 同一文件里号码布重复出现 → 该行报错并跳过。 +func TestImportRejectsDuplicateBibInFile(t *testing.T) { + env, _, client := recorderWithSeed(t) + + body := importHeader + "\n" + + "M100,男子,A,张三,,男,,B2001,,registered,\n" + + "M100,男子,A,李四,,男,,B2001,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(1) || result["skipped"] != float64(1) { + t.Fatalf("首行应新建、次行应跳过: %+v", result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") { + t.Fatalf("应报号码布重复,得到 %+v", errs) + } + + var athletes int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE bib = 'B2001'`).Scan(&athletes); err != nil { + t.Fatalf("统计人员: %v", err) + } + if athletes != 1 { + t.Fatalf("重复号码布只应建 1 个人员,得到 %d", athletes) + } +} + +// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。 +func TestImportConflictIncludesBibDiff(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + conflicts, _ := result["conflicts"].([]any) + if len(conflicts) != 1 { + t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts) + } + conflict := conflicts[0].(map[string]any) + if conflict["bib"] != "A200" { + t.Fatalf("冲突定位应带表里的号码布: %+v", conflict) + } + changed, _ := conflict["changedFields"].([]any) + if len(changed) != 1 || changed[0] != "bib" { + t.Fatalf("应只有 bib 变化,得到 %+v", changed) + } + existing := conflict["existing"].(map[string]any) + if existing["bib"] != "A100" { + t.Fatalf("existing 快照应带库中号码布: %+v", existing) + } + incoming := conflict["incoming"].(map[string]any) + if incoming["bib"] != "A200" { + t.Fatalf("incoming 快照应带表里号码布: %+v", incoming) + } + + var bib string + if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil { + t.Fatalf("查询人员号码布: %v", err) + } + if bib != "A100" { + t.Fatalf("冲突不应改库中号码布,得到 %s", bib) + } +} + +// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,keep-new 用表里字段覆盖。 +func TestImportResolveConflictByBib(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟伟,,男,,A100,,registered,\n" + decisions := `[{"bib":"A100","action":"keep-new"}]` + status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions) + if status != http.StatusOK || !env2.OK { + t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["updated"] != float64(1) || result["created"] != float64(0) { + t.Fatalf("keep-new 应更新已有报名: %+v", result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("已决策的冲突不应再返回: %+v", conflicts) + } + + var name string + if err := env.ev.QueryRow(`SELECT name FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name); err != nil { + t.Fatalf("查询人员姓名: %v", err) + } + if name != "张伟伟" { + t.Fatalf("keep-new 应用表里的姓名覆盖人员,得到 %s", name) + } +} + +// 表里的 age 列写入新建人员。 +func TestImportCreatesAthleteWithAge(t *testing.T) { + env, _, client := recorderWithSeed(t) + + body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,23,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(1) || result["skipped"] != float64(0) { + t.Fatalf("应新建 1 条报名且无跳过: %+v", result) + } + + var age sql.NullInt64 + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&age); err != nil { + t.Fatalf("查询新建人员: %v", err) + } + if !age.Valid || age.Int64 != 23 { + t.Fatalf("应写入年龄 23,得到 %+v", age) + } +} + +// age 非整数或超出 0-120 → 该行报错并跳过,人员与报名都不落库。 +func TestImportRejectsInvalidAge(t *testing.T) { + env, _, client := recorderWithSeed(t) + + for _, age := range []string{"abc", "-1", "121", "30.5"} { + body := importHeader + "\nM100,男子,A,张三,110101199001011234,男," + age + ",,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(0) || result["skipped"] != float64(1) { + t.Fatalf("age=%s 应跳过该行: %+v", age, result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 1 || !strings.Contains(errs[0].(string), "age") { + t.Fatalf("age=%s 应报年龄非法,得到 %+v", age, errs) + } + } + + var athletes int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil { + t.Fatalf("统计人员: %v", err) + } + if athletes != 2 { + t.Fatalf("非法行不应建人员,得到 %d 个", athletes) + } +} + +// 姓名 + 单位命中且库中证件号为空 → 一并回填年龄,不新建人员。 +func TestImportBackfillsAgeForExistingAthlete(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,35,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["updated"] != float64(1) || result["created"] != float64(0) { + t.Fatalf("回填应更新已有报名: %+v", result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("回填不算冲突: %+v", conflicts) + } + + var age sql.NullInt64 + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil { + t.Fatalf("查询人员年龄: %v", err) + } + if !age.Valid || age.Int64 != 35 { + t.Fatalf("应回填年龄 35,得到 %+v", age) + } + + // 回填后年龄两边相同,再导入一次不算冲突。 + status, env2 = env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("二次导入应 200,得到 %d %+v", status, env2.Error) + } + result = decodeData[map[string]any](t, env2) + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("年龄相同不算冲突: %+v", conflicts) + } + if result["updated"] != float64(1) { + t.Fatalf("二次导入应更新已有报名: %+v", result) + } +} + +// 年龄两边都有且不同 → 冲突,快照与差异字段都带 age,本轮不落库。 +func TestImportConflictIncludesAgeDiff(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + conflicts, _ := result["conflicts"].([]any) + if len(conflicts) != 1 { + t.Fatalf("年龄不同应收集 1 条冲突,得到 %+v", conflicts) + } + conflict := conflicts[0].(map[string]any) + changed, _ := conflict["changedFields"].([]any) + if len(changed) != 1 || changed[0] != "age" { + t.Fatalf("应只有 age 变化,得到 %+v", changed) + } + if existing := conflict["existing"].(map[string]any); existing["age"] != float64(30) { + t.Fatalf("existing 快照应带库中年龄: %+v", existing) + } + if incoming := conflict["incoming"].(map[string]any); incoming["age"] != float64(26) { + t.Fatalf("incoming 快照应带表里年龄: %+v", incoming) + } + + var age sql.NullInt64 + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil { + t.Fatalf("查询人员年龄: %v", err) + } + if !age.Valid || age.Int64 != 30 { + t.Fatalf("冲突不应改库中年龄,得到 %+v", age) + } +} + +// 升级前的 10 列表头没有 age 列:不报错,也不能把已有年龄的人误报成冲突。 +func TestImportWithoutAgeColumnKeepsExistingAge(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA) + + body := importHeaderNoAge + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("缺 age 列应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("缺 age 列不该误报冲突: %+v", conflicts) + } + if result["updated"] != float64(1) { + t.Fatalf("缺 age 列应正常更新: %+v", result) + } + + var age sql.NullInt64 + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil { + t.Fatalf("查询人员年龄: %v", err) + } + if !age.Valid || age.Int64 != 30 { + t.Fatalf("缺 age 列不该清掉库中年龄,得到 %+v", age) + } +} + +// keep-new 只在表里给了年龄时才覆盖,缺列/空值不能清掉库中已有的年龄。 +func TestImportResolveKeepNewAgeOnlyOverwritesWhenGiven(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA) + + // 表里 age 为空:keep-new 覆盖单位与姓名,年龄保持库里的值。 + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" + decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]` + status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions) + if status != http.StatusOK || !env2.OK { + t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error) + } + if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("已决策的冲突不应再返回: %+v", conflicts) + } + + var age sql.NullInt64 + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil { + t.Fatalf("查询人员年龄: %v", err) + } + if !age.Valid || age.Int64 != 30 { + t.Fatalf("表里没给年龄时不该清掉库里年龄,得到 %+v", age) + } + + // 表里给了 age:keep-new 覆盖成表里的值。 + body = importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n" + if status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions); status != http.StatusOK || !env2.OK { + t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error) + } + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil { + t.Fatalf("查询人员年龄: %v", err) + } + if !age.Valid || age.Int64 != 26 { + t.Fatalf("keep-new 应用表里的年龄覆盖,得到 %+v", age) + } +} + +// ---------- 图片上传与静态访问 ---------- + +// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。 +func newUploadEnv(t *testing.T) (*testEnv, string) { + t.Helper() + projectDir := filepath.Join(t.TempDir(), "event_projs", "test") + env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir }) + return env, filepath.Join(projectDir, "uploads") +} + +// pngContent 是占位 PNG:服务端只按扩展名白名单放行,不解码图片内容。 +func pngContent() []byte { + return append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, []byte("fake-png-body")...) +} + +// postUpload 以 multipart 字段 file 上传到 /api/uploads。 +func (e *testEnv) postUpload(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) { + t.Helper() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("构造 multipart: %v", err) + } + if _, err := fw.Write(content); err != nil { + t.Fatalf("写入上传内容: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("关闭 multipart: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/uploads", &buf) + if err != nil { + t.Fatalf("构造请求: %v", err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + if token := e.csrfToken(client); token != "" { + req.Header.Set("X-CSRF-Token", token) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("上传图片: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var env envelope + raw, _ := io.ReadAll(resp.Body) + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("解析上传响应: %v (%s)", err, raw) + } + } + return resp.StatusCode, env +} + +type uploadResultBody struct { + Path string `json:"path"` + URL string `json:"url"` +} + +func uploadsDirEntries(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatalf("读取 uploads 目录: %v", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +// 上传的图片要真落盘到 /uploads,且匿名 能取到同样的内容(赛事包导出靠这个目录)。 +func TestUploadImageThenServeAnonymously(t *testing.T) { + env, uploadsDir := newUploadEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + content := pngContent() + status, body := env.postUpload(t, client, "赛事会标.png", content) + if status != http.StatusOK || !body.OK { + t.Fatalf("上传 png 应 200,得到 %d %+v", status, body.Error) + } + result := decodeData[uploadResultBody](t, body) + if result.Path != result.URL { + t.Fatalf("path 与 url 应一致,得到 %q / %q", result.Path, result.URL) + } + if !strings.HasPrefix(result.Path, "/uploads/") || !strings.HasSuffix(result.Path, ".png") { + t.Fatalf("返回路径应形如 /uploads/xxx.png,得到 %q", result.Path) + } + // 落盘名不能用客户端文件名,否则重名会互相覆盖、也会带出路径注入。 + if strings.Contains(result.Path, "赛事会标") { + t.Fatalf("落盘名应为随机名,得到 %q", result.Path) + } + + stored := filepath.Join(uploadsDir, filepath.Base(result.Path)) + got, err := os.ReadFile(stored) + if err != nil { + t.Fatalf("上传文件未落在 projectDir/uploads: %v", err) + } + if !bytes.Equal(got, content) { + t.Fatalf("落盘内容与上传内容不一致: %q", got) + } + + var audits int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.create'`).Scan(&audits); err != nil { + t.Fatalf("查询上传审计: %v", err) + } + if audits != 1 { + t.Fatalf("上传应写一条 uploads.create 审计,得到 %d", audits) + } + + // 大屏与 不带会话:匿名 GET 必须 200。 + resp, err := http.Get(env.server.URL + result.Path) + if err != nil { + t.Fatalf("匿名取图: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("匿名取图应 200,得到 %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "image/png" { + t.Fatalf("Content-Type 应为 image/png,得到 %q", ct) + } + if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "max-age") { + t.Fatalf("随机文件名应可长缓存,得到 Cache-Control %q", cc) + } + served, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("读取图片响应: %v", err) + } + if !bytes.Equal(served, content) { + t.Fatalf("静态响应内容与上传内容不一致: %q", served) + } +} + +// 只收位图:SVG 可内嵌脚本,匿名同源访问等于存储型 XSS 入口。 +func TestUploadRejectsUnsupportedImageType(t *testing.T) { + env, uploadsDir := newUploadEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + cases := []struct { + name string + content []byte + }{ + {"logo.svg", []byte(``)}, + {"logo.txt", []byte("not an image")}, + {"logo.png.exe", []byte("not an image")}, + } + for _, c := range cases { + status, body := env.postUpload(t, client, c.name, c.content) + if status != http.StatusBadRequest || body.OK { + t.Fatalf("上传 %s 应 400,得到 %d %+v", c.name, status, body.Error) + } + } + if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 { + t.Fatalf("被拒的上传不该落盘,实际有 %v", names) + } +} + +// 单文件上限 5 MiB:略超与远超都要 400,且不落盘。 +func TestUploadRejectsOversizeImage(t *testing.T) { + env, uploadsDir := newUploadEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + for _, size := range []int{5<<20 + 1, 8 << 20} { + status, body := env.postUpload(t, client, "big.png", bytes.Repeat([]byte{'a'}, size)) + if status != http.StatusBadRequest || body.OK { + t.Fatalf("%d 字节应 400,得到 %d %+v", size, status, body.Error) + } + if !strings.Contains(body.Error.Message, "5 MiB") { + t.Fatalf("超限提示应说明上限,得到 %q", body.Error.Message) + } + } + if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 { + t.Fatalf("超限上传不该落盘,实际有 %v", names) + } +} + +// 上传是写操作:未登录(guest)一律 401 AUTH_REQUIRED,与其它写接口口径一致。 +func TestUploadRequiresRecorder(t *testing.T) { + env, uploadsDir := newUploadEnv(t) + client, _ := env.client() + + status, body := env.postUpload(t, client, "logo.png", pngContent()) + if status != http.StatusUnauthorized { + t.Fatalf("未登录上传应 401,得到 %d %+v", status, body.Error) + } + if body.Error == nil || body.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("错误码应为 AUTH_REQUIRED,得到 %+v", body.Error) + } + if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 { + t.Fatalf("未登录上传不该落盘,实际有 %v", names) + } +} + +// 没配置赛事项目目录时没有落盘位置,明确报 503 而不是 panic。 +func TestUploadWithoutProjectDir(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, body := env.postUpload(t, client, "logo.png", pngContent()) + if status != http.StatusServiceUnavailable { + t.Fatalf("未配置 ProjectDir 应 503,得到 %d %+v", status, body.Error) + } +} + +// 静态访问只认「单层文件名 + 白名单扩展名」,任何穿越写法都不能读到 uploads 之外的文件。 +func TestUploadStaticRejectsTraversal(t *testing.T) { + env, uploadsDir := newUploadEnv(t) + root := filepath.Dir(filepath.Dir(uploadsDir)) // projectDir 的上一级(event_projs) + const secret = "top-secret-content" + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("创建 uploads 之外的目录: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(secret), 0o644); err != nil { + t.Fatalf("写 uploads 之外的文件: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "..", "secret.txt"), []byte(secret), 0o644); err != nil { + t.Fatalf("写 uploads 之外的文件: %v", err) + } + + // 不跟随重定向:要看到服务端真正给出的状态码。 + client := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + paths := []string{ + "/uploads/..%2F..%2Fconfig.yaml", + "/uploads/..%2F..%2F..%2Fsecret.txt", + "/uploads/%2e%2e%2Fconfig.yaml", + "/uploads/a/b.png", + "/uploads/..%2Fconfig.yaml", + } + for _, path := range paths { + resp, err := client.Get(env.server.URL + path) + if err != nil { + t.Fatalf("请求 %s: %v", path, err) + } + raw, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest { + t.Fatalf("%s 应 404/400,得到 %d", path, resp.StatusCode) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("%s 读到了 uploads 之外的文件: %s", path, raw) + } + } +} + +// 名字合法但文件不存在时返回 404,不能把目录列表或空图当成功。 +func TestUploadStaticMissingFile(t *testing.T) { + env, _ := newUploadEnv(t) + resp, err := http.Get(env.server.URL + "/uploads/0123456789abcdef0123456789abcdef.png") + if err != nil { + t.Fatalf("请求不存在的图片: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("不存在的图片应 404,得到 %d", resp.StatusCode) + } +} + +// 上传返回的路径要能直接写进赛事配置,且大屏匿名取图链路通(会标/背景的实际用法)。 +func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) { + env, _ := newUploadEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + status, body := env.postUpload(t, client, "logo.png", pngContent()) + if status != http.StatusOK || !body.OK { + t.Fatalf("上传应 200,得到 %d %+v", status, body.Error) + } + path := decodeData[uploadResultBody](t, body).Path + + status, body = env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "event": map[string]any{"logoPath": path}, + "display": map[string]any{"logo": path, "background": path}, + }, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("把上传路径写进配置应 200,得到 %d %+v", status, body.Error) + } + + status, body = env.do(client, http.MethodGet, "/api/settings", nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("读取配置应 200,得到 %d %+v", status, body.Error) + } + settings := decodeData[dto.EventSettings](t, body) + if settings.Event.LogoPath == nil || *settings.Event.LogoPath != path { + t.Fatalf("event.logoPath 应保存上传路径 %q,得到 %+v", path, settings.Event.LogoPath) + } + if settings.Display.Logo == nil || *settings.Display.Logo != path { + t.Fatalf("display.logo 应保存上传路径 %q,得到 %+v", path, settings.Display.Logo) + } + + // 大屏用的就是这个路径,匿名 GET 必须直接取到图。 + resp, err := http.Get(env.server.URL + path) + if err != nil { + t.Fatalf("匿名取图: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode) + } +} diff --git a/server/internal/httpapi/handlers_system.go b/server/internal/httpapi/handlers_system.go index 242c44d..b56eb9c 100644 --- a/server/internal/httpapi/handlers_system.go +++ b/server/internal/httpapi/handlers_system.go @@ -25,6 +25,8 @@ func (s *server) systemRoutes(mux *http.ServeMux) { func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) { return map[string]any{ "internalSignature": version.InternalSignature, + // 构建时间同样是编译期注入的,未注入时为空串,由前端显示「未注入」。 + "buildTime": version.BuildTime, }, nil } diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index a1ec6af..fdf1bbd 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -287,18 +287,20 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error { // ---------- athletes ---------- -const athleteColumns = `id, name, gender, team_id, employee_no, id_card, active` +const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active` func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) { var ( - a athleteDTO - gender, empNo, idCard sql.NullString - teamID sql.NullInt64 + a athleteDTO + gender, empNo, idCard, bib sql.NullString + teamID, age sql.NullInt64 ) - err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &a.Active) + err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age, &a.Active) a.Gender = strPtr(gender) a.EmployeeNo = strPtr(empNo) a.IDCard = strPtr(idCard) + a.Bib = strPtr(bib) + a.Age = intPtr(age) if teamID.Valid { a.TeamID = teamID.Int64 } @@ -314,6 +316,14 @@ func isIDCardConflict(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card") } +// isBibConflict 判断错误是否来自 athletes.bib 的唯一索引,文案要与证件号冲突区分开。 +func isBibConflict(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "athletes.bib") +} + func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) { query := `SELECT ` + athleteColumns + ` FROM athletes` args := []any{} @@ -351,30 +361,36 @@ func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) { return a, nil } +// validateAthleteAge 年龄可以留空,填了就必须落在 0–120(JSON 已保证是整数)。 +func validateAthleteAge(age *int) error { + if age != nil && (*age < 0 || *age > 120) { + return badRequest("age 需要在 0-120 之间", nil) + } + return nil +} + func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) { if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 { return athleteDTO{}, badRequest("name 与 teamId 必填", nil) } - // 证件号是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。 - if in.IDCard != nil && *in.IDCard != "" { - dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? LIMIT 1`, *in.IDCard) - if err != nil { - return athleteDTO{}, internal(err) - } - if dup { - return athleteDTO{}, conflict("证件号已被其它人员使用") - } + if err := validateAthleteAge(in.Age); err != nil { + return athleteDTO{}, err + } + // 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。 + if err := s.checkAthleteIdentity(ctx, in, 0); err != nil { + return athleteDTO{}, err } res, err := s.ev.ExecContext(ctx, - `INSERT INTO athletes (name, gender, team_id, employee_no, id_card, active) VALUES (?, ?, ?, ?, ?, ?)`, - in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active) - if isIDCardConflict(err) { + `INSERT INTO athletes (name, gender, team_id, employee_no, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active) + switch { + case isIDCardConflict(err): return athleteDTO{}, conflict("证件号已被其它人员使用") - } - if isForeignKeyViolation(err) { + case isBibConflict(err): + return athleteDTO{}, conflict("号码布已被其它人员使用") + case isForeignKeyViolation(err): return athleteDTO{}, badRequest("teamId 不存在", nil) - } - if err != nil { + case err != nil: return athleteDTO{}, internal(err) } id, err := lastInsertID(res) @@ -384,26 +400,47 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, return s.getAthlete(ctx, id) } -func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) { +// checkAthleteIdentity 校验证件号/号码布是否已被他人占用(id 为 0 表示新建)。 +func (s *server) checkAthleteIdentity(ctx context.Context, in athleteDTO, id int64) error { if in.IDCard != nil && *in.IDCard != "" { dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id) if err != nil { - return athleteDTO{}, internal(err) + return internal(err) } if dup { - return athleteDTO{}, conflict("证件号已被其它人员使用") + return conflict("证件号已被其它人员使用") } } + if in.Bib != nil && *in.Bib != "" { + dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE bib = ? AND id <> ? LIMIT 1`, *in.Bib, id) + if err != nil { + return internal(err) + } + if dup { + return conflict("号码布已被其它人员使用") + } + } + return nil +} + +func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) { + if err := validateAthleteAge(in.Age); err != nil { + return athleteDTO{}, err + } + if err := s.checkAthleteIdentity(ctx, in, id); err != nil { + return athleteDTO{}, err + } res, err := s.ev.ExecContext(ctx, - `UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, active = ? WHERE id = ?`, - in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active, id) - if isIDCardConflict(err) { + `UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, bib = ?, age = ?, active = ? WHERE id = ?`, + in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active, id) + switch { + case isIDCardConflict(err): return athleteDTO{}, conflict("证件号已被其它人员使用") - } - if isForeignKeyViolation(err) { + case isBibConflict(err): + return athleteDTO{}, conflict("号码布已被其它人员使用") + case isForeignKeyViolation(err): return athleteDTO{}, badRequest("teamId 不存在", nil) - } - if err != nil { + case err != nil: return athleteDTO{}, internal(err) } if err := requireChanges(res, "运动员不存在"); err != nil { diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index b22a5ad..4cce0dc 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -14,7 +14,7 @@ func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64 return id, nil } -// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空)。 +// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空,Age 为空表示表里没填)。 type athleteImportRow struct { Line int Name string @@ -22,6 +22,16 @@ type athleteImportRow struct { TeamID int64 TeamCode string IDCard string + Bib string + Age *int +} + +// decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。 +func (in athleteImportRow) decisionKey() string { + if in.IDCard != "" { + return in.IDCard + } + return in.Bib } // athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。 @@ -38,91 +48,133 @@ type existingAthlete struct { Gender *string TeamID int64 EmployeeNo *string + IDCard *string + Bib *string + Age *int } -// matchImportedAthlete 按契约顺序匹配人员:证件号 → 姓名+单位(库中证件号为空则回填)→ 新建。 -// 证件号命中但姓名/性别/单位与库中不一致时记为冲突;decisions 里对该证件号有决策时按决策落库。 +// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。 +// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。 func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { - existing, err := s.athleteByIDCard(ctx, in.IDCard) + existing, err := s.athleteByIdentity(ctx, in) switch { case err == nil: - changed := changedAthleteFields(existing, in) - if len(changed) == 0 { - return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil - } - conflict, err := s.importConflictFor(ctx, in, existing, changed) - if err != nil { - return athleteImportMatch{}, err - } - action, decided := decisions[in.IDCard] - switch { - case !decided: - return athleteImportMatch{Conflict: &conflict}, nil - case action == decisionKeepExisting: - // 人员字段保持库里的值,报名也跟随库里的单位。 - return athleteImportMatch{AthleteID: existing.ID, TeamID: existing.TeamID}, nil - default: - if err := s.overwriteAthleteFromImport(ctx, existing.ID, in); err != nil { - return athleteImportMatch{}, err - } - return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil - } + return s.matchExistingAthlete(ctx, in, existing, decisions) case errors.Is(err, sql.ErrNoRows): - return s.matchOrCreateAthleteByName(ctx, in) + return s.matchOrCreateAthleteByName(ctx, in, decisions) default: return athleteImportMatch{}, internal(err) } } -// matchOrCreateAthleteByName 处理证件号未命中的情况: -// 姓名+单位命中且库中证件号为空 → 视为同一人并回填证件号;否则新建人员。 -func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) { - var id int64 - err := s.ev.QueryRowContext(ctx, - `SELECT id FROM athletes WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '') - ORDER BY id LIMIT 1`, in.Name, in.TeamID).Scan(&id) +// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。 +func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) { + if in.IDCard != "" { + ex, err := s.athleteByIDCard(ctx, in.IDCard) + if !errors.Is(err, sql.ErrNoRows) { + return ex, err + } + } + if in.Bib != "" { + return s.athleteByBib(ctx, in.Bib) + } + return existingAthlete{}, sql.ErrNoRows +} + +// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段, +// 有差异时走冲突流程,decisions 里对该身份有决策才落库。 +func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) { + changed := changedAthleteFields(ex, in) + if len(changed) == 0 { + if err := s.backfillEmptyAthleteFields(ctx, ex, in); err != nil { + return athleteImportMatch{}, err + } + return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil + } + conflict, err := s.importConflictFor(ctx, in, ex, changed) + if err != nil { + return athleteImportMatch{}, err + } + action, decided := decisions[in.decisionKey()] + switch { + case !decided: + return athleteImportMatch{Conflict: &conflict}, nil + case action == decisionKeepExisting: + // 人员字段保持库里的值,报名也跟随库里的单位。 + return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID}, nil + default: + if err := s.overwriteAthleteFromImport(ctx, ex.ID, in); err != nil { + return athleteImportMatch{}, err + } + return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil + } +} + +// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况: +// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。 +func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { + existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID) switch { case err == nil: - if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, id); err != nil { - return athleteImportMatch{}, internal(err) - } - return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil + return s.matchExistingAthlete(ctx, in, existing, decisions) case !errors.Is(err, sql.ErrNoRows): return athleteImportMatch{}, internal(err) } res, err := s.ev.ExecContext(ctx, - `INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES (?, ?, ?, ?, 1)`, - in.Name, nullStr(&in.Gender), in.TeamID, in.IDCard) + `INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`, + in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age)) if err != nil { return athleteImportMatch{}, internal(err) } - id, err = lastInsertID(res) + id, err := lastInsertID(res) if err != nil { return athleteImportMatch{}, internal(err) } return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil } -func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) { +// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。 +const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age` + +func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete, error) { var ( - a existingAthlete - gender, empNo sql.NullString - teamID sql.NullInt64 + a existingAthlete + gender, empNo, idCard, bib sql.NullString + teamID, age sql.NullInt64 ) - err := s.ev.QueryRowContext(ctx, - `SELECT id, name, gender, team_id, employee_no FROM athletes WHERE id_card = ? LIMIT 1`, idCard). - Scan(&a.ID, &a.Name, &gender, &teamID, &empNo) - if err != nil { + if err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age); err != nil { return existingAthlete{}, err } a.Gender = strPtr(gender) a.EmployeeNo = strPtr(empNo) + a.IDCard = strPtr(idCard) + a.Bib = strPtr(bib) + a.Age = intPtr(age) a.TeamID = teamID.Int64 return a, nil } +func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) { + return scanExistingAthlete(s.ev.QueryRowContext(ctx, + `SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard)) +} + +func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) { + return scanExistingAthlete(s.ev.QueryRowContext(ctx, + `SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib)) +} + +// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。 +func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) { + return scanExistingAthlete(s.ev.QueryRowContext(ctx, + `SELECT `+importAthleteColumns+` FROM athletes + WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '') + ORDER BY id LIMIT 1`, name, teamID)) +} + // changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。 +// 证件号/号码布只在两边都有值且不同时算冲突;库中为空属于待回填,不算冲突。 func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string { var changed []string if ex.Name != in.Name { @@ -132,12 +184,47 @@ func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string { if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) { changed = append(changed, "gender") } + // 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。 + if in.Age != nil && ex.Age != nil && *ex.Age != *in.Age { + changed = append(changed, "age") + } if ex.TeamID != in.TeamID { changed = append(changed, "teamCode") } + if identityDiffers(ex.IDCard, in.IDCard) { + changed = append(changed, "idCard") + } + if identityDiffers(ex.Bib, in.Bib) { + changed = append(changed, "bib") + } return changed } +// identityDiffers 判断身份字段(证件号/号码布)是否冲突:两边都有值且不同才算。 +func identityDiffers(dbVal *string, inVal string) bool { + return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal +} + +// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。 +func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error { + if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") { + if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil { + return internal(err) + } + } + if in.Bib != "" && (ex.Bib == nil || *ex.Bib == "") { + if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET bib = ? WHERE id = ?`, in.Bib, ex.ID); err != nil { + return internal(err) + } + } + if in.Age != nil && ex.Age == nil { + if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET age = ? WHERE id = ?`, *in.Age, ex.ID); err != nil { + return internal(err) + } + } + return nil +} + func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string) (importConflict, error) { existingCode, err := s.teamCodeByID(ctx, ex.TeamID) if err != nil { @@ -146,12 +233,15 @@ func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex return importConflict{ Row: in.Line, IDCard: in.IDCard, + Bib: in.Bib, AthleteID: ex.ID, Existing: importAthleteView{ Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo, + IDCard: ex.IDCard, Bib: ex.Bib, Age: ex.Age, }, Incoming: importAthleteView{ Name: in.Name, Gender: nilIfEmpty(in.Gender), TeamCode: in.TeamCode, + IDCard: nilIfEmpty(in.IDCard), Bib: nilIfEmpty(in.Bib), Age: in.Age, }, ChangedFields: changed, }, nil @@ -169,11 +259,12 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) { } // overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。 -// 表里没填性别时保留库里的值,避免决策后反而丢字段。 +// 表里没填的字段保留库里的值,避免决策后反而丢字段。 func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error { if _, err := s.ev.ExecContext(ctx, - `UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender) WHERE id = ?`, - in.Name, in.TeamID, nullStr(&in.Gender), id); err != nil { + `UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender), + id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`, + in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil { return internal(err) } return nil diff --git a/server/internal/httpapi/repo_test.go b/server/internal/httpapi/repo_test.go index 95e75fc..53a4d98 100644 --- a/server/internal/httpapi/repo_test.go +++ b/server/internal/httpapi/repo_test.go @@ -1,8 +1,10 @@ package httpapi_test import ( + "database/sql" "fmt" "net/http" + "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" @@ -140,3 +142,141 @@ func TestAthleteIDCardUnique(t *testing.T) { t.Fatalf("读人员应带 idCard,得到 %d %+v", status, got) } } + +// 号码布与证件号同为唯一标识:可读写、重复 409(文案区分字段)、空值不互斥。 +func TestAthleteBibUnique(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "张三", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("仅给号码布应能建人员,得到 %d %+v", status, env2.Error) + } + created := decodeData[map[string]any](t, env2) + if created["bib"] != "A001" { + t.Fatalf("建人员应返回 bib: %+v", created) + } + athleteID := int64(created["id"].(float64)) + + status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "李四", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true, + }, true) + if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" { + t.Fatalf("重复号码布应 409 CONFLICT,得到 %d %+v", status, env2.Error) + } + if !strings.Contains(env2.Error.Message, "号码布") { + t.Fatalf("409 文案应点明号码布,得到 %+v", env2.Error) + } + + status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{ + "name": "李强", "gender": "男", "teamId": ids.teamB, "bib": "A001", "active": true, + }, true) + if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" { + t.Fatalf("改人员到重复号码布应 409,得到 %d %+v", status, env2.Error) + } + + // 空号码布是合法的,多条空值不能互相冲突;唯一索引兜底重复写入。 + status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "王五", "gender": "女", "teamId": ids.teamA, "active": true, + }, true) + if status != http.StatusOK { + t.Fatalf("空号码布应可建,得到 %d %+v", status, env2.Error) + } + env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA) + env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA) + if _, err := env.ev.Exec( + `INSERT INTO athletes (name, team_id, bib, active) VALUES ('丙', ?, 'A001', 1)`, ids.teamA); err == nil { + t.Fatalf("唯一索引应拒绝重复号码布") + } + + status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false) + got := decodeData[map[string]any](t, env2) + if status != http.StatusOK || got["bib"] != "A001" { + t.Fatalf("读人员应带 bib,得到 %d %+v", status, got) + } +} + +// 年龄可读可写;越界或非整数返回 400,不填时为空。 +func TestAthleteAgeReadWriteAndRange(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "张三", "gender": "男", "teamId": ids.teamA, "age": 30, "active": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error) + } + created := decodeData[map[string]any](t, env2) + if created["age"] != float64(30) { + t.Fatalf("建人员应返回 age: %+v", created) + } + athleteID := int64(created["id"].(float64)) + + status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false) + if got := decodeData[map[string]any](t, env2); status != http.StatusOK || got["age"] != float64(30) { + t.Fatalf("读人员应带 age,得到 %d %+v", status, got) + } + + // 边界值 0 与 120 合法。 + status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{ + "name": "张三", "gender": "男", "teamId": ids.teamA, "age": 0, "active": true, + }, true) + if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(0) { + t.Fatalf("age=0 应可保存,得到 %d %+v", status, env2.Error) + } + status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{ + "name": "张三", "gender": "男", "teamId": ids.teamA, "age": 120, "active": true, + }, true) + if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(120) { + t.Fatalf("age=120 应可保存,得到 %d %+v", status, env2.Error) + } + + for _, age := range []int{-1, 121} { + status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{ + "name": "张三", "gender": "男", "teamId": ids.teamA, "age": age, "active": true, + }, true) + if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("age=%d 应 400,得到 %d %+v", age, status, env2.Error) + } + } + status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "李四", "gender": "男", "teamId": ids.teamA, "age": 121, "active": true, + }, true) + if status != http.StatusBadRequest { + t.Fatalf("新建年龄越界应 400,得到 %d %+v", status, env2.Error) + } + status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "赵六", "gender": "男", "teamId": ids.teamA, "age": "abc", "active": true, + }, true) + if status != http.StatusBadRequest { + t.Fatalf("年龄非整数应 400,得到 %d %+v", status, env2.Error) + } + + // 不填年龄是合法的,返回空值。 + status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{ + "name": "王五", "gender": "女", "teamId": ids.teamA, "active": true, + }, true) + if status != http.StatusOK { + t.Fatalf("不填年龄应可建,得到 %d %+v", status, env2.Error) + } + if created = decodeData[map[string]any](t, env2); created["age"] != nil { + t.Fatalf("没填年龄应返回 null: %+v", created) + } + + var age sql.NullInt64 + if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, athleteID).Scan(&age); err != nil { + t.Fatalf("查询人员年龄: %v", err) + } + if !age.Valid || age.Int64 != 120 { + t.Fatalf("越界的 400 不该改库中年龄,得到 %+v", age) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 8a9ddb5..9b1133e 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -249,6 +249,11 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/revisions", s.read(auth.RoleRecorder, s.listRevisionsHandler)) mux.HandleFunc("GET /api/revisions/{id}", s.read(auth.RoleRecorder, s.getRevisionDetailHandler)) + // 图片上传与静态访问(会标/背景/单位图标)。上传落 /uploads/,随赛事包导出; + // 静态访问要匿名放行,大屏的 才能不带会话直接取图。 + mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler)) + mux.HandleFunc("GET /uploads/{name}", s.serveUpload) + // 由其他模块负责的接口:先占位,避免落到静态资源处理器。 mux.HandleFunc("/api/export/", s.notWired("导出")) mux.HandleFunc("/api/rules/", s.notWired("规则导入导出")) diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index 5ff4b25..8713473 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -9,6 +9,7 @@ import ( "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/version" ) // waitClosed 等待关闭回调触发;没触发就报错。 @@ -133,6 +134,51 @@ func TestShutdownByToken(t *testing.T) { } } +// GET /api/system/about:版本包里的 buildTime 原样返回;未注入时是空串(前端据此显示「未注入」)。 +// 顺带锁住权限:guest 依旧读不到(该端点靠它取内部署名)。 +func TestSystemAboutReturnsBuildTime(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + + guest, _ := env.client() + if status, _ := env.do(guest, http.MethodGet, "/api/system/about", nil, false); status != http.StatusForbidden { + t.Fatalf("guest 读 about 应 403,得到 %d", status) + } + + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 模拟 ldflags 注入:直接改包级变量,用例结束后恢复,避免影响其他用例。 + original := version.BuildTime + t.Cleanup(func() { version.BuildTime = original }) + + version.BuildTime = "2026-09-10 08:50:12 +08:00" + status, env2 := env.do(client, http.MethodGet, "/api/system/about", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("recorder 读 about 应 200,得到 %d %+v", status, env2.Error) + } + var about map[string]any + if err := json.Unmarshal(env2.Data, &about); err != nil { + t.Fatalf("解析 about: %v (%s)", err, env2.Data) + } + if about["buildTime"] != "2026-09-10 08:50:12 +08:00" { + t.Fatalf("buildTime 应原样返回注入值,得到 %#v", about["buildTime"]) + } + if _, ok := about["internalSignature"]; !ok { + t.Fatalf("about 仍应带 internalSignature: %v", about) + } + + // 未注入时字段仍在、值为空串,前端才能稳定判断「未注入」而不是解析失败。 + version.BuildTime = "" + _, env2 = env.do(client, http.MethodGet, "/api/system/about", nil, false) + if err := json.Unmarshal(env2.Data, &about); err != nil { + t.Fatalf("解析 about: %v", err) + } + if got, ok := about["buildTime"]; !ok || got != "" { + t.Fatalf("未注入时 buildTime 应为空串,得到 %#v(存在=%v)", got, ok) + } +} + // GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。 func TestRuleSwitchesReadableByRecorder(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/version/version.go b/server/internal/version/version.go index 996a699..e9040c4 100644 --- a/server/internal/version/version.go +++ b/server/internal/version/version.go @@ -6,3 +6,10 @@ package version // 源码里必须永远保持为空字符串:真实署名只存在于构建机的 deploy/signature.local.txt // (该文件已 gitignore),因此公开仓库里既没有值、也改不动它。 var InternalSignature = "" + +// BuildTime 是构建时间,**由构建脚本用 -ldflags 注入**,编译期写进二进制。 +// +// 源码里必须永远保持为空字符串:实际值由 deploy/build.bat 在构建时用 +// -X .../internal/version.BuildTime=<时间戳> 填进来。没注入(例如直接 go build)时为空, +// 管理端「关于」页据此显示「未注入」,不编造时间。 +var BuildTime = "" diff --git a/server/migrations/event/0014_athletes_bib.sql b/server/migrations/event/0014_athletes_bib.sql new file mode 100644 index 0000000..c172f15 --- /dev/null +++ b/server/migrations/event/0014_athletes_bib.sql @@ -0,0 +1,6 @@ +-- 号码布与证件号同为人员的唯一标识(二选一即可)。历史数据没有号码布,允许为空; +-- 用部分唯一索引只约束「填了号码布」的行,空值可以有多条。 +ALTER TABLE athletes ADD COLUMN bib TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_athletes_bib + ON athletes(bib) WHERE bib IS NOT NULL AND bib <> ''; diff --git a/server/migrations/event/0015_athletes_age.sql b/server/migrations/event/0015_athletes_age.sql new file mode 100644 index 0000000..f9de0ab --- /dev/null +++ b/server/migrations/event/0015_athletes_age.sql @@ -0,0 +1,2 @@ +-- 年龄是人员的补充信息,不参与唯一标识。历史数据没有年龄,允许为空。 +ALTER TABLE athletes ADD COLUMN age INTEGER; diff --git a/web/src/admin/components/baseData/AthletesSection.vue b/web/src/admin/components/baseData/AthletesSection.vue index 70db797..0736963 100644 --- a/web/src/admin/components/baseData/AthletesSection.vue +++ b/web/src/admin/components/baseData/AthletesSection.vue @@ -14,9 +14,12 @@ const toast = useToastStore() interface AthleteForm { name: string gender: '' | Gender + // type="number" 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),这里如实标注 + age: string | number teamId: string employeeNo: string idCard: string + bib: string active: boolean } @@ -38,7 +41,21 @@ const confirmTarget = ref(null) const confirmPending = ref(false) function emptyForm(): AthleteForm { - return { name: '', gender: '', teamId: '', employeeNo: '', idCard: '', active: true } + return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true } +} + +/** + * 年龄取值。 + * + * `type="number"` 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),所以不能直接 `.trim()`: + * 填了数字后字段是 number,`.trim()` 抛 TypeError,点击处理器中断,表现为「点了没反应」。 + * 非法输入返回 NaN,交给 validate 标红。 + */ +function toAge(value: string | number): number | null { + const text = String(value).trim() + if (text === '') return null + const parsed = Number(text) + return Number.isFinite(parsed) ? parsed : Number.NaN } const filtered = computed(() => { @@ -53,6 +70,7 @@ const filtered = computed(() => { athlete.name.toLowerCase().includes(keyword) || (athlete.employeeNo ?? '').toLowerCase().includes(keyword) || (athlete.idCard ?? '').toLowerCase().includes(keyword) || + (athlete.bib ?? '').toLowerCase().includes(keyword) || (team?.name ?? '').toLowerCase().includes(keyword) ) }) @@ -74,9 +92,11 @@ function openEdit(athlete: Athlete): void { form.value = { name: athlete.name, gender: athlete.gender ?? '', + age: athlete.age === null ? '' : String(athlete.age), teamId: String(athlete.teamId), employeeNo: athlete.employeeNo ?? '', idCard: athlete.idCard ?? '', + bib: athlete.bib ?? '', active: athlete.active, } errors.value = {} @@ -90,6 +110,8 @@ function validate(): boolean { if (form.value.teamId === '' || data.teamById(Number(form.value.teamId)) === undefined) { next.teamId = '必须选择一个参赛单位' } + const age = toAge(form.value.age) + if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) next.age = '年龄为 0–120 的整数' errors.value = next return Object.keys(next).length === 0 } @@ -101,9 +123,11 @@ async function submit(): Promise { const input: AthleteInput = { name: form.value.name.trim(), gender: form.value.gender === '' ? null : form.value.gender, + age: toAge(form.value.age), teamId: Number(form.value.teamId), employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(), idCard: form.value.idCard.trim() === '' ? null : form.value.idCard.trim(), + bib: form.value.bib.trim() === '' ? null : form.value.bib.trim(), active: form.value.active, } try { @@ -133,9 +157,11 @@ async function toggleActive(athlete: Athlete): Promise { { name: athlete.name, gender: athlete.gender, + age: athlete.age, teamId: athlete.teamId, employeeNo: athlete.employeeNo, idCard: athlete.idCard, + bib: athlete.bib, active: !athlete.active, }, athlete.id, @@ -162,7 +188,7 @@ async function toggleActive(athlete: Athlete): Promise {
+ + + - + + + + diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index ea0ecb6..4282289 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -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 { @@ -138,7 +140,7 @@ async function submit(): Promise { 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 { v-model="checked" type="checkbox" :value="athlete.id" - :disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)" + :disabled="!data.canWrite.value || isRegistered(athlete) || missingIdentifier(athlete)" /> {{ athlete.name }} - {{ athlete.employeeNo ?? '—' }} + {{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }} 已报名 - 缺证件号 + 缺证件号/号码布
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }} @@ -243,8 +245,8 @@ async function submit(): Promise {

-

号码布、种子等字段仍通过下方报名表导入填写。

-

证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。

+

报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。

+

证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。

{{ error }}
diff --git a/web/src/admin/components/baseData/ImportSection.vue b/web/src/admin/components/baseData/ImportSection.vue index f8dc55f..c5f13e8 100644 --- a/web/src/admin/components/baseData/ImportSection.vue +++ b/web/src/admin/components/baseData/ImportSection.vue @@ -8,6 +8,8 @@ import { useToastStore } from '../../stores/toast' import { REQUIRED_COLUMNS, conflictDiffs, + conflictLabel, + decisionKeyOf, decisionsPayload, pruneDecisions, recordDecisions, @@ -20,8 +22,9 @@ const toast = useToastStore() const TEMPLATE_CSV = [ REQUIRED_COLUMNS.join(','), - 'M100,男子组,F1,张伟,110101199001011234,男,101,,registered,', - 'BB,混合组,F2,,,,,种子1,registered,第一分公司代表队', + 'M100,男子组,F1,张伟,110101199001011234,男,26,101,,registered,', + 'W100,女子组,F2,王芳,,女,,202,,registered,', + 'BB,混合组,F2,,,,,,种子1,registered,第一分公司代表队', ].join('\n') const MAX_SIZE = 16 * 1024 * 1024 @@ -33,7 +36,7 @@ const importing = ref(false) const result = ref(null) const error = ref(null) -/** 冲突决策面板的暂存状态:证件号 → 保留哪一侧,点「应用决策」才提交。 */ +/** 冲突决策面板的暂存状态:决策键(证件号优先,否则号码布)→ 保留哪一侧,点「应用决策」才提交。 */ const decisions = ref({}) const selected = ref([]) const resolving = ref(false) @@ -88,7 +91,7 @@ function clearFile(): void { } function selectAll(): void { - selected.value = conflicts.value.map((conflict) => conflict.idCard) + selected.value = conflicts.value.map(decisionKeyOf) } function clearSelection(): void { @@ -101,7 +104,7 @@ function decideSelected(action: ConflictAction): void { } function decideOne(conflict: ImportConflict, action: ConflictAction): void { - decisions.value = recordDecisions(decisions.value, conflicts.value, [conflict.idCard], action) + decisions.value = recordDecisions(decisions.value, conflicts.value, [decisionKeyOf(conflict)], action) } async function applyDecisions(): Promise { @@ -116,8 +119,8 @@ async function applyDecisions(): Promise { result.value = next // 已决策的人不再出现在剩余冲突里,同步清掉暂存与勾选,避免串到下一轮。 decisions.value = pruneDecisions(decisions.value, next.conflicts) - const remaining = new Set(next.conflicts.map((conflict) => conflict.idCard)) - selected.value = selected.value.filter((idCard) => remaining.has(idCard)) + const remaining = new Set(next.conflicts.map(decisionKeyOf)) + selected.value = selected.value.filter((key) => remaining.has(key)) toast.success('冲突决策已应用', `新增 ${next.created} · 更新 ${next.updated} · 跳过 ${next.skipped}`) } catch (caught) { resolveError.value = caught instanceof Error ? caught.message : '应用决策失败' @@ -186,9 +189,10 @@ function downloadTemplate(): void {
- 表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender 可缺省, - 但有姓名的行必须填证件号,否则该行会报错并跳过。证件号是人员唯一标识:先按证件号匹配, - 未命中再按「姓名 + 单位」匹配库里证件号为空的人并回填。 + 表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age 可缺省, + 但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。 + 证件号与号码布都是人员唯一标识: + 先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
@@ -258,7 +262,7 @@ function downloadTemplate(): void {
{{ conflicts.length }} 人信息冲突 - 同一证件号在库里与报名表里的姓名 / 性别 / 单位不一致,本轮未落库,也不建报名。 + 同一证件号 / 号码布在库里与报名表里的姓名 / 性别 / 年龄 / 单位 / 号码布不一致,本轮未落库,也不建报名。

逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。

@@ -294,23 +298,23 @@ function downloadTemplate(): void {
- {{ decisions[conflict.idCard] === 'keep-existing' ? '保留已有数据' : '使用新数据' }} + {{ decisions[decisionKeyOf(conflict)] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
- - + + diff --git a/web/src/admin/components/baseData/entrySelection.ts b/web/src/admin/components/baseData/entrySelection.ts index 49041b7..018785c 100644 --- a/web/src/admin/components/baseData/entrySelection.ts +++ b/web/src/admin/components/baseData/entrySelection.ts @@ -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 = { 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 -/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏证件号混进载荷。 */ +/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏键混进载荷。 */ 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 diff --git a/web/src/admin/components/baseData/mockBaseData.ts b/web/src/admin/components/baseData/mockBaseData.ts index 02edca3..53c289d 100644 --- a/web/src/admin/components/baseData/mockBaseData.ts +++ b/web/src/admin/components/baseData/mockBaseData.ts @@ -237,6 +237,8 @@ export class MockBaseData { teamId: team.id, employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`, idCard: null, + bib: `${team.id}${String(nameIndex + 1).padStart(2, '0')}`, + age: 22 + nameIndex * 2, active: true, }) }) @@ -409,6 +411,8 @@ export class MockBaseData { } createAthlete(input: AthleteInput): Athlete { + this.assertAthleteIdentityFree(input, null) + this.assertAthleteAge(input) const athlete: Athlete = { ...input, id: (this.nextAthleteId += 1) } this.athletes = [...this.athletes, athlete] return { ...athlete } @@ -417,11 +421,32 @@ export class MockBaseData { updateAthlete(id: number, input: AthleteInput): Athlete { const index = this.athletes.findIndex((athlete) => athlete.id === id) if (index < 0) throw new Error(`运动员 ${id} 不存在`) + this.assertAthleteIdentityFree(input, id) + this.assertAthleteAge(input) const athlete: Athlete = { ...input, id } this.athletes[index] = athlete return { ...athlete } } + /** 证件号 / 号码布都是人员唯一标识,重复时报出具体是哪个字段。 */ + private assertAthleteIdentityFree(input: AthleteInput, selfId: number | null): void { + const idCard = input.idCard ?? '' + if (idCard !== '' && this.athletes.some((athlete) => athlete.id !== selfId && athlete.idCard === idCard)) { + throw new Error(`证件号 ${idCard} 已被其他人员使用`) + } + const bib = input.bib ?? '' + if (bib !== '' && this.athletes.some((athlete) => athlete.id !== selfId && athlete.bib === bib)) { + throw new Error(`号码布 ${bib} 已被其他人员使用`) + } + } + + /** 年龄可空;填了就必须是 0–120 的整数,与服务端校验一致。 */ + private assertAthleteAge(input: AthleteInput): void { + if (input.age !== null && (!Number.isInteger(input.age) || input.age < 0 || input.age > 120)) { + throw new Error('年龄必须是 0–120 的整数') + } + } + removeAthlete(id: number): void { const index = this.athletes.findIndex((athlete) => athlete.id === id) if (index < 0) throw new Error(`运动员 ${id} 不存在`) @@ -434,9 +459,12 @@ export class MockBaseData { return this.runImport(file, new Map()) } - /** 冲突决策重跑:decisions 为空的证件号仍视为未决策,继续留在返回的 conflicts 里。 */ + /** 冲突决策重跑:没给决策的键仍视为未决策,继续留在返回的 conflicts 里。 */ async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise { - return this.runImport(file, new Map(decisions.map((decision) => [decision.idCard, decision.action]))) + // 与服务端一致:证件号优先,没有证件号时用号码布当决策键。 + const keyOf = (decision: ImportConflictDecision): string => + decision.idCard !== '' ? decision.idCard : decision.bib + return this.runImport(file, new Map(decisions.map((decision) => [keyOf(decision), decision.action]))) } private async runImport(file: File, decisions: Map): Promise { @@ -453,6 +481,7 @@ export class MockBaseData { const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] } const seenIdCards = new Set() + const seenBibs = new Set() rows.slice(1).forEach((row, offset) => { const line = offset + 2 if (row.every((cell) => cell.trim() === '')) return @@ -498,35 +527,58 @@ export class MockBaseData { return } const idCard = cell('id_card') - if (idCard === '') { + const bib = cell('bib') + if (idCard === '' && bib === '') { result.skipped += 1 - result.errors.push(`第 ${line} 行:有姓名的行必须填证件号`) + result.errors.push(`第 ${line} 行:有姓名的行必须填证件号或号码布`) return } - if (seenIdCards.has(idCard)) { + // 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占证件号 / 号码布名额。 + const age = parseAge(cell('age')) + if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) { + result.skipped += 1 + result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`) + return + } + if (idCard !== '' && seenIdCards.has(idCard)) { result.skipped += 1 result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`) return } - seenIdCards.add(idCard) + if (bib !== '' && seenBibs.has(bib)) { + result.skipped += 1 + result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`) + return + } + if (idCard !== '') seenIdCards.add(idCard) + if (bib !== '') seenBibs.add(bib) const rawGender = cell('gender') const gender = rawGender === '男' || rawGender === '女' ? rawGender : null - const matched = this.athletes.find((athlete) => athlete.idCard === idCard) + // 契约匹配顺序:证件号 → 号码布 → 姓名 + 单位(库里证件号为空)。 + const matched = + (idCard === '' ? undefined : this.athletes.find((athlete) => athlete.idCard === idCard)) ?? + (bib === '' ? undefined : this.athletes.find((athlete) => athlete.bib === bib)) if (matched !== undefined) { - const conflict = this.detectConflict(matched, line, idCard, athleteName, gender, team) - if (conflict !== null && decisions.get(idCard) === undefined) { + const conflict = this.detectConflict(matched, line, idCard, bib, athleteName, gender, age, team) + const key = idCard !== '' ? idCard : bib + if (conflict !== null && decisions.get(key) === undefined) { result.conflicts.push(conflict) return } - if (conflict !== null && decisions.get(idCard) === 'keep-new') { + if (conflict !== null && decisions.get(key) === 'keep-new') { matched.name = athleteName if (gender !== null) matched.gender = gender + if (age !== null) matched.age = age matched.teamId = team.id + if (idCard !== '') matched.idCard = idCard + if (bib !== '') matched.bib = bib } + // 库里没有年龄时按表里的值回填,已有年龄一律不动。 + if (age !== null && matched.age === null) matched.age = age result.created += 1 return } - // 证件号未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。 + // 证件号 / 号码布未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。 const legacy = this.athletes.find( (athlete) => athlete.name === athleteName && @@ -534,7 +586,18 @@ export class MockBaseData { (athlete.idCard === null || athlete.idCard === ''), ) if (legacy !== undefined) { - legacy.idCard = idCard + const conflict = this.detectConflict(legacy, line, idCard, bib, athleteName, gender, age, team) + const legacyKey = idCard !== '' ? idCard : bib + if (conflict !== null && decisions.get(legacyKey) === undefined) { + result.conflicts.push(conflict) + return + } + if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && bib !== '') legacy.bib = bib + if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && age !== null) legacy.age = age + if (idCard !== '') legacy.idCard = idCard + if (bib !== '' && (legacy.bib === null || legacy.bib === '')) legacy.bib = bib + // 其余情况只补库中为空的年龄,绝不用空值清掉已有值。 + if (age !== null && legacy.age === null) legacy.age = age result.created += 1 return } @@ -544,7 +607,9 @@ export class MockBaseData { gender, teamId: team.id, employeeNo: null, - idCard, + idCard: idCard === '' ? null : idCard, + bib: bib === '' ? null : bib, + age, active: true, }) result.created += 1 @@ -552,36 +617,57 @@ export class MockBaseData { return result } - /** name / gender / teamCode 与库里不一致才算冲突;表里 gender 为空视为未指定。 */ + /** name / gender / age / teamCode / bib 与库里不一致才算冲突;表里为空的字段视为未指定。 */ private detectConflict( athlete: Athlete, row: number, idCard: string, + bib: string, name: string, gender: Athlete['gender'], + age: number | null, team: Team, ): ImportConflict | null { const changed: ImportConflictField[] = [] if (athlete.name !== name) changed.push('name') if (gender !== null && athlete.gender !== gender) changed.push('gender') + // 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。 + if (age !== null && athlete.age !== null && athlete.age !== age) changed.push('age') if (athlete.teamId !== team.id) changed.push('teamCode') + // 号码布两侧都给了且不一致才算差异;表里没给视为不修改。 + if (bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== bib) changed.push('bib') if (changed.length === 0) return null return { row, idCard, + bib, athleteId: athlete.id, existing: { name: athlete.name, gender: athlete.gender, + age: athlete.age, teamCode: this.teams.find((item) => item.id === athlete.teamId)?.code ?? '', employeeNo: athlete.employeeNo, + bib: athlete.bib, }, - incoming: { name, gender, teamCode: team.code, employeeNo: null }, + incoming: { name, gender, age, teamCode: team.code, employeeNo: null, bib: bib === '' ? null : bib }, changedFields: changed, } } } +/** + * 报名表里的年龄:空值返回 null(未指定),非数字返回 NaN 交给调用方报错。 + * + * 与「组别」表的写法一致:不能对 number 直接 `.trim()`,先 `String()` 再解析。 + */ +function parseAge(raw: string): number | null { + const text = String(raw).trim() + if (text === '') return null + const parsed = Number(text) + return Number.isFinite(parsed) ? parsed : Number.NaN +} + /** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */ function parseCsv(text: string): string[][] { const rows: string[][] = [] diff --git a/web/src/admin/components/system/EventInfoPanel.vue b/web/src/admin/components/system/EventInfoPanel.vue index 6a55f66..612051e 100644 --- a/web/src/admin/components/system/EventInfoPanel.vue +++ b/web/src/admin/components/system/EventInfoPanel.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue' import type { EventSettings } from '@/shared' import MxIcon from '../MxIcon.vue' +import ImageUploadField from './ImageUploadField.vue' import MockBadge from './MockBadge.vue' import { describeError, settingsApi, type EventSettingsPatch } from './systemApi' import { useAuthStore } from '../../stores/auth' @@ -77,11 +78,15 @@ async function save(): Promise { name: next.event.name.trim(), subtitle: optional(next.event.subtitle), host: optional(next.event.host), + // 同理:会标清空必须传空串,传 null 服务端会当成「不改动」。 + logoPath: optional(next.event.logoPath), }, display: { ...next.display, title: next.display.title.trim(), subtitle: optional(next.display.subtitle), + logo: optional(next.display.logo), + background: optional(next.display.background), rotateSeconds, }, standings: { ...next.standings }, @@ -151,10 +156,34 @@ async function save(): Promise {
+
+ 赛事会标 + +

大屏配置

+
+ 大屏会标 + +
+
+ 大屏背景图 + +
@@ -243,6 +272,10 @@ async function save(): Promise { display: flex; flex-direction: column; } +/* 图片字段要比纯文本框宽:缩略图与两个按钮并排。 */ +.field.wide { + grid-column: 1 / -1; +} .switch-field { justify-content: flex-end; } diff --git a/web/src/admin/components/system/ImageUploadField.vue b/web/src/admin/components/system/ImageUploadField.vue new file mode 100644 index 0000000..43c885c --- /dev/null +++ b/web/src/admin/components/system/ImageUploadField.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/web/src/admin/components/system/imagePath.ts b/web/src/admin/components/system/imagePath.ts new file mode 100644 index 0000000..896355e --- /dev/null +++ b/web/src/admin/components/system/imagePath.ts @@ -0,0 +1,13 @@ +/** + * 图片路径解析(供 使用)。 + * + * 上传接口返回的路径本就带 `/uploads/` 前缀,这里主要兼容手填的历史值: + * 绝对地址(http/https)、内联数据(data:)与已带前导斜杠的路径原样使用, + * 其余相对路径补一个 `/`,否则浏览器会相对当前路由解析(如 `/admin/logos/x.png`)。 + */ +export function resolveAssetUrl(value: string | null | undefined): string { + const path = (value ?? '').trim() + if (path === '') return '' + if (path.startsWith('/') || path.startsWith('data:') || /^https?:\/\//i.test(path)) return path + return `/${path}` +} diff --git a/web/src/admin/pages/AboutPage.vue b/web/src/admin/pages/AboutPage.vue index a251151..10654a2 100644 --- a/web/src/admin/pages/AboutPage.vue +++ b/web/src/admin/pages/AboutPage.vue @@ -3,14 +3,15 @@ import { computed, onMounted, ref } from 'vue' import PageHeader from '../components/PageHeader.vue' import { adminApi } from '../api/client' import { useEventStore } from '../stores/event' +import type { SystemAbout } from '@/shared/api' const data = useEventStore() const SYSTEM_NAME = '运动会积分榜系统' const SYSTEM_EN = 'Scoreboard' -// /api/health 只回 version,/api/system/about 也不带构建时间;前端构建期未注入该常量, -// 因此这里如实显示「未注入」,不再用假的 'dev' 冒充构建时间。 -const BUILD_TIME = '未注入' +// 构建时间由服务端构建期注入,随 /api/system/about 一起取回;没注入(空串)或请求失败时 +// 如实显示「未注入」,不用假的 'dev' 冒充。 +const BUILD_TIME_UNKNOWN = '未注入' const version = ref(null) const versionChecked = ref(false) @@ -73,8 +74,12 @@ const port = computed(() => { const origin = computed(() => window.location.origin) -// 内部署名由构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串 +// 内部署名与构建时间都由服务端构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串 const internalSignature = ref('') +const buildTime = ref('') + +// 空串(未注入)/ 请求失败一律显示「未注入」,不留空白 +const buildTimeText = computed(() => buildTime.value.trim() || BUILD_TIME_UNKNOWN) onMounted(async () => { // 直接从 /api/health 取版本;不走 adminApi.health 的示例数据兜底,避免把 mock 当成真实版本 @@ -90,11 +95,12 @@ onMounted(async () => { try { const res = await fetch('/api/system/about', { credentials: 'include' }) if (res.ok) { - const body = (await res.json()) as { ok: boolean; data?: { internalSignature?: string } } + const body = (await res.json()) as { ok: boolean; data?: SystemAbout } internalSignature.value = body.data?.internalSignature ?? '' + buildTime.value = body.data?.buildTime ?? '' } } catch { - // 未登录 / 接口不可达都按「没有署名」处理,不打扰用户 + // 未登录 / 接口不可达都按「没有署名、没有构建时间」处理,不打扰用户 } if (!data.loaded) void data.load() @@ -132,7 +138,7 @@ onMounted(async () => {
构建时间
-
{{ BUILD_TIME }}
+
{{ buildTimeText }}
运行模式
diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue index e43b186..c8798c4 100644 --- a/web/src/admin/pages/DisplayPage.vue +++ b/web/src/admin/pages/DisplayPage.vue @@ -1,5 +1,5 @@