feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员
大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)
图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮
关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
(用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)
安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认
自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
1 parent
24fff8dd08
commit
6483acf9ad
50 files changed
+2980
-395
No files matched your search
+2
-2
@@ -15,8 +15,8 @@ rules/*
|
||||
# 内部署名(构建期注入,绝不进仓库)
|
||||
deploy/signature.local.txt
|
||||
deploy/signature.build.txt
|
||||
# 安装器许可页的署名文本(构建时生成)
|
||||
console/resources/installer-signature.nsh
|
||||
# 安装器自定义脚本:许可页署名 + 上次安装目录记忆(构建时生成)
|
||||
console/resources/installer-custom.nsh
|
||||
|
||||
# 开发文档(内部设计文档,不进仓库)
|
||||
devdocs/
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
## 快速开始
|
||||
|
||||
1. 解压部署包
|
||||
2. 双击 `Scoreboard Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启)
|
||||
2. 双击 `Scoreboard_Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启)
|
||||
3. 浏览器打开终端提示的地址
|
||||
|
||||
```
|
||||
|
||||
+6
-2
@@ -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:<port>/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` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@
|
||||
"openAtLogin": false
|
||||
},
|
||||
"admin": {
|
||||
"width": 1040,
|
||||
"height": 760
|
||||
"width": 1280,
|
||||
"height": 860
|
||||
},
|
||||
"log": {
|
||||
"maxLines": 2000,
|
||||
|
||||
@@ -43,8 +43,8 @@ const DEFAULTS = {
|
||||
openAtLogin: false
|
||||
},
|
||||
admin: {
|
||||
width: 1040,
|
||||
height: 760
|
||||
width: 1280,
|
||||
height: 860
|
||||
},
|
||||
log: {
|
||||
maxLines: 2000,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+59
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+119
-114
@@ -46,130 +46,135 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>服务端状态</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-start" class="btn primary" type="button">启动服务</button>
|
||||
<button id="btn-stop" class="btn" type="button">停止服务</button>
|
||||
<button id="btn-restart" class="btn" type="button">重启服务</button>
|
||||
<!-- 左栏放状态与配置类卡片,右栏整栏留给运行日志 -->
|
||||
<div class="col-left">
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>服务端状态</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-start" class="btn primary" type="button">启动服务</button>
|
||||
<button id="btn-stop" class="btn" type="button">停止服务</button>
|
||||
<button id="btn-restart" class="btn" type="button">重启服务</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span id="state-dot" class="dot"></span>
|
||||
<strong id="state-label">已停止</strong>
|
||||
<span id="state-detail" class="muted"></span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span id="state-dot" class="dot"></span>
|
||||
<strong id="state-label">已停止</strong>
|
||||
<span id="state-detail" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<dl class="kv">
|
||||
<div>
|
||||
<dt>服务地址</dt>
|
||||
<dd id="k-url">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>进程 PID</dt>
|
||||
<dd id="k-pid">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>自动重启</dt>
|
||||
<dd id="k-restarts">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>防熄屏</dt>
|
||||
<dd id="k-power">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开机自启</dt>
|
||||
<dd>
|
||||
<label class="switch">
|
||||
<input id="sw-open-at-login" type="checkbox" />
|
||||
<span id="sw-open-at-login-label" class="muted">—</span>
|
||||
</label>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<dt>服务端程序</dt>
|
||||
<dd id="k-exe" class="mono">—</dd>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<dt>配置文件</dt>
|
||||
<dd id="k-config" class="mono">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl class="kv">
|
||||
<div>
|
||||
<dt>服务地址</dt>
|
||||
<dd id="k-url">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>进程 PID</dt>
|
||||
<dd id="k-pid">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>自动重启</dt>
|
||||
<dd id="k-restarts">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>防熄屏</dt>
|
||||
<dd id="k-power">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开机自启</dt>
|
||||
<dd>
|
||||
<label class="switch">
|
||||
<input id="sw-open-at-login" type="checkbox" />
|
||||
<span id="sw-open-at-login-label" class="muted">—</span>
|
||||
</label>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<dt>服务端程序</dt>
|
||||
<dd id="k-exe" class="mono">—</dd>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<dt>配置文件</dt>
|
||||
<dd id="k-config" class="mono">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p id="config-warning" class="warning" hidden></p>
|
||||
</section>
|
||||
<p id="config-warning" class="warning" hidden></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>大屏窗口</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-relayout" class="btn" type="button">重新排布</button>
|
||||
<button id="btn-open-admin" class="btn" type="button">打开服务端管理面板</button>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>大屏窗口</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-relayout" class="btn" type="button">重新排布</button>
|
||||
<button id="btn-open-admin" class="btn" type="button">打开服务端管理面板</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。
|
||||
</p>
|
||||
<ul id="display-list" class="display-list">
|
||||
<li class="muted">尚未打开大屏窗口</li>
|
||||
</ul>
|
||||
</section>
|
||||
<p class="hint">
|
||||
单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。
|
||||
</p>
|
||||
<ul id="display-list" class="display-list">
|
||||
<li class="muted">尚未打开大屏窗口</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>已连接的大屏实例</h2>
|
||||
<span id="instances-summary" class="muted"></span>
|
||||
</div>
|
||||
<p class="hint">
|
||||
来自服务端 <span class="mono">GET /api/display/instances</span>,每 5 秒刷新一次;
|
||||
沿用服务端管理面板的登录态,需要记录员及以上权限。
|
||||
</p>
|
||||
<ul id="instance-list" class="display-list">
|
||||
<li class="muted">正在读取…</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>管理员账号</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-reset-account" class="btn" type="button">重置账号</button>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>已连接的大屏实例</h2>
|
||||
<span id="instances-summary" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
本机直接调用同目录的 <span class="mono">scoreboard.exe passwd</span>,无需登录验证。
|
||||
重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>当前用户名</span>
|
||||
<input id="acct-user" class="input" type="text" value="admin" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新用户名(留空 = 不改)</span>
|
||||
<input id="acct-new-user" class="input" type="text" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新密码(留空 = 随机生成)</span>
|
||||
<input id="acct-password" class="input" type="password" autocomplete="new-password" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
<p id="acct-status" class="hint" hidden></p>
|
||||
<pre id="acct-output" class="log account-output" hidden></pre>
|
||||
</section>
|
||||
<p class="hint">
|
||||
来自服务端 <span class="mono">GET /api/display/instances</span>,每 5 秒刷新一次;
|
||||
沿用服务端管理面板的登录态,需要记录员及以上权限。
|
||||
</p>
|
||||
<ul id="instance-list" class="display-list">
|
||||
<li class="muted">正在读取…</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card log-card">
|
||||
<div class="card-head">
|
||||
<h2>运行日志</h2>
|
||||
<div class="actions">
|
||||
<label class="check"><input id="autoscroll" type="checkbox" checked /> 自动滚动</label>
|
||||
<button id="btn-clear-log" class="btn" type="button">清空</button>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>管理员账号</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-reset-account" class="btn" type="button">重置账号</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="log" class="log"></pre>
|
||||
</section>
|
||||
<p class="hint">
|
||||
本机直接调用同目录的 <span class="mono">scoreboard.exe passwd</span>,无需登录验证。
|
||||
重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>当前用户名</span>
|
||||
<input id="acct-user" class="input" type="text" value="admin" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新用户名(留空 = 不改)</span>
|
||||
<input id="acct-new-user" class="input" type="text" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新密码(留空 = 随机生成)</span>
|
||||
<input id="acct-password" class="input" type="password" autocomplete="new-password" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
<p id="acct-status" class="hint" hidden></p>
|
||||
<pre id="acct-output" class="log account-output" hidden></pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-right">
|
||||
<section class="card log-card">
|
||||
<div class="card-head">
|
||||
<h2>运行日志</h2>
|
||||
<div class="actions">
|
||||
<label class="check"><input id="autoscroll" type="checkbox" checked /> 自动滚动</label>
|
||||
<button id="btn-clear-log" class="btn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="log" class="log"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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" (
|
||||
|
||||
@@ -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)`)
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
|
||||
## 3. 首次启动
|
||||
|
||||
- [ ] 双击 `Scoreboard Console.exe`,启动管理窗口出现
|
||||
- [ ] 双击 `Scoreboard_Console.exe`,启动管理窗口出现
|
||||
- [ ] 服务端状态变为「运行中 · 就绪」
|
||||
- [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录
|
||||
- [ ] **管理员初始密码**在 `logs\console.log` 里搜 `INITIAL ADMIN CREATED`,密码就在该行上方;也可以直接打开 Console 的「运行日志」面板往上翻。首次登录会要求改密
|
||||
|
||||
+47
-13
@@ -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` |
|
||||
| 个人积分榜上屏 | 开关;关闭后大屏不展示个人积分榜 |
|
||||
|
||||
+14
-5
@@ -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`,相对服务端工作目录解析。
|
||||
+3
-3
@@ -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` |
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 同值,前端可直接拿去当 <img src>。
|
||||
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 匿名提供上传目录里的图片:大屏与 <img> 不带会话,不能要求登录。
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 上传的图片要真落盘到 <ProjectDir>/uploads,且匿名 <img> 能取到同样的内容(赛事包导出靠这个目录)。
|
||||
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)
|
||||
}
|
||||
|
||||
// 大屏与 <img> 不带会话:匿名 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(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)},
|
||||
{"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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
// 图片上传与静态访问(会标/背景/单位图标)。上传落 <ProjectDir>/uploads/,随赛事包导出;
|
||||
// 静态访问要匿名放行,大屏的 <img> 才能不带会话直接取图。
|
||||
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("规则导入导出"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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 <> '';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 年龄是人员的补充信息,不参与唯一标识。历史数据没有年龄,允许为空。
|
||||
ALTER TABLE athletes ADD COLUMN age INTEGER;
|
||||
@@ -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<Athlete | null>(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<void> {
|
||||
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<void> {
|
||||
{
|
||||
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<void> {
|
||||
<div class="bd-toolbar-left">
|
||||
<div class="mx-input-wrap bd-search">
|
||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 证件号" />
|
||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 证件号 / 号码布" />
|
||||
</div>
|
||||
<select v-model="teamFilter" class="mx-cell-input bd-select">
|
||||
<option value="all">全部单位</option>
|
||||
@@ -187,16 +213,18 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th class="bd-cell-mini">性别</th>
|
||||
<th class="bd-cell-mini">年龄</th>
|
||||
<th>所属单位</th>
|
||||
<th>工号 / 学号</th>
|
||||
<th>证件号</th>
|
||||
<th>号码布</th>
|
||||
<th>状态</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="data.loading.value && data.athletes.value.length === 0">
|
||||
<td colspan="7">
|
||||
<td colspan="9">
|
||||
<div class="mx-empty bd-empty">
|
||||
<span class="mx-spinner" />
|
||||
<div class="mx-empty-title">加载中…</div>
|
||||
@@ -207,9 +235,11 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<tr v-for="athlete in filtered" :key="athlete.id">
|
||||
<td>{{ athlete.name }}</td>
|
||||
<td>{{ athlete.gender ?? '—' }}</td>
|
||||
<td>{{ athlete.age ?? '—' }}</td>
|
||||
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
|
||||
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
|
||||
<td class="bd-mono">{{ athlete.idCard ?? '—' }}</td>
|
||||
<td class="bd-mono">{{ athlete.bib ?? '—' }}</td>
|
||||
<td>
|
||||
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||
{{ athlete.active ? '启用' : '已停用' }}
|
||||
@@ -233,7 +263,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="7">
|
||||
<td colspan="9">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
|
||||
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
|
||||
@@ -267,6 +297,9 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<option value="女">女</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="年龄" hint="可留空,填了必须是 0–120 的整数" :error="errors.age">
|
||||
<input v-model="form.age" class="mx-cell-input" type="number" min="0" max="120" placeholder="例如 26" />
|
||||
</FormField>
|
||||
<FormField label="所属单位" required :error="errors.teamId">
|
||||
<select v-model="form.teamId" class="mx-cell-input">
|
||||
<option value="" disabled>请选择单位</option>
|
||||
@@ -278,9 +311,12 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
||||
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
||||
</FormField>
|
||||
<FormField label="证件号" hint="人员唯一标识,建议必填">
|
||||
<FormField label="证件号" hint="证件号与号码布至少填一个,二者都是人员唯一标识">
|
||||
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
|
||||
</FormField>
|
||||
<FormField label="号码布" hint="没有身份证信息时可只填号码布">
|
||||
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
|
||||
</FormField>
|
||||
<FormField label="状态" wide>
|
||||
<label class="bd-check"><input v-model="form.active" type="checkbox" /> 启用</label>
|
||||
</FormField>
|
||||
|
||||
@@ -83,9 +83,11 @@ function isRegistered(athlete: Athlete): boolean {
|
||||
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
|
||||
}
|
||||
|
||||
/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */
|
||||
function missingIdCard(athlete: Athlete): boolean {
|
||||
return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '')
|
||||
/** 个人项目的行必须带证件号或号码布;团体行运动员为空,不受影响。 */
|
||||
function missingIdentifier(athlete: Athlete): boolean {
|
||||
const noIdCard = athlete.idCard === null || athlete.idCard === ''
|
||||
const noBib = athlete.bib === null || athlete.bib === ''
|
||||
return mode.value === 'individual' && noIdCard && noBib
|
||||
}
|
||||
|
||||
async function loadEntries(id: number): Promise<void> {
|
||||
@@ -138,7 +140,7 @@ async function submit(): Promise<void> {
|
||||
categoryName: category.name,
|
||||
teamCode: team.code,
|
||||
mode: mode.value,
|
||||
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })),
|
||||
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard, bib: athlete.bib })),
|
||||
})
|
||||
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
|
||||
const result = await data.importEntries(file)
|
||||
@@ -227,12 +229,12 @@ async function submit(): Promise<void> {
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
:value="athlete.id"
|
||||
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)"
|
||||
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdentifier(athlete)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
|
||||
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}</span>
|
||||
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-else-if="missingIdCard(athlete)" class="mx-badge mx-badge-warning">缺证件号</span>
|
||||
<span v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
|
||||
</label>
|
||||
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
||||
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||
@@ -243,8 +245,8 @@ async function submit(): Promise<void> {
|
||||
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
|
||||
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
|
||||
</p>
|
||||
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p v-if="mode === 'individual'" class="bd-note">证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
|
||||
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
|
||||
</template>
|
||||
|
||||
@@ -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<EntryImportResult | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** 冲突决策面板的暂存状态:证件号 → 保留哪一侧,点「应用决策」才提交。 */
|
||||
/** 冲突决策面板的暂存状态:决策键(证件号优先,否则号码布)→ 保留哪一侧,点「应用决策」才提交。 */
|
||||
const decisions = ref<DecisionMap>({})
|
||||
const selected = ref<string[]>([])
|
||||
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<void> {
|
||||
@@ -116,8 +119,8 @@ async function applyDecisions(): Promise<void> {
|
||||
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 {
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender 可缺省,
|
||||
但有姓名的行必须填证件号,否则该行会报错并跳过。证件号是人员唯一标识:先按证件号匹配,
|
||||
未命中再按「姓名 + 单位」匹配库里证件号为空的人并回填。
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
证件号与号码布都是人员唯一标识:
|
||||
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +262,7 @@ function downloadTemplate(): void {
|
||||
<div class="conflicts-head">
|
||||
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
|
||||
<span class="bd-note">
|
||||
同一证件号在库里与报名表里的姓名 / 性别 / 单位不一致,本轮未落库,也不建报名。
|
||||
同一证件号 / 号码布在库里与报名表里的姓名 / 性别 / 年龄 / 单位 / 号码布不一致,本轮未落库,也不建报名。
|
||||
</span>
|
||||
</div>
|
||||
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
|
||||
@@ -294,23 +298,23 @@ function downloadTemplate(): void {
|
||||
<div class="conflict-list">
|
||||
<div
|
||||
v-for="conflict in conflicts"
|
||||
:key="conflict.idCard"
|
||||
:key="decisionKeyOf(conflict)"
|
||||
class="conflict-item"
|
||||
:class="{ decided: decisions[conflict.idCard] !== undefined }"
|
||||
:class="{ decided: decisions[decisionKeyOf(conflict)] !== undefined }"
|
||||
>
|
||||
<div class="conflict-head">
|
||||
<label class="bd-check conflict-check">
|
||||
<input v-model="selected" type="checkbox" :value="conflict.idCard" />
|
||||
<span class="conflict-id bd-mono">{{ conflict.idCard }}</span>
|
||||
<input v-model="selected" type="checkbox" :value="decisionKeyOf(conflict)" />
|
||||
<span class="conflict-id bd-mono">{{ conflictLabel(conflict) }}</span>
|
||||
<span class="conflict-name">{{ conflict.existing.name }}</span>
|
||||
<span class="bd-note">第 {{ conflict.row }} 行</span>
|
||||
</label>
|
||||
<span
|
||||
v-if="decisions[conflict.idCard] !== undefined"
|
||||
v-if="decisions[decisionKeyOf(conflict)] !== undefined"
|
||||
class="mx-badge"
|
||||
:class="decisions[conflict.idCard] === 'keep-existing' ? 'mx-badge-neutral' : 'mx-badge-success'"
|
||||
:class="decisions[decisionKeyOf(conflict)] === 'keep-existing' ? 'mx-badge-neutral' : 'mx-badge-success'"
|
||||
>
|
||||
{{ decisions[conflict.idCard] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
|
||||
{{ decisions[decisionKeyOf(conflict)] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
|
||||
</span>
|
||||
<div class="bd-actions conflict-actions">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="decideOne(conflict, 'keep-existing')">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import type { Team, TeamInput } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import ImageUploadField from '../system/ImageUploadField.vue'
|
||||
import FormDialog from './FormDialog.vue'
|
||||
import FormField from './FormField.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
@@ -281,8 +282,8 @@ async function toggleActive(team: Team): Promise<void> {
|
||||
<span v-if="form.color" class="bd-color" :style="{ background: form.color }" />
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Logo 路径" hint="相对 uploads/,可留空">
|
||||
<input v-model="form.logoPath" class="mx-cell-input" placeholder="logos/team1.png" />
|
||||
<FormField label="Logo 路径" hint="留空则不显示单位图标;上传的图片随赛事包一起导出">
|
||||
<ImageUploadField v-model="form.logoPath" :disabled="!data.canWrite.value" />
|
||||
</FormField>
|
||||
<FormField label="排序" :error="errors.sortOrder" hint="数字越小越靠前">
|
||||
<input v-model="form.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
|
||||
|
||||
@@ -21,14 +21,15 @@ export const REQUIRED_COLUMNS = [
|
||||
'athlete_name',
|
||||
'id_card',
|
||||
'gender',
|
||||
'age',
|
||||
'bib',
|
||||
'seed',
|
||||
'status',
|
||||
'member_names',
|
||||
] as const
|
||||
|
||||
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card。 */
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender'] as const
|
||||
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age'] as const
|
||||
|
||||
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
|
||||
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
|
||||
@@ -44,8 +45,8 @@ export interface SelectionInput {
|
||||
teamCode: string
|
||||
status?: string
|
||||
mode: SelectionMode
|
||||
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号才能命中证件号优先的匹配规则。 */
|
||||
athletes: { name: string; idCard?: string | null }[]
|
||||
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。 */
|
||||
athletes: { name: string; idCard?: string | null; bib?: string | null }[]
|
||||
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
|
||||
memberNames?: string[]
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
athlete.idCard ?? '',
|
||||
'',
|
||||
'',
|
||||
athlete.bib ?? '',
|
||||
'',
|
||||
status,
|
||||
'',
|
||||
@@ -77,7 +79,19 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
return rows
|
||||
}
|
||||
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
|
||||
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', '', '', status, names.join('/')])
|
||||
rows.push([
|
||||
input.eventCode,
|
||||
input.categoryName,
|
||||
input.teamCode,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
status,
|
||||
names.join('/'),
|
||||
])
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -96,8 +110,10 @@ function escapeField(field: string): string {
|
||||
const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
|
||||
name: '姓名',
|
||||
gender: '性别',
|
||||
age: '年龄',
|
||||
teamCode: '单位',
|
||||
employeeNo: '工号 / 学号',
|
||||
bib: '号码布',
|
||||
}
|
||||
|
||||
/** 一条冲突里某个字段的两侧取值,changed 为 true 时界面高亮。 */
|
||||
@@ -120,32 +136,48 @@ export function conflictDiffs(conflict: ImportConflict): ConflictFieldDiff[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function formatFieldValue(value: string | null): string {
|
||||
return value === null || value === '' ? '—' : value
|
||||
function formatFieldValue(value: string | number | null | undefined): string {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
/** 证件号 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||
/**
|
||||
* 冲突的决策键:证件号优先,没有证件号时用号码布。
|
||||
*
|
||||
* 与服务端 repo_import.go 的 decisionKey() 是同一个约定,两边必须保持一致;
|
||||
* 只有号码布的行证件号为空串,直接拿证件号当键会让多条冲突互相覆盖。
|
||||
*/
|
||||
export function decisionKeyOf(conflict: ImportConflict): string {
|
||||
return conflict.idCard !== '' ? conflict.idCard : conflict.bib
|
||||
}
|
||||
|
||||
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
|
||||
export function conflictLabel(conflict: ImportConflict): string {
|
||||
const key = decisionKeyOf(conflict)
|
||||
return key === '' ? '(无证件号 / 号码布)' : key
|
||||
}
|
||||
|
||||
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||
export type DecisionMap = Record<string, ConflictAction>
|
||||
|
||||
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏证件号混进载荷。 */
|
||||
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏键混进载荷。 */
|
||||
export function recordDecisions(
|
||||
current: DecisionMap,
|
||||
conflicts: readonly ImportConflict[],
|
||||
idCards: readonly string[],
|
||||
keys: readonly string[],
|
||||
action: ConflictAction,
|
||||
): DecisionMap {
|
||||
const known = new Set(conflicts.map((conflict) => conflict.idCard))
|
||||
const known = new Set(conflicts.map(decisionKeyOf))
|
||||
const next: DecisionMap = { ...current }
|
||||
for (const idCard of idCards) {
|
||||
if (known.has(idCard)) next[idCard] = action
|
||||
for (const key of keys) {
|
||||
if (known.has(key)) next[key] = action
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 丢弃已不存在的证件号,换文件或 resolve 之后调用。 */
|
||||
/** 丢弃已不存在的决策键,换文件或 resolve 之后调用。 */
|
||||
export function pruneDecisions(current: DecisionMap, conflicts: readonly ImportConflict[]): DecisionMap {
|
||||
const known = new Set(conflicts.map((conflict) => conflict.idCard))
|
||||
return Object.fromEntries(Object.entries(current).filter(([idCard]) => known.has(idCard)))
|
||||
const known = new Set(conflicts.map(decisionKeyOf))
|
||||
return Object.fromEntries(Object.entries(current).filter(([key]) => known.has(key)))
|
||||
}
|
||||
|
||||
/** 按冲突在结果里的顺序生成 resolve 载荷,服务端回显顺序与界面一致。 */
|
||||
@@ -154,8 +186,12 @@ export function decisionsPayload(
|
||||
conflicts: readonly ImportConflict[],
|
||||
): ImportConflictDecision[] {
|
||||
return conflicts
|
||||
.filter((conflict) => current[conflict.idCard] !== undefined)
|
||||
.map((conflict) => ({ idCard: conflict.idCard, action: current[conflict.idCard] as ConflictAction }))
|
||||
.filter((conflict) => current[decisionKeyOf(conflict)] !== undefined)
|
||||
.map((conflict) => ({
|
||||
idCard: conflict.idCard,
|
||||
bib: conflict.bib,
|
||||
action: current[decisionKeyOf(conflict)] as ConflictAction,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface DecisionSummary {
|
||||
@@ -171,7 +207,7 @@ export function summarizeDecisions(
|
||||
): DecisionSummary {
|
||||
const summary: DecisionSummary = { keepExisting: 0, keepNew: 0, undecided: 0 }
|
||||
for (const conflict of conflicts) {
|
||||
const action = current[conflict.idCard]
|
||||
const action = current[decisionKeyOf(conflict)]
|
||||
if (action === 'keep-existing') summary.keepExisting += 1
|
||||
else if (action === 'keep-new') summary.keepNew += 1
|
||||
else summary.undecided += 1
|
||||
|
||||
@@ -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<EntryImportResult> {
|
||||
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<string, ConflictAction>): Promise<EntryImportResult> {
|
||||
@@ -453,6 +481,7 @@ export class MockBaseData {
|
||||
|
||||
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
|
||||
const seenIdCards = new Set<string>()
|
||||
const seenBibs = new Set<string>()
|
||||
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[][] = []
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
<input id="event-host" v-model="form.event.host" class="mx-input" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<span class="mx-label">赛事会标</span>
|
||||
<ImageUploadField
|
||||
v-model="form.event.logoPath"
|
||||
:disabled="!isAdmin"
|
||||
hint="积分榜左上角的会标。上传的图片存放在服务端 uploads/ 下,会随赛事包一起导出。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="group-title">大屏配置</h3>
|
||||
<div class="form-grid">
|
||||
<div class="field wide">
|
||||
<span class="mx-label">大屏会标</span>
|
||||
<ImageUploadField
|
||||
v-model="form.display.logo"
|
||||
:disabled="!isAdmin"
|
||||
hint="大屏顶栏的会标;留空则回落到赛事会标。"
|
||||
/>
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<span class="mx-label">大屏背景图</span>
|
||||
<ImageUploadField
|
||||
v-model="form.display.background"
|
||||
:disabled="!isAdmin"
|
||||
hint="大屏整屏背景图,建议 1920×1080;留空则不显示背景图。"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="display-title">大屏标题</label>
|
||||
<div class="mx-input-wrap">
|
||||
@@ -243,6 +272,10 @@ async function save(): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* 图片字段要比纯文本框宽:缩略图与两个按钮并排。 */
|
||||
.field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.switch-field {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import { describeError } from './systemApi'
|
||||
import { resolveAssetUrl } from './imagePath'
|
||||
|
||||
/**
|
||||
* 图片字段:文本框(可手填 / 粘贴路径)+ 上传按钮 + 缩略图预览。
|
||||
*
|
||||
* 上传走 POST /api/uploads,把服务端返回的 `/uploads/<随机名>` 写回 v-model;
|
||||
* 服务端的 uploads 目录随赛事包一起导出,所以跨设备转移只需导出整包。
|
||||
* 清空一律写空串而不是 null:服务端对 *string 的 nil 是「保持原值」,
|
||||
* 传 null 会让旧图永远清不掉。
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string | null
|
||||
hint?: string
|
||||
disabled?: boolean
|
||||
accept?: string
|
||||
}>(),
|
||||
{
|
||||
hint: '',
|
||||
disabled: false,
|
||||
accept: 'image/png,image/jpeg,image/webp,image/gif',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
|
||||
|
||||
const toast = useToastStore()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false)
|
||||
const broken = ref(false)
|
||||
/** 上传中的本地即时预览(Blob URL),上传成功后以服务端返回路径为准。 */
|
||||
const localPreview = ref<string | null>(null)
|
||||
|
||||
const hasValue = computed(() => (props.modelValue ?? '').trim() !== '')
|
||||
const previewUrl = computed(() => localPreview.value ?? resolveAssetUrl(props.modelValue))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
broken.value = false
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(revokeLocalPreview)
|
||||
|
||||
function revokeLocalPreview(): void {
|
||||
if (localPreview.value === null) return
|
||||
URL.revokeObjectURL(localPreview.value)
|
||||
localPreview.value = null
|
||||
}
|
||||
|
||||
function choose(): void {
|
||||
if (props.disabled || uploading.value) return
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onInput(event: Event): void {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
revokeLocalPreview()
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
async function onPick(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
// 不清空 value 时连续选同一个文件不会再触发 change。
|
||||
input.value = ''
|
||||
if (file === null) return
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
toast.error('图片过大', `单张图片不能超过 ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`)
|
||||
return
|
||||
}
|
||||
|
||||
revokeLocalPreview()
|
||||
localPreview.value = URL.createObjectURL(file)
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await adminApi.client.uploads.upload(file)
|
||||
revokeLocalPreview()
|
||||
emit('update:modelValue', result.path)
|
||||
toast.success('图片已上传', result.path)
|
||||
} catch (caught) {
|
||||
revokeLocalPreview()
|
||||
toast.error('图片上传失败', describeError(caught))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="img-field">
|
||||
<div class="img-row">
|
||||
<input
|
||||
class="mx-cell-input"
|
||||
type="text"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled"
|
||||
placeholder="可手填相对路径,如 uploads/logo.png"
|
||||
@input="onInput"
|
||||
/>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="disabled || uploading"
|
||||
@click="choose"
|
||||
>
|
||||
<MxIcon name="push" :size="15" /> {{ uploading ? '上传中…' : '上传图片' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasValue"
|
||||
class="mx-btn mx-btn-ghost"
|
||||
type="button"
|
||||
:disabled="disabled || uploading"
|
||||
@click="clear"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" class="file-input" type="file" :accept="accept" @change="onPick" />
|
||||
|
||||
<div v-if="previewUrl !== ''" class="img-preview">
|
||||
<img :src="previewUrl" alt="" @load="broken = false" @error="broken = true" />
|
||||
<span v-if="broken" class="img-note error">图片无法加载,请检查路径</span>
|
||||
<span v-else-if="localPreview !== null" class="img-note">上传中,预览为本地文件</span>
|
||||
</div>
|
||||
<span v-else class="img-note">未设置:留空则不显示</span>
|
||||
|
||||
<span v-if="hint !== ''" class="img-note">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.img-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.img-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.img-row .mx-cell-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
.img-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.img-preview img {
|
||||
max-width: 180px;
|
||||
max-height: 64px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
border: 1px solid var(--mx-border);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.img-note {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.img-note.error {
|
||||
color: var(--mx-error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 图片路径解析(供 <img src> 使用)。
|
||||
*
|
||||
* 上传接口返回的路径本就带 `/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}`
|
||||
}
|
||||
@@ -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<string | null>(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 () => {
|
||||
</div>
|
||||
<div>
|
||||
<dt>构建时间</dt>
|
||||
<dd class="mono">{{ BUILD_TIME }}</dd>
|
||||
<dd class="mono">{{ buildTimeText }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行模式</dt>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
@@ -37,6 +37,115 @@ const announceText = ref('')
|
||||
const announceSeconds = ref(15)
|
||||
const pending = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 显示器菜单:只有「打开服务端管理面板」那个 Electron 窗口里的 shell-preload 会注入
|
||||
* window.desktopShell.displays,浏览器直接打开面板时没有这一组能力。
|
||||
*/
|
||||
interface DesktopDisplayInfo {
|
||||
id: number
|
||||
label: string
|
||||
primary: boolean
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
open: boolean
|
||||
page: string | null
|
||||
mode: 'kiosk' | 'windowed' | null
|
||||
}
|
||||
|
||||
interface DesktopDisplayOpenResult {
|
||||
ok: boolean
|
||||
created: boolean
|
||||
mode: 'kiosk' | 'windowed' | null
|
||||
/** 请求全屏但被 Console 按规则降级时的原因。 */
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface DesktopDisplaysApi {
|
||||
list: () => Promise<{ single: boolean; displays: DesktopDisplayInfo[] }>
|
||||
open: (displayId: number, options: { page: string; fullscreen: boolean }) => Promise<DesktopDisplayOpenResult>
|
||||
close: (displayId: number) => Promise<{ ok: boolean; closed: boolean; message?: string }>
|
||||
relayout: () => Promise<unknown>
|
||||
}
|
||||
|
||||
// window.desktopShell 的全局声明里没有 displays 这一组,这里只在组件内部放宽,不动共享类型
|
||||
const displaysApi =
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: ((window as unknown as { desktopShell?: { displays?: DesktopDisplaysApi } }).desktopShell?.displays ?? null)
|
||||
const hasDisplayMenu = displaysApi !== null
|
||||
|
||||
/** 给新开的窗口锁定的页面,默认总积分榜。 */
|
||||
const windowPage = ref<DisplayPage>('standings')
|
||||
const displayList = ref<DesktopDisplayInfo[]>([])
|
||||
const singleDisplay = ref(true)
|
||||
const displayBusy = ref(false)
|
||||
|
||||
/** 全屏只放行「多屏 + 非主屏」,与 Console 主进程的判定保持一致。 */
|
||||
function canFullscreen(item: DesktopDisplayInfo): boolean {
|
||||
return !singleDisplay.value && !item.primary
|
||||
}
|
||||
|
||||
async function loadDisplays(): Promise<void> {
|
||||
if (!displaysApi) return
|
||||
try {
|
||||
const payload = await displaysApi.list()
|
||||
displayList.value = payload.displays
|
||||
// 单屏与否以 Console 的枚举结果为准,不看浏览器自己的 window.screen
|
||||
singleDisplay.value = payload.single === true
|
||||
} catch (caught) {
|
||||
toast.error('读取显示器列表失败', caught instanceof Error ? caught.message : null)
|
||||
}
|
||||
}
|
||||
|
||||
async function runDisplayAction(label: string, action: () => Promise<void>): Promise<void> {
|
||||
displayBusy.value = true
|
||||
try {
|
||||
await action()
|
||||
} catch (caught) {
|
||||
toast.error(`${label}失败`, caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
displayBusy.value = false
|
||||
await loadDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
async function openDisplayWindow(item: DesktopDisplayInfo): Promise<void> {
|
||||
if (!displaysApi) return
|
||||
await runDisplayAction('打开大屏窗口', async () => {
|
||||
const result = await displaysApi.open(item.id, { page: windowPage.value, fullscreen: canFullscreen(item) })
|
||||
if (!result.ok) {
|
||||
toast.error('打开大屏窗口失败', result.message ?? null)
|
||||
return
|
||||
}
|
||||
// 一律以 Console 返回的实际模式为准:请求全屏也可能被降级成窗口化
|
||||
const mode = result.mode === 'kiosk' ? '全屏窗口' : '窗口化窗口'
|
||||
toast.success(`已${result.created ? '创建' : '聚焦'}「${item.label}」的${mode}`, result.message ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
async function closeDisplayWindow(item: DesktopDisplayInfo): Promise<void> {
|
||||
if (!displaysApi) return
|
||||
await runDisplayAction('关闭大屏窗口', async () => {
|
||||
const result = await displaysApi.close(item.id)
|
||||
if (!result.ok) {
|
||||
toast.error('关闭大屏窗口失败', result.message ?? null)
|
||||
return
|
||||
}
|
||||
toast.success(result.closed ? `已关闭「${item.label}」上的大屏窗口` : `「${item.label}」上没有大屏窗口`)
|
||||
})
|
||||
}
|
||||
|
||||
async function relayoutDisplays(): Promise<void> {
|
||||
if (!displaysApi) return
|
||||
await runDisplayAction('重新排布大屏窗口', async () => {
|
||||
await displaysApi.relayout()
|
||||
toast.success('已请求按设置重建全部大屏窗口')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadDisplays)
|
||||
|
||||
/**
|
||||
* 在线看板(占位)。
|
||||
*
|
||||
@@ -203,6 +312,89 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">显示器</span>
|
||||
<span v-if="hasDisplayMenu" class="mx-badge mx-badge-neutral">{{ displayList.length }} 块屏</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p v-if="!hasDisplayMenu" class="display-note">
|
||||
此功能需要在服务管理窗口里点「打开服务端管理面板」打开的面板中使用。
|
||||
</p>
|
||||
<template v-else>
|
||||
<label class="display-page-pick">
|
||||
<span class="display-pick-label">窗口页面</span>
|
||||
<span class="mx-input-wrap display-pick-select">
|
||||
<select v-model="windowPage" class="mx-input">
|
||||
<option v-for="page in PAGES" :key="page.key" :value="page.key">{{ page.label }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="displayList.length === 0" class="display-note">没有读到显示器,请确认 Console(服务管理程序)正在本机运行。</p>
|
||||
|
||||
<div v-for="item in displayList" :key="item.id" class="display-row">
|
||||
<div class="display-meta">
|
||||
<div class="display-name">
|
||||
{{ item.label }}
|
||||
<span v-if="item.primary" class="mx-badge mx-badge-neutral">主显示器</span>
|
||||
</div>
|
||||
<div class="display-sub">
|
||||
{{ item.width }} × {{ item.height }} · {{ item.scaleFactor }}x ·
|
||||
{{ item.open ? `已开窗口(${item.mode === 'kiosk' ? '全屏' : '窗口化'})` : '未开窗口' }}
|
||||
</div>
|
||||
<div v-if="!item.open && !canFullscreen(item)" class="display-sub">单显示器 / 主显示器只能窗口化</div>
|
||||
</div>
|
||||
<div class="display-actions">
|
||||
<button
|
||||
v-if="item.open"
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="displayBusy"
|
||||
@click="openDisplayWindow(item)"
|
||||
>
|
||||
<MxIcon name="push" :size="15" /> 聚焦窗口
|
||||
</button>
|
||||
<button
|
||||
v-else-if="canFullscreen(item)"
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="displayBusy"
|
||||
@click="openDisplayWindow(item)"
|
||||
>
|
||||
<MxIcon name="display" :size="15" /> 创建全屏窗口
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="displayBusy"
|
||||
@click="openDisplayWindow(item)"
|
||||
>
|
||||
<MxIcon name="display" :size="15" /> 创建窗口(窗口化)
|
||||
</button>
|
||||
<button
|
||||
v-if="item.open"
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="displayBusy"
|
||||
@click="closeDisplayWindow(item)"
|
||||
>
|
||||
<MxIcon name="close" :size="15" /> 关闭窗口
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mx-btn mx-btn-secondary full" type="button" :disabled="displayBusy" @click="relayoutDisplays">
|
||||
<MxIcon name="refresh" :size="15" /> 重新排布(按设置重建全部窗口)
|
||||
</button>
|
||||
<p class="display-note">
|
||||
全屏窗口只在「多块显示器 + 非主显示器」上开放:单显示器或主显示器上开全屏会把桌面整个盖住又没法退出,所以只能开窗口化窗口。副屏全屏窗口被误关后,点该屏的「创建全屏窗口」即可恢复;「重新排布」按 Console 配置的选屏策略重建全部窗口(不在策略内的窗口会被关掉)。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mx-card">
|
||||
@@ -363,6 +555,57 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
.board-hint .mock {
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.display-page-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.display-pick-label {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.display-pick-select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.display-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.display-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.display-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.display-sub {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.display-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.display-note {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.display-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -17,33 +17,42 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
||||
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
|
||||
})
|
||||
|
||||
test('表头含证件号 / 性别可选列,必填列不含这两列', () => {
|
||||
test('表头含证件号 / 性别 / 年龄可选列,必填列不含这三列', () => {
|
||||
assert.deepEqual(
|
||||
[...REQUIRED_COLUMNS],
|
||||
['event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'bib', 'seed', 'status', 'member_names'],
|
||||
[
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
|
||||
'bib', 'seed', 'status', 'member_names',
|
||||
],
|
||||
)
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender'])
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age'])
|
||||
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:一人一行,共 10 列并带上证件号', () => {
|
||||
test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件号 / 号码布', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
teamCode: 'F1',
|
||||
mode: 'individual',
|
||||
athletes: [
|
||||
{ name: '张伟', idCard: '110101199001011234' },
|
||||
{ name: '李强', idCard: null },
|
||||
{ name: '张伟', idCard: '110101199001011234', bib: '101' },
|
||||
{ name: '李强', idCard: null, bib: '102' },
|
||||
{ name: '王芳', idCard: '110101199001011235', bib: null },
|
||||
],
|
||||
})
|
||||
assert.equal(rows.length, 3, '表头 + 2 名运动员')
|
||||
assert.equal(rows.length, 4, '表头 + 3 名运动员')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 10)
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '', 'registered', ''])
|
||||
// 证件号为空时留空列,服务端按「有姓名的行必须填证件号」报错。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '', 'registered', ''])
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', 'registered', ''])
|
||||
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', 'registered', ''])
|
||||
// 只有证件号时 bib 列留空。
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||
@@ -55,8 +64,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||
assert.equal(rows[1]?.length, 10)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||
@@ -69,5 +78,16 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
||||
athletes: [{ name: '王芳' }],
|
||||
memberNames: ['刘洋', '王芳'],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
teamCode: 'F1',
|
||||
mode: 'individual',
|
||||
athletes: [{ name: '无标识' }],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveAssetUrl } from '../components/system/imagePath'
|
||||
|
||||
// 服务端上传接口返回的路径已带 /uploads/ 前缀,这里主要保证手填的历史值也能加载。
|
||||
test('resolveAssetUrl 原样保留已带前缀的路径与绝对地址', () => {
|
||||
assert.equal(resolveAssetUrl('/uploads/a.png'), '/uploads/a.png')
|
||||
assert.equal(resolveAssetUrl('https://cdn.example.com/a.png'), 'https://cdn.example.com/a.png')
|
||||
assert.equal(resolveAssetUrl('HTTP://cdn.example.com/a.png'), 'HTTP://cdn.example.com/a.png')
|
||||
assert.equal(resolveAssetUrl('data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA')
|
||||
})
|
||||
|
||||
test('resolveAssetUrl 给相对路径补上前导斜杠', () => {
|
||||
assert.equal(resolveAssetUrl('uploads/a.png'), '/uploads/a.png')
|
||||
assert.equal(resolveAssetUrl('logos/team1.png'), '/logos/team1.png')
|
||||
assert.equal(resolveAssetUrl(' logos/team1.png '), '/logos/team1.png')
|
||||
})
|
||||
|
||||
// 空值必须返回空串:调用方据此判断「不渲染缩略图」,而不是给出一个坏掉的 <img src>。
|
||||
test('resolveAssetUrl 对空值返回空串', () => {
|
||||
assert.equal(resolveAssetUrl(null), '')
|
||||
assert.equal(resolveAssetUrl(undefined), '')
|
||||
assert.equal(resolveAssetUrl(' '), '')
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ImportConflict } from '@/shared'
|
||||
import type { AthleteInput, ImportConflict } from '@/shared'
|
||||
import { createApiClient } from '@/shared'
|
||||
import {
|
||||
REQUIRED_COLUMNS,
|
||||
@@ -17,10 +17,11 @@ import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
|
||||
return {
|
||||
row: 12,
|
||||
bib: '',
|
||||
athleteId: 42,
|
||||
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: '1001' },
|
||||
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
|
||||
changedFields: ['teamCode'],
|
||||
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
|
||||
changedFields: ['age', 'teamCode', 'bib'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -29,32 +30,49 @@ function csv(rows: string[][]): File {
|
||||
return new File([toCsv([[...REQUIRED_COLUMNS], ...rows])], 'import.csv', { type: 'text/csv' })
|
||||
}
|
||||
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / bib / seed / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, '', '', 'registered', '']
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', 'registered', '']
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段', () => {
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
|
||||
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.field),
|
||||
['name', 'gender', 'teamCode', 'employeeNo'],
|
||||
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.label),
|
||||
['姓名', '性别', '单位', '工号 / 学号'],
|
||||
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.changed),
|
||||
[false, false, true, false],
|
||||
[false, false, true, true, false, true],
|
||||
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
|
||||
)
|
||||
assert.equal(diffs[3]?.incoming, '—', 'null 展示为占位符')
|
||||
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
|
||||
assert.equal(diffs[2]?.incoming, '27')
|
||||
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
|
||||
assert.equal(diffs[5]?.existing, '101')
|
||||
assert.equal(diffs[5]?.incoming, '102')
|
||||
})
|
||||
|
||||
test('recordDecisions 只接受仍存在的证件号,且不改原 map', () => {
|
||||
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
|
||||
const diffs = conflictDiffs(
|
||||
conflict({
|
||||
idCard: '110101199001011234',
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
changedFields: [],
|
||||
}),
|
||||
)
|
||||
const age = diffs.find((diff) => diff.field === 'age')
|
||||
assert.deepEqual([age?.existing, age?.incoming, age?.changed], ['—', '—', false])
|
||||
})
|
||||
|
||||
test('recordDecisions 只接受仍存在的决策键,且不改原 map', () => {
|
||||
const conflicts = [conflict({ idCard: 'A' }), conflict({ idCard: 'B' })]
|
||||
const current = recordDecisions({}, conflicts, ['A', '不存在的证件号'], 'keep-existing')
|
||||
const current = recordDecisions({}, conflicts, ['A', '不存在的键'], 'keep-existing')
|
||||
assert.deepEqual(current, { A: 'keep-existing' })
|
||||
const next = recordDecisions(current, conflicts, ['B'], 'keep-new')
|
||||
assert.deepEqual(next, { A: 'keep-existing', B: 'keep-new' })
|
||||
@@ -63,20 +81,44 @@ test('recordDecisions 只接受仍存在的证件号,且不改原 map', () =>
|
||||
|
||||
test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收敛', () => {
|
||||
const conflicts = [conflict({ idCard: 'B' }), conflict({ idCard: 'A' })]
|
||||
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的证件号: 'keep-new' }
|
||||
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的键: 'keep-new' }
|
||||
assert.deepEqual(pruneDecisions(decisions, conflicts), { A: 'keep-new', B: 'keep-existing' })
|
||||
assert.deepEqual(
|
||||
decisionsPayload(decisions, conflicts),
|
||||
[
|
||||
{ idCard: 'B', action: 'keep-existing' },
|
||||
{ idCard: 'A', action: 'keep-new' },
|
||||
{ idCard: 'B', bib: '', action: 'keep-existing' },
|
||||
{ idCard: 'A', bib: '', action: 'keep-new' },
|
||||
],
|
||||
'载荷顺序与冲突列表一致',
|
||||
'载荷顺序与冲突列表一致,证件号与号码布都带上',
|
||||
)
|
||||
assert.deepEqual(summarizeDecisions(decisions, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
|
||||
assert.deepEqual(summarizeDecisions({}, conflicts), { keepExisting: 0, keepNew: 0, undecided: 2 })
|
||||
})
|
||||
|
||||
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
|
||||
|
||||
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
|
||||
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
|
||||
assert.deepEqual(first, { 101: 'keep-existing' })
|
||||
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
|
||||
|
||||
const both = recordDecisions(first, conflicts, ['102'], 'keep-new')
|
||||
assert.deepEqual(both, { 101: 'keep-existing', 102: 'keep-new' }, '两条号码布各留各的决策')
|
||||
assert.deepEqual(
|
||||
decisionsPayload(both, conflicts),
|
||||
[
|
||||
{ idCard: '', bib: '101', action: 'keep-existing' },
|
||||
{ idCard: '', bib: '102', action: 'keep-new' },
|
||||
],
|
||||
'载荷两条都在,且分别带自己的号码布供服务端算决策键',
|
||||
)
|
||||
assert.deepEqual(summarizeDecisions(both, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
|
||||
assert.deepEqual(pruneDecisions({ 101: 'keep-existing', 102: 'keep-new' }, [conflicts[1] as ImportConflict]), {
|
||||
102: 'keep-new',
|
||||
})
|
||||
})
|
||||
|
||||
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
@@ -99,7 +141,7 @@ test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', as
|
||||
const mock = new MockBaseData()
|
||||
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
||||
assert.equal(missing.skipped, 1)
|
||||
assert.match(missing.errors[0] ?? '', /必须填证件号/)
|
||||
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
|
||||
|
||||
const duplicated = await mock.importEntries(
|
||||
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
|
||||
@@ -121,9 +163,10 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
|
||||
assert.deepEqual(conflicted.conflicts[0], {
|
||||
row: 2,
|
||||
idCard,
|
||||
bib: '',
|
||||
athleteId,
|
||||
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: null },
|
||||
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
|
||||
changedFields: ['teamCode'],
|
||||
})
|
||||
|
||||
@@ -132,7 +175,7 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
|
||||
assert.equal(again.conflicts.length, 1)
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||
{ idCard, action: 'keep-existing' },
|
||||
{ idCard, bib: '', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.conflicts.length, 0)
|
||||
assert.equal(keptExisting.created, 1)
|
||||
@@ -140,7 +183,7 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
|
||||
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||
{ idCard, action: 'keep-new' },
|
||||
{ idCard, bib: '', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.conflicts.length, 0)
|
||||
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
||||
@@ -161,7 +204,7 @@ test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async
|
||||
},
|
||||
})
|
||||
const file = new File(['a'], 'entries.csv', { type: 'text/csv' })
|
||||
const decisions = [{ idCard: '110101199001010001', action: 'keep-new' as const }]
|
||||
const decisions = [{ idCard: '110101199001010001', bib: '101', action: 'keep-new' as const }]
|
||||
const result = await client.entries.resolveImport(file, decisions)
|
||||
|
||||
assert.equal(captured.url, '/api/entries/import/resolve')
|
||||
@@ -177,3 +220,204 @@ test('导入:gender 只认男 / 女,其它值忽略', async () => {
|
||||
const athlete = mock.listAthletes().find((item) => item.idCard === '110101199001010005')
|
||||
assert.equal(athlete?.gender, null)
|
||||
})
|
||||
|
||||
test('导入:有姓名的行只填号码布也能建人员', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([row('号布甲', '', 'F1', '男', '8001')]))
|
||||
assert.equal(result.created, 1)
|
||||
assert.deepEqual(result.conflicts, [])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
const athlete = mock.listAthletes().find((item) => item.bib === '8001')
|
||||
assert.equal(athlete?.name, '号布甲')
|
||||
assert.equal(athlete?.idCard, null)
|
||||
})
|
||||
|
||||
test('导入:号码布命中已有人员,不再新建', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
// 种子数据里 JG 的「张伟」号码布为 101。
|
||||
const result = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '101')]))
|
||||
assert.equal(result.created, 1)
|
||||
assert.deepEqual(result.conflicts, [])
|
||||
assert.equal(mock.listAthletes().length, before, '号码布命中已有人员,不新增')
|
||||
})
|
||||
|
||||
test('导入:姓名 + 单位命中且证件号为空时回填证件号并保留已有号码布', async () => {
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 F1 的「陈晨」证件号为空、号码布为 202。
|
||||
const athlete = mock.listAthletes().find((item) => item.name === '陈晨')
|
||||
assert.equal(athlete?.bib, '202')
|
||||
const result = await mock.importEntries(csv([row('陈晨', '110101199001010006', 'F1', '女')]))
|
||||
assert.equal(result.created, 1)
|
||||
assert.equal(result.conflicts.length, 0)
|
||||
const after = mock.listAthletes().find((item) => item.id === athlete?.id)
|
||||
assert.equal(after?.idCard, '110101199001010006')
|
||||
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
|
||||
})
|
||||
|
||||
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
|
||||
const idCard = '110101199001010007'
|
||||
const mock = new MockBaseData()
|
||||
// JG 的「张伟」证件号为空、号码布为 101,先用姓名 + 单位回填证件号。
|
||||
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
|
||||
const athleteId = mock.listAthletes().find((item) => item.idCard === idCard)?.id
|
||||
assert.ok(athleteId !== undefined)
|
||||
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
|
||||
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.equal(conflict?.idCard, idCard)
|
||||
assert.equal(conflict?.existing.bib, '101')
|
||||
assert.equal(conflict?.incoming.bib, '999')
|
||||
assert.deepEqual(conflict?.changedFields, ['bib'])
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
||||
{ idCard, bib: '999', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.created, 1)
|
||||
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '101')
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
||||
{ idCard, bib: '999', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.created, 1)
|
||||
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '999')
|
||||
})
|
||||
|
||||
test('导入:同一文件里号码布重复按行报错', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const result = await mock.importEntries(
|
||||
csv([row('号布甲', '', 'F1', '男', '8101'), row('号布乙', '', 'F2', '男', '8101')]),
|
||||
)
|
||||
assert.equal(result.created, 1, '第一行仍导入')
|
||||
assert.equal(result.skipped, 1)
|
||||
assert.match(result.errors[0] ?? '', /号码布 8101 在文件中重复出现/)
|
||||
})
|
||||
|
||||
test('导入(mock):只有号码布的两条冲突各按自己的号码布决策', async () => {
|
||||
const mock = new MockBaseData()
|
||||
await mock.importEntries(csv([row('号布甲', '', 'F1', '男', '9101'), row('号布乙', '', 'F2', '男', '9102')]))
|
||||
|
||||
// 号码布命中同一人但单位对调 → 两条冲突,证件号都是空串。
|
||||
const conflicted = await mock.importEntries(
|
||||
csv([row('号布甲', '', 'F2', '男', '9101'), row('号布乙', '', 'F1', '男', '9102')]),
|
||||
)
|
||||
assert.deepEqual(
|
||||
conflicted.conflicts.map((item) => [item.idCard, item.bib]),
|
||||
[
|
||||
['', '9101'],
|
||||
['', '9102'],
|
||||
],
|
||||
)
|
||||
|
||||
const resolved = await mock.resolveImportConflicts(
|
||||
csv([row('号布甲', '', 'F2', '男', '9101'), row('号布乙', '', 'F1', '男', '9102')]),
|
||||
[{ idCard: '', bib: '9101', action: 'keep-new' }],
|
||||
)
|
||||
assert.equal(resolved.created, 1, '只处理已决策的那条')
|
||||
assert.deepEqual(
|
||||
resolved.conflicts.map((item) => item.bib),
|
||||
['9102'],
|
||||
'没决策的号码布仍留在冲突里,没被另一条的决策带走',
|
||||
)
|
||||
const f2 = mock.listTeams().find((team) => team.code === 'F2')?.id
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '9101')?.teamId, f2)
|
||||
})
|
||||
|
||||
test('导入:库里没年龄时表里的年龄只回填,不算冲突;库里已有年龄则不覆盖', async () => {
|
||||
const idCard = '110101199001010009'
|
||||
const mock = new MockBaseData()
|
||||
await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男')]))
|
||||
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, null)
|
||||
|
||||
// 旧数据没有年龄列:表里带年龄也不能把所有人标成冲突。
|
||||
const filled = await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男', '', '31')]))
|
||||
assert.deepEqual(filled.conflicts, [], '库中年龄为空不算差异')
|
||||
assert.equal(filled.created, 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 31, '回填到库里')
|
||||
|
||||
// 库里已有年龄:表里留空不得清掉。
|
||||
const kept = await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男')]))
|
||||
assert.deepEqual(kept.conflicts, [])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 31, '空值不清掉已有年龄')
|
||||
})
|
||||
|
||||
test('导入:年龄两侧都有值且不同记为冲突,决策后按所选一侧处理', async () => {
|
||||
const idCard = '110101199001010010'
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 JG 的「张伟」号码布为 101、年龄为 22。
|
||||
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
|
||||
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 22)
|
||||
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '', '30')]))
|
||||
assert.equal(conflicted.created, 0, '年龄冲突同样不落库')
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.deepEqual([conflict?.existing.age, conflict?.incoming.age], [22, 30])
|
||||
assert.deepEqual(conflict?.changedFields, ['age'])
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '', '30')]), [
|
||||
{ idCard, bib: '', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.created, 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 22, '保留已有年龄')
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '', '30')]), [
|
||||
{ idCard, bib: '', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.created, 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 30, '使用新数据时年龄更新')
|
||||
})
|
||||
|
||||
test('导入:年龄非整数 / 超出 0–120 的行报错并跳过', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
const bad = await mock.importEntries(
|
||||
csv([
|
||||
row('年龄非整数', '110101199001010011', 'F1', '男', '', '26.5'),
|
||||
row('年龄超范围', '110101199001010012', 'F1', '男', '', '121'),
|
||||
row('年龄非数字', '110101199001010013', 'F1', '男', '', '二十'),
|
||||
]),
|
||||
)
|
||||
assert.equal(bad.skipped, 3)
|
||||
assert.equal(bad.created, 0)
|
||||
assert.equal(mock.listAthletes().length, before, '非法行不建人员')
|
||||
for (const error of bad.errors) assert.match(error, /年龄必须是 0–120 的整数/)
|
||||
|
||||
// 团体 / 对抗行没有姓名,年龄不参与校验。
|
||||
const team = await mock.importEntries(csv([row('', '', 'F1', '', '', '999')]))
|
||||
assert.equal(team.created, 1)
|
||||
assert.deepEqual(team.errors, [])
|
||||
})
|
||||
|
||||
test('MockBaseData 新增 / 更新人员时校验年龄范围', () => {
|
||||
const mock = new MockBaseData()
|
||||
const base: AthleteInput = {
|
||||
name: '年龄校验', gender: null, age: 26, teamId: 1, employeeNo: null, idCard: null, bib: null, active: true,
|
||||
}
|
||||
mock.createAthlete(base)
|
||||
const id = mock.listAthletes().find((athlete) => athlete.name === '年龄校验')?.id
|
||||
assert.ok(id !== undefined)
|
||||
assert.throws(() => mock.createAthlete({ ...base, age: 121 }), /年龄必须是 0–120 的整数/)
|
||||
assert.throws(() => mock.updateAthlete(id, { ...base, age: 26.5 }), /年龄必须是 0–120 的整数/)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === id)?.age, 26, '校验失败不改动原数据')
|
||||
assert.equal(mock.updateAthlete(id, { ...base, age: null }).age, null, '年龄可以清空')
|
||||
})
|
||||
|
||||
test('MockBaseData 新增人员时证件号、号码布重复分别报错', () => {
|
||||
const mock = new MockBaseData()
|
||||
const base = { name: '唯一性测试', gender: null, age: null, teamId: 1, employeeNo: null, active: true } as const
|
||||
mock.createAthlete({ ...base, idCard: '110101199001010008', bib: '9001' })
|
||||
assert.throws(
|
||||
() => mock.createAthlete({ ...base, idCard: '110101199001010008', bib: null }),
|
||||
/证件号 .* 已被其他人员使用/,
|
||||
)
|
||||
assert.throws(
|
||||
() => mock.createAthlete({ ...base, idCard: null, bib: '9001' }),
|
||||
/号码布 .* 已被其他人员使用/,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createApiClient } from '@/shared/api'
|
||||
|
||||
// 上传端点的契约由服务端定义(multipart 字段名 file,响应 {ok,data:{path,url}}),
|
||||
// 这里钉住前端这一侧:字段名或路径写错时,图片会在真机上静默 400。
|
||||
test('uploads.upload 以 multipart 字段 file POST /api/uploads 并返回路径', async () => {
|
||||
const captured: { url?: string; method?: string; body?: FormData } = {}
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
fetchImpl: async (input, init) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method
|
||||
captured.body = init?.body as FormData
|
||||
return new Response(JSON.stringify({ ok: true, data: { path: '/uploads/ab12.png', url: '/uploads/ab12.png' } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const file = new File(['x'], 'team.png', { type: 'image/png' })
|
||||
const result = await client.uploads.upload(file)
|
||||
|
||||
assert.equal(captured.url, '/api/uploads')
|
||||
assert.equal(captured.method, 'POST')
|
||||
assert.equal(captured.body?.get('file'), file)
|
||||
assert.deepEqual(result, { path: '/uploads/ab12.png', url: '/uploads/ab12.png' })
|
||||
})
|
||||
|
||||
test('uploads.upload 把服务端的错误文案原样抛出', async () => {
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
JSON.stringify({ ok: false, error: { code: 'VALIDATION_FAILED', message: '图片超过 5 MiB 上限' } }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => client.uploads.upload(new File(['x'], 'big.png', { type: 'image/png' })),
|
||||
(error: Error) => error.message === '图片超过 5 MiB 上限',
|
||||
)
|
||||
})
|
||||
@@ -7,9 +7,10 @@ import OfflineBadge from './OfflineBadge.vue'
|
||||
const { settings, options } = useScoreboard()
|
||||
|
||||
const now = ref(new Date().toISOString())
|
||||
// 右上角时钟精确到秒,所以每秒刷新一次;15 秒的旧间隔会让秒针跳格。
|
||||
const timer = window.setInterval(() => {
|
||||
now.value = new Date().toISOString()
|
||||
}, 15_000)
|
||||
}, 1_000)
|
||||
onUnmounted(() => window.clearInterval(timer))
|
||||
|
||||
const display = computed(() => settings.value?.display ?? null)
|
||||
|
||||
@@ -144,6 +144,17 @@ export interface EntriesApi {
|
||||
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
|
||||
}
|
||||
|
||||
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
|
||||
export interface UploadResult {
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface UploadsApi {
|
||||
/** 仅图片(png / jpeg / webp / gif),单文件上限由服务端限制。 */
|
||||
upload(file: File): Promise<UploadResult>
|
||||
}
|
||||
|
||||
export interface ResultsApi {
|
||||
list(query: ResultQuery): Promise<Page<Result>>
|
||||
saveDraft(payload: SaveDraftPayload): Promise<Result[]>
|
||||
@@ -201,6 +212,12 @@ export interface SystemApi {
|
||||
backup(): Promise<BackupResult>
|
||||
}
|
||||
|
||||
/** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */
|
||||
export interface SystemAbout {
|
||||
internalSignature?: string
|
||||
buildTime?: string
|
||||
}
|
||||
|
||||
interface Http {
|
||||
request<T>(path: string, options?: RequestOptions): Promise<T>
|
||||
requestVoid(path: string, options?: RequestOptions): Promise<void>
|
||||
@@ -324,6 +341,7 @@ export class ApiClient {
|
||||
readonly auditLogs: AuditApi
|
||||
readonly rules: RulesApi
|
||||
readonly system: SystemApi
|
||||
readonly uploads: UploadsApi
|
||||
|
||||
private readonly baseUrl: string
|
||||
private readonly credentials: RequestCredentials
|
||||
@@ -429,6 +447,14 @@ export class ApiClient {
|
||||
this.system = {
|
||||
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
|
||||
}
|
||||
|
||||
this.uploads = {
|
||||
upload: (file) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return http.request<UploadResult>('/uploads', { method: 'POST', body: form })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */
|
||||
|
||||
@@ -102,8 +102,8 @@ export function formatDateTime(iso: string | null): string {
|
||||
/** ISO → `HH:mm`,大屏顶栏时钟用。 */
|
||||
export function formatClock(iso: string | null): string {
|
||||
const date = toDate(iso)
|
||||
if (date === null) return '--:--'
|
||||
return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}`
|
||||
if (date === null) return '--:--:--'
|
||||
return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}:${DATE_PAD(date.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 带时区后缀(Z 或 ±HH:MM)的时间串按原样解析。 */
|
||||
|
||||
@@ -24,5 +24,5 @@ test('formatSeconds 在 fractionDigits=0 时秒数仍补足两位', () => {
|
||||
|
||||
test('formatDateTime 对空值返回占位', () => {
|
||||
assert.equal(formatDateTime(null), '—')
|
||||
assert.equal(formatClock(null), '--:--')
|
||||
assert.equal(formatClock(null), '--:--:--')
|
||||
})
|
||||
+14
-2
@@ -27,8 +27,12 @@ export interface Athlete {
|
||||
gender: Gender | null
|
||||
teamId: number
|
||||
employeeNo: string | null
|
||||
/** 人员唯一标识(证件号);历史数据可能为空。 */
|
||||
/** 人员唯一标识之一(证件号);与 bib 二者取其一即可,历史数据可能都为空。 */
|
||||
idCard: string | null
|
||||
/** 人员唯一标识之一(号码布);与 idCard 二者取其一即可。 */
|
||||
bib: string | null
|
||||
/** 年龄;可空,历史数据与团体报名都可能没有。 */
|
||||
age: number | null
|
||||
active: boolean
|
||||
}
|
||||
|
||||
@@ -156,17 +160,23 @@ export interface EntryWithResult extends Entry {
|
||||
export interface ImportConflictFields {
|
||||
name: string
|
||||
gender: Gender | null
|
||||
/** 年龄;两侧都有值且不同才算差异,空值视为未指定。 */
|
||||
age: number | null
|
||||
teamCode: string
|
||||
employeeNo: string | null
|
||||
/** 人员级号码布;参与冲突差异展示。 */
|
||||
bib: string | null
|
||||
}
|
||||
|
||||
export type ImportConflictField = keyof ImportConflictFields
|
||||
|
||||
/** 同一证件号在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
|
||||
/** 同一证件号 / 号码布在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
|
||||
export interface ImportConflict {
|
||||
/** 报名表原始行号(含表头,从 1 开始)。 */
|
||||
row: number
|
||||
idCard: string
|
||||
/** 报名表该行的号码布;证件号为空时它就是这条冲突的决策键。 */
|
||||
bib: string
|
||||
athleteId: number
|
||||
existing: ImportConflictFields
|
||||
incoming: ImportConflictFields
|
||||
@@ -176,8 +186,10 @@ export interface ImportConflict {
|
||||
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
|
||||
export type ConflictAction = 'keep-existing' | 'keep-new'
|
||||
|
||||
/** 决策键由服务端判定:证件号优先,为空时取号码布,因此两个字段都要带。 */
|
||||
export interface ImportConflictDecision {
|
||||
idCard: string
|
||||
bib: string
|
||||
action: ConflictAction
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user