feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发) - 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、 手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。 - 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错 (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。 ## 实时赛况(新大屏页 + 定向接口) - 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩 (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。 设置键 display.live_events / display.live_source。 - 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面 (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。 - 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only), 故对非管理员整组置灰 + 提示条(不新增角色)。 ## 显式批次层(组别之下) - 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果 完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 + 一键均分(按 N 人切片,已有批次号接着往后排)。 ## 成绩输入框实时悬浮提示 - 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻; 合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文; 与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。 ## 窄屏 / 手机端布局 - 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针, 四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框 塌成 22px 这类真问题。 ## 版本号单一真相 - 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json 的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。 测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
This commit is contained in:
1 parent
e5e0944d26
commit
08a5740fb2
64 files changed
+4968
-270
No files matched your search
@@ -15,6 +15,8 @@ rules/*
|
||||
# 内部署名(构建期注入,绝不进仓库)
|
||||
deploy/signature.local.txt
|
||||
deploy/signature.build.txt
|
||||
# 版本注入脚本(构建时由 deploy/prepare-extra.mjs 从仓库根 VERSION 生成)
|
||||
deploy/version.gen.bat
|
||||
# 安装器自定义脚本:许可页署名 + 上次安装目录记忆(构建时生成)
|
||||
console/resources/installer-custom.nsh
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
- **变更历史**:仿 Git 图谱,一眼看清成绩是怎么变成现在这样的
|
||||
- **现场公开**:积分明细可下钻,争议时一键推送到大屏
|
||||
- **全屏公告**:推送后先全屏显示(5–120 秒),随即常驻大屏底部公告栏,直到替换或清除
|
||||
- **分批发布**:一个项目分多场跑(如跑道 8 条、30 人分 4 场)时,**每跑完一批就能先发布一批**——空成绩只是提示、**不再挡发布**,未录的行自动跳过,补录后再发一次即自动重算;名次与积分先按已发布的那部分算,大屏立刻有内容
|
||||
- **实时赛况大屏页**:新增「实时赛况」页,显示当前进行中项目的实时排名(名次 / 运动员·队 / 单位 / 成绩);1 个项目单栏、2 个自动左右分栏;当前项目按「人工指定 → 排期命中(开始时间前后 30 分钟)→ 最近发布过成绩的项目」判定,**下一场开始前持续显示上一场**;可在「大屏控制 → 实时赛况」卡片配置
|
||||
- **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分)
|
||||
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗;「大屏控制」页的「在线大屏」列表实时显示每个在线实例(含局域网浏览器打开的 `/display`)当前在哪一页,可逐屏 / 逐设备单独切页
|
||||
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ pnpm dist # electron-builder --win --x64 → ../dist/console/
|
||||
pnpm pack # 只出目录,不生成安装包,便于现场调试
|
||||
```
|
||||
|
||||
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。
|
||||
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.1-x64.*`)。
|
||||
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。
|
||||
- 部署包根目录的说明书是可选的:`deploy/prepare-extra.mjs` 在打包前按一张清单暂存到 `console/extra/`(`extraFiles` 会把该目录内容平铺到安装目录根):`docs/使用手册.md` → `使用说明.txt`,`docs/报名名单导入规范.md` → `报名名单导入规范.txt`(各自优先取 `docs/` 下的同名 `.txt`)。缺哪个就在构建日志里注明并跳过。
|
||||
- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scoreboard-console",
|
||||
"productName": "Scoreboard Console",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"author": "AskaEth",
|
||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||
|
||||
+12
-3
@@ -27,6 +27,11 @@ call pnpm build || exit /b 1
|
||||
cd ..
|
||||
|
||||
echo === [3/4] server build ===
|
||||
REM version: repo-root VERSION is the single source of truth. The Node script
|
||||
REM writes deploy\version.gen.bat (pure ASCII, for cmd) and syncs the installer
|
||||
REM version in console\package.json. Must run before the go build below.
|
||||
node deploy\prepare-extra.mjs || exit /b 1
|
||||
call deploy\version.gen.bat || exit /b 1
|
||||
cd server
|
||||
if not exist ..\dist mkdir ..\dist
|
||||
REM internal signature is injected from a machine-local file (gitignored, never in repo).
|
||||
@@ -36,6 +41,9 @@ 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 compile-time version: -X main.version targets package main (no import path),
|
||||
REM unlike the two vars below which are import-path-qualified.
|
||||
set "VERSION_LDFLAG=-X 'main.version=%SCOREBOARD_VERSION%'"
|
||||
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="
|
||||
@@ -43,14 +51,15 @@ for /f "usebackq delims=" %%T in (`powershell -NoProfile -Command "(Get-Date).To
|
||||
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%' %BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1
|
||||
go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%' %VERSION_LDFLAG% %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 -ldflags "%BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1
|
||||
go build -ldflags "%VERSION_LDFLAG% %BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1
|
||||
echo no internal signature
|
||||
)
|
||||
echo build time %BUILD_TIME%
|
||||
echo version %SCOREBOARD_VERSION%
|
||||
cd ..
|
||||
|
||||
echo === [4/4] console package (electron-builder) ===
|
||||
@@ -64,7 +73,7 @@ if not exist "rules" (
|
||||
echo [ERROR] rules dir missing - electron-builder extraFiles needs it
|
||||
exit /b 1
|
||||
)
|
||||
node deploy\prepare-extra.mjs || exit /b 1
|
||||
REM prepare-extra.mjs already ran in step 3 (version, user docs, installer script).
|
||||
cd console
|
||||
REM console\pnpm-workspace.yaml uses allowBuilds so electron's install script is
|
||||
REM allowed to run; do NOT add --ignore-scripts here, it makes pnpm run fail its
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 构建前准备两样东西:
|
||||
// 构建前准备三样东西:
|
||||
// 1) 把 docs/ 下的使用说明与报名名单导入规范暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录;
|
||||
// 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件)
|
||||
// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)。
|
||||
// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径);
|
||||
// 3) 读仓库根 VERSION 文件:生成 deploy/version.gen.bat(供 build.bat 注入 Go 二进制)
|
||||
// 并同步 console/package.json 的 version(决定安装包/免安装包的文件名)。
|
||||
// 用 Node 而不是批处理:中文文件名/中文内容在 .bat 里会被 cmd 解析错乱。
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -104,3 +106,48 @@ writeFileSync(nshFile, nsh, 'utf8')
|
||||
// 改名前生成的旧脚本已经没人引用,构建机上顺手清掉,免得看起来还在用。
|
||||
rmSync(legacyNshFile, { force: true })
|
||||
console.log(` installer custom script ready (${existsSync(signatureFile) ? 'with' : 'without'} internal signature)`)
|
||||
|
||||
// ---- 3. version -------------------------------------------------------------
|
||||
// 版本号只维护仓库根的 VERSION 文件(唯一真相):这里读出来分两路用——
|
||||
// deploy/version.gen.bat 给 build.bat,编译期用 -X main.version 写进二进制;
|
||||
// console/package.json electron-builder 据此给安装包/免安装包命名。
|
||||
// 二进制里的版本在编译那一刻定死,运行期不再读任何文件。
|
||||
const versionFile = join(root, 'VERSION')
|
||||
if (!existsSync(versionFile)) {
|
||||
console.error(' [version] missing VERSION file at repo root')
|
||||
process.exit(1)
|
||||
}
|
||||
const version = (readFileSync(versionFile, 'utf8')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.trim() !== '') ?? '').trim()
|
||||
if (version === '') {
|
||||
console.error(' [version] VERSION file is empty')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 必须纯 ASCII:cmd.exe 会错解 .bat 里的非 ASCII 文本(见本文件头部说明)。
|
||||
// 用 CRLF:与 .gitattributes 里 *.bat 的约定一致,cmd 在 Windows 上按 CRLF 读最稳。
|
||||
// 只 set 不加 setlocal:build.bat 用 call 调它,变量要留在调用方的环境里。
|
||||
const genBatFile = join(root, 'deploy', 'version.gen.bat')
|
||||
const genBat = [
|
||||
'@echo off',
|
||||
'REM Generated by deploy/prepare-extra.mjs from the VERSION file - do not edit.',
|
||||
`set "SCOREBOARD_VERSION=${version}"`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
writeFileSync(genBatFile, genBat, 'utf8')
|
||||
console.log(` version ${version} -> deploy/version.gen.bat`)
|
||||
|
||||
// console/package.json 的 version 是 semver,不带 dev_ 前缀(dev_ 只用于二进制版本号)。
|
||||
// 逐字替换而不是 JSON.parse/stringify:保住原有缩进与末尾换行,改动最小。
|
||||
const pkgVersion = version.replace(/^dev_/, '')
|
||||
const pkgFile = join(root, 'console', 'package.json')
|
||||
const pkgRaw = readFileSync(pkgFile, 'utf8')
|
||||
const pkgNext = pkgRaw.replace(/^(\s*"version"\s*:\s*)"[^"]*"/m, `$1"${pkgVersion}"`)
|
||||
if (pkgNext === pkgRaw) {
|
||||
console.log(` console/package.json version is ${pkgVersion}`)
|
||||
} else {
|
||||
writeFileSync(pkgFile, pkgNext, 'utf8')
|
||||
console.log(` console/package.json version -> ${pkgVersion}`)
|
||||
}
|
||||
+5
-3
@@ -6,11 +6,11 @@
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `Scoreboard Console-0.1.0-x64.exe` | NSIS 安装包(推荐),双击按向导装 |
|
||||
| `Scoreboard Console-0.1.0-x64.zip` | 免安装包,解压即用 |
|
||||
| `Scoreboard Console-0.1.1-x64.exe` | NSIS 安装包(推荐),双击按向导装 |
|
||||
| `Scoreboard Console-0.1.1-x64.zip` | 免安装包,解压即用 |
|
||||
| `scoreboard.exe` | 服务端本体(已内嵌前端页面) |
|
||||
|
||||
> 校验完整性:`certutil -hashfile "Scoreboard Console-0.1.0-x64.exe" SHA256`
|
||||
> 校验完整性:`certutil -hashfile "Scoreboard Console-0.1.1-x64.exe" SHA256`
|
||||
|
||||
## 2. 目标机(服务端主机)
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
|
||||
- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常)
|
||||
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
|
||||
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
|
||||
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
|
||||
|
||||
## 6. 数据与备份
|
||||
|
||||
|
||||
+45
-14
@@ -88,7 +88,7 @@
|
||||
- 距离 / 计数 / 得分类:按工具栏提示的单位填数字,如米 `12.34`、次 `12`、分 `96.5`;
|
||||
- 秒位 ≥ 60 仍然拒绝(`1:70` 不是 70 秒),但报错会写明原因(见 4.4)。
|
||||
|
||||
**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「成绩为空」拦下。过去示例文本长得和真实输入一样,容易看成「我已经填了」。
|
||||
**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「还没录」自动跳过(本次不发布这一行),不会连累其他已填成绩的发布。过去示例文本长得和真实输入一样,容易看成「我已经填了」。
|
||||
|
||||
右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。
|
||||
|
||||
@@ -110,26 +110,40 @@
|
||||
|
||||
### 4.4 防呆校验
|
||||
|
||||
页面下方「防呆校验」会实时列出问题:
|
||||
页面下方「防呆校验」会实时列出问题,分两档:
|
||||
|
||||
- **错误(阻止发布)**:成绩格有三种原因,文案都会**把那一格的实际内容原样打出来**,并对症给出改法,不会只说一句「缺少成绩」让你去改本来就对的格式:
|
||||
- 空格子:`第 3 行 张三:成绩为空(当前「(空)」),请填写成绩`;
|
||||
- **错误(阻止发布)**:只有**会让名次或积分算错**的才挡发布,文案都会**把那一格的实际内容原样打出来**并对症给改法:
|
||||
- 秒位 ≥ 60:`第 3 行 张三:秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类`;
|
||||
- 写法无法识别:`第 3 行 张三:成绩写法无法识别(当前「约1分06秒」),可用 66、66.33、1:06、1:06.33、1分06秒`——非时间单位会换成对应的数字提示(不会拿时间格式去教距离项目);
|
||||
- 另外还有:手动名次项目缺少名次。
|
||||
- **提醒(不阻止发布)**:成绩 ≤ 0、名次重复(请确认是否并列)、成绩与赛会纪录差距过大。
|
||||
- 手动名次项目缺少名次。
|
||||
- **提醒(不阻止发布)**:
|
||||
- **成绩还没录**(空格子):`第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行`。这是中性状态,**不挡发布**——跑完一批就能先发一批,剩下没跑的补录后再发(见 4.6);
|
||||
- 成绩 ≤ 0、名次重复(请确认是否并列)、成绩与赛会纪录差距过大。
|
||||
|
||||
有错误时点「发布」会被拦下并提示第一处问题。
|
||||
有**错误**时点「发布」会被拦下并提示第一处问题;只有提醒(含还没录的行)时发布会照常进行,未录的行自动跳过。
|
||||
|
||||
### 4.5 存草稿与发布
|
||||
|
||||
1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。
|
||||
- **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。
|
||||
2. **发布**:点击后弹出确认框,显示本次影响的单位数与最大分差;确认后成绩立即上屏并重算积分榜。
|
||||
2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜。
|
||||
- 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存;
|
||||
- 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。
|
||||
|
||||
### 4.6 撤销发布
|
||||
### 4.6 分批发布(跑一批发一批)
|
||||
|
||||
跑道只有 8 条、一个项目可能要分好几场跑时,**不需要等所有人都跑完**:每跑完一批就发一批,没跑到的先空着。
|
||||
|
||||
1. 这一批跑完,把已出的成绩填上(其他还没跑的行保持空白即可);
|
||||
2. 点「发布」(该组已有发布过的成绩、且还有没发的已填行时,按钮显示为「**发布本批**」);
|
||||
3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去;
|
||||
4. 确认后这批成绩立即上屏,**名次与积分先按已发布的这部分算**,大屏立刻有内容;
|
||||
5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新;
|
||||
6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。
|
||||
|
||||
> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。
|
||||
|
||||
### 4.7 撤销发布
|
||||
|
||||
已发布的组别会出现「撤销发布」按钮:
|
||||
|
||||
@@ -148,10 +162,10 @@
|
||||
3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化;
|
||||
4. 「发布影响」列出受影响单位与分值变化;
|
||||
5. 操作:
|
||||
- **发布整组**:确认后整组发布并重算积分榜;
|
||||
- **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜,没录的保持未发布,补录后再发一次即可(分批发布,见 4.6);
|
||||
- **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。
|
||||
|
||||
> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效。
|
||||
> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。
|
||||
|
||||
---
|
||||
|
||||
@@ -240,7 +254,7 @@
|
||||
|
||||
| 区块 | 操作 |
|
||||
| --- | --- |
|
||||
| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 |
|
||||
| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 实时赛况 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 |
|
||||
| 锁定与刷新 | **锁定**:停止自动轮播并停在当前页;**解锁**:恢复轮播;**强制刷新**:让所有大屏重新拉取快照,用于排障 |
|
||||
| 公告 | 输入公告文字,设置「滚动时长」(5–120 秒,实际是全屏展示的秒数),点「推送公告」;点「清除公告」撤下当前公告 |
|
||||
|
||||
@@ -248,6 +262,14 @@
|
||||
|
||||
**公告**:点「推送公告」后所有大屏先**全屏**展示这条公告,到时间后自动退回原来的页面(没锁定就继续轮播),公告正文留在底栏的「公告」槽位继续显示,直到被新公告替换或点「清除公告」。公告卡片顶部徽标显示「当前公告:有 / 无」,徽标下面那一行就是当前公告正文(没有公告时显示「暂无公告(大屏底栏显示占位)」)。公告会随赛事设置保存:新开的大屏窗口、断线重连之后,会先把当前公告再全屏播一遍(默认 15 秒)后留在底栏;点「清除公告」后底栏回到「暂无公告」占位。
|
||||
|
||||
**实时赛况**:「实时赛况」卡片决定大屏「实时赛况」页显示哪几个项目(**最多 2 个**)。
|
||||
|
||||
- **自动判定 / 人工指定**:卡片右上角徽标显示当前模式。选「自动判定」时按三条依据依次判定——**人工指定 → 排期命中(项目开始时间前后 30 分钟内)→ 最近发布过成绩的项目**;选「人工指定」时只显示下面指定的项目,不做任何回退;
|
||||
- **项目 1 / 项目 2** 两个下拉:从当前赛事的小项里选(选项里有「未指定」),已选的行可以「清除」;同一个项目不会重复占两个槽位;
|
||||
- **当前生效**:显示此刻大屏实际会显示的项目与入选原因,格式如 `男子 100 米(排期命中)`;没有可显示的项目时显示「当前没有可显示的项目」。旁边的「刷新」只重新读取,不改配置;
|
||||
- **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」;
|
||||
- 该卡片的「保存」需要**管理员**身份(实时赛况属于赛事设置,只有管理员能改);示例数据模式下不能保存。
|
||||
|
||||
**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示实例 ID、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。
|
||||
|
||||
**逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。
|
||||
@@ -633,7 +655,7 @@
|
||||
|
||||
### 17.1 页面
|
||||
|
||||
大屏支持五个页面:
|
||||
大屏支持六个页面:
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
@@ -641,8 +663,17 @@
|
||||
| 团体奖牌榜 | 名次、单位(带单位 logo)、金 / 银 / 铜、合计 |
|
||||
| 个人积分奖牌榜 | 名次、姓名(姓名前显示所属单位 logo)、单位、总分、奖牌 |
|
||||
| 分项成绩 | 最新已完成项目的成绩,单位列名字前显示单位 logo |
|
||||
| 实时赛况 | 当前正在进行项目的实时排名(见下方说明) |
|
||||
| 公告 | 管理端推送的公告:推送后先全屏展示,之后常驻底栏的「公告」槽位 |
|
||||
|
||||
**实时赛况**:专门给「现在正在比的项目」用,每一行显示**名次、运动员 / 队、单位(带单位 logo)、成绩**,按名次升序。
|
||||
|
||||
- **显示哪个项目**由管理端判定(见第 9 节的「实时赛况」卡片):优先按人工指定的项目,其次按项目排期(开始时间前后 30 分钟内),都没有时显示**最近发布过成绩的项目**——所以**在下一场开始之前,这一页会一直保持显示上一场的成绩**;
|
||||
- **1 个项目单栏铺开,2 个项目自动左右分栏**(最多 2 栏,对应「两个项目时间撞在一起」的现场);
|
||||
- 只显示**已发布**的成绩:配合分批发布,发一批这一页就更新一批(见 4.6);
|
||||
- 同一个小项分了多个组别时,栏里显示**最近发布的那一组**,标题旁用「另有 N 组」标注;该项目还没有已发布成绩时栏内显示「该项目暂无已发布的成绩」;
|
||||
- 每栏最多列前 10 个名次;一个项目都没有时整页显示「暂无进行中的项目」(刚切到本页、结果还没回来时显示「正在获取当前项目…」)。
|
||||
|
||||
> 单位 logo 取自「基础数据 → 参赛单位」里上传的图片(见 10.1),按行内文字大小显示,不会撑开行高;没传 logo 的单位不占位,图片临时加载不出来时保留占位、刷新后恢复,队名不会跳动。
|
||||
|
||||
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
|
||||
@@ -653,7 +684,7 @@
|
||||
|
||||
- Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播;
|
||||
- 在管理端「大屏控制」点「解锁」后开始整页轮播,**每 20 秒切换一页**;
|
||||
- 公告页是手动触发的页面,不参与自动轮播;个人积分榜可在「赛事信息」里关闭上屏;
|
||||
- 公告页是手动触发的页面,不参与自动轮播;个人积分榜可在「赛事信息」里关闭上屏;「实时赛况」页参与轮播(见 17.1);
|
||||
- 底部滚条固定有「公告」槽位:管理端推送过公告就一直显示最近那条,没有公告时显示「暂无公告」(见第 9 节);
|
||||
- 点「锁定」可停在当前页。
|
||||
|
||||
|
||||
+33
-3
@@ -37,7 +37,7 @@
|
||||
撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。
|
||||
|
||||
**Q9:为什么「发布」是灰的?**
|
||||
常见原因:未登录、服务端断开、示例数据模式,或存在阻止发布的校验错误(成绩为空、秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。成绩格的报错现在会把**那一格的实际内容**打出来并对症给改法,照着提示改即可(见 Q75、Q76)。
|
||||
常见原因:未登录、服务端断开、示例数据模式,或存在**会阻止发布**的校验错误(秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。**没录成绩不算错误、不挡发布**(见 Q77)。成绩格的报错会把**那一格的实际内容**打出来并对症给改法(见 Q75、Q76)。
|
||||
|
||||
**Q10:并列名次怎么处理?**
|
||||
成绩相同(或手动名次相同)自动按并列处理,显示同一名次。默认口径是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名第 5 名;奖牌同样并列(并列第 1 各记 1 金、无银)。具体口径由规则包配置决定。
|
||||
@@ -428,10 +428,10 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
|
||||
别和 Console 自己的 `logs\console.log`(在安装目录里)混淆:那个装的是 Console 与服务端的控制台输出;服务端日志文件在数据目录。
|
||||
|
||||
**Q75:为什么我明明填了成绩,发布却说缺少成绩?**
|
||||
**Q75:为什么我明明填了成绩,那一行还是被当成「还没录」?**
|
||||
最可能是把格子里的**灰字示例文本**当成了已填内容。过去成绩格的示例是 `1:06.33`,没有样式区分,看起来就像已经填好;现在示例统一带「例」字(时间 `例 1:06.33`、距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`,备注格是 `可留空`),并且是**更淡的斜体灰字**,和真实输入明显不一样。灰字不会被提交,那一格仍然是空的。
|
||||
|
||||
新版报错也会自证:会写 `第 3 行 张三:成绩为空(当前「(空)」),请填写成绩`,把那一格的实际内容原样打出来。填了但写法不认的是另外两条:`秒位不能 ≥ 60(当前「1:70」)…` 或 `成绩写法无法识别(当前「约1分06秒」)…`,各自带内容与可用写法(见 Q76)。
|
||||
那空着会不会发不出去?**不会**。空格子在防呆校验里只是**提醒**,文案是 `第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行`,**不挡发布**;点发布会只发已填的行,其余保持未发布,补录后再发一次即可(见 Q77)。真正挡发布的只有写错:`秒位不能 ≥ 60(当前「1:70」)…` 或 `成绩写法无法识别(当前「约1分06秒」)…`(见 Q76)。
|
||||
|
||||
**Q76:成绩能怎么填?**
|
||||
按项目单位填(工具栏会显示当前单位:秒 / 米 / 次 / 分):
|
||||
@@ -442,3 +442,33 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
- 成绩格已经加宽,四位数也能看全。
|
||||
|
||||
有未保存的成绩时,切到别的菜单页面会先弹确认框,不会静默丢弃。
|
||||
|
||||
**Q77:100 米要分好几场跑,必须等所有人跑完才能发布吗?**
|
||||
不用。跑道只有 8 条、一个项目分好几场时,**每跑完一批就能先发一批**,没跑到的先空着:
|
||||
|
||||
1. 这一批跑完,把已出的成绩填上,其他还没跑的行保持空白;
|
||||
2. 点「发布」(该组已有发布过的成绩、还有没发的已填行时,按钮显示为「**发布本批**」);
|
||||
3. 确认框会写明 `本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可`——确认后系统只发布已填的 M 人,**不会把没成绩的人当成 0 分发出去**;
|
||||
4. 这批成绩立即上屏,名次与积分先按已发布的这部分算;
|
||||
5. 下一批跑完补录后,再点一次「发布本批」,系统会把两批放在一起**自动重算**名次与积分。
|
||||
|
||||
防呆校验里的「还没录成绩」只是提醒,**不挡发布**;只有写错(秒位 ≥ 60、写法无法识别)或手动名次项目缺名次才挡(见 Q75、Q76)。审核页的「发布整组」也是同一套口径。
|
||||
|
||||
**Q78:为什么有的成绩还没录就发布成功了?会不会发漏?**
|
||||
不会发漏,也不会算错:分批发布**只发布已经填了成绩的那部分**,没录的行本次**跳过**、保持未发布状态,确认框和发布后的提示都会写明「本次发布 M 人;其余 N 人保持未发布,补录后再次发布即可」。
|
||||
|
||||
要留意的是「跳过」不等于「算 0 分」——没成绩的人这一批根本不参与排名,不会占名次也不会拉低别人。等下一批补录后再发布一次,系统会把两批合起来重算;先前发布的那部分名次与积分会跟着一起更新。所以只要最后把所有人补录并发布完整,结果与一次性发布是一致的。
|
||||
|
||||
**Q79:大屏上的「实时赛况」显示的是哪个项目?**
|
||||
按三条依据**依次**判定,先满足的优先:
|
||||
|
||||
1. **人工指定**:管理端「大屏控制 → 实时赛况」卡片里指定的项目(最多 2 个);
|
||||
2. **排期命中**:小项的排期时间落在当前时间**前后 30 分钟内**;
|
||||
3. **最近发布过成绩的项目**:以上都没有时,显示最近一次发布过成绩的项目——所以**下一场开始之前,这一页会一直保持显示上一场**。
|
||||
|
||||
当前实际显示哪个项目、为什么选中,可以在卡片里的「当前生效」一行看到,格式如 `男子 100 米(排期命中)`;没有可显示的项目时显示「当前没有可显示的项目」。选「人工指定」模式时只认第 1 条,不做任何回退。该页只显示**已发布**的成绩,所以配合分批发布,发一批它就更新一批。
|
||||
|
||||
**Q80:两个项目同时进行,实时赛况页怎么显示?**
|
||||
会**自动左右分栏**,每个项目一栏,最多 2 栏(一个项目时单栏铺开,不会分栏)。每栏显示该项目的名次、运动员 / 队、单位(带单位 logo)与成绩,按名次升序、最多列前 10 个名次;同一个小项分了多个组别时,栏里显示最近发布的那一组,标题旁用「另有 N 组」标注。
|
||||
|
||||
场地上两个项目时间撞在一起、自动判定没选中你想看的组合时,让管理员到「大屏控制 → 实时赛况」把模式切到「人工指定」,把这两个项目分别选进「项目 1」「项目 2」并保存即可(该设置只有管理员能保存)。
|
||||
+7
-7
@@ -32,11 +32,11 @@ deploy\build.bat
|
||||
| 产物 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `dist\scoreboard.exe` | 服务端 | 前端已通过内嵌方式打进可执行文件,单独运行也能提供全部页面 |
|
||||
| `dist\console\Scoreboard Console-0.1.0-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 |
|
||||
| `dist\console\Scoreboard Console-0.1.0-x64.zip` | 免安装包 | 解压即用 |
|
||||
| `dist\console\Scoreboard Console-0.1.1-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 |
|
||||
| `dist\console\Scoreboard Console-0.1.1-x64.zip` | 免安装包 | 解压即用 |
|
||||
| `dist\console\win-unpacked\` | 目录 | 未压缩的运行目录,便于现场调试 |
|
||||
|
||||
> 文件名中的 `0.1.0` 来自 `console\package.json` 的版本号,会随版本升级变化;以 `dist\console\` 下的实际文件名为准。
|
||||
> 版本号来自仓库根的 `VERSION` 文件:构建时由 `deploy\build.bat` 注入服务端二进制(`-ldflags -X main.version`,运行期不再读任何文件),并同步到 `console\package.json` 决定安装包/免安装包的文件名。升级版本只改 `VERSION` 一处;以 `dist\console\` 下的实际文件名为准。
|
||||
>
|
||||
> 目标机器**不需要** Go / Node / pnpm,也不联网;上述工具只用于构建机。
|
||||
>
|
||||
@@ -48,7 +48,7 @@ deploy\build.bat
|
||||
|
||||
### 3.1 使用安装包
|
||||
|
||||
1. 双击 `Scoreboard Console-0.1.0-x64.exe`。
|
||||
1. 双击 `Scoreboard Console-0.1.1-x64.exe`。
|
||||
2. 按向导选择安装目录(可自定义)。
|
||||
3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。
|
||||
4. 双击「赛事积分榜」启动。
|
||||
@@ -57,7 +57,7 @@ deploy\build.bat
|
||||
|
||||
### 3.2 使用免安装包
|
||||
|
||||
1. 把 `Scoreboard Console-0.1.0-x64.zip` 解压到任意目录(建议使用纯英文路径)。
|
||||
1. 把 `Scoreboard Console-0.1.1-x64.zip` 解压到任意目录(建议使用纯英文路径)。
|
||||
2. 双击目录里的 `Scoreboard_Console.exe` 启动。
|
||||
|
||||
### 3.3 首次启动会自动完成
|
||||
@@ -295,7 +295,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
|
||||
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
||||
| `display.selection` | `all-secondary` | 选屏策略,见第 7 节 |
|
||||
| `display.displays` | `[]` | 仅 `specified` 策略生效,填显示器 id 或 0 起序号 |
|
||||
| `display.pages` | `["standings","medals","individual","results"]` | 多屏时按屏序轮转锁定的页面 |
|
||||
| `display.pages` | `["standings","medals","individual","results"]` | 多屏时按屏序轮转锁定的页面;可加入 `live`(实时赛况)等页面值,见 7.4 |
|
||||
| `display.pageParam` | `page` | 页面 URL 参数名 |
|
||||
| `display.kiosk` | `true` | 多屏时是否 kiosk 全屏(单屏恒为窗口化) |
|
||||
| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 |
|
||||
@@ -351,7 +351,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
|
||||
- 除内置屏外的每块屏各开一个 kiosk 全屏窗口;
|
||||
- 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配;
|
||||
- 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识;
|
||||
- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。
|
||||
- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`live`(实时赛况)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。把 `live` 加进 `display.pages`,就能把某块屏固定成「实时赛况」(`live` 也认 `live-results` / `实时赛况` / `实时` / `赛况` 这些别名,与其它页一样)。
|
||||
|
||||
### 7.5 在管理端单独控制某块屏
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package dto
|
||||
|
||||
// LiveEvents 是 GET /api/display/live 的线上结构:大屏「实时赛况」页的当前项目及其成绩。
|
||||
//
|
||||
// 为什么单开一个接口而不是塞进 snapshot:当前项目的判定依赖「当前时间 + 排期 + 最近一次发布」,
|
||||
// 是随时间变化的量;快照只在变更时推送,装不下一个会自己过期的结论。
|
||||
// 两个设置键(display.live_events / display.live_source)仍然随 snapshot.settings 下发,
|
||||
// 大屏也可以只用 events / latestResults 自行推导。
|
||||
type LiveEvents struct {
|
||||
// Source 是本次生效的判定方式:manual 或 auto(来自 display.live_source)。
|
||||
Source string `json:"source"`
|
||||
// EventIDs 是判定出的当前项目 id,最多 MaxLiveEvents 个,按重要程度排序;空表示没有。
|
||||
EventIDs []int64 `json:"eventIds"`
|
||||
// Events 与 EventIDs 一一对应,带上小项信息与已发布成绩(名次已由发布时回写)。
|
||||
Events []LiveEvent `json:"events"`
|
||||
}
|
||||
|
||||
// LiveEvent 是一个当前项目。
|
||||
type LiveEvent struct {
|
||||
Event Event `json:"event"`
|
||||
// Reason 说明它为什么被选中:manual(人工指定)/ scheduled(排期命中)/ recent(最近发布)。
|
||||
Reason string `json:"reason"`
|
||||
// Groups 是该小项已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。
|
||||
Groups []EventResultGroup `json:"groups"`
|
||||
}
|
||||
@@ -42,6 +42,72 @@ type EventSettingsDisplay struct {
|
||||
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
|
||||
FontTitle *string `json:"fontTitle"`
|
||||
FontBody *string `json:"fontBody"`
|
||||
// LiveEvents 是「实时赛况」页人工指定的当前项目 id,最多 MaxLiveEvents 个。
|
||||
// 库里以逗号分隔的 id 存 display.live_events;空数组表示没有人工指定。
|
||||
LiveEvents []int64 `json:"liveEvents"`
|
||||
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
||||
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||||
LiveSource string `json:"liveSource"`
|
||||
}
|
||||
|
||||
// 「大屏实时赛况」的两个设置键与取值约束。
|
||||
const (
|
||||
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
||||
SettingKeyLiveEvents = "display.live_events"
|
||||
SettingKeyLiveSource = "display.live_source"
|
||||
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
||||
MaxLiveEvents = 2
|
||||
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
||||
LiveSourceAuto = "auto"
|
||||
LiveSourceManual = "manual"
|
||||
)
|
||||
|
||||
// ParseLiveEventIDs 解析 display.live_events 的逗号分隔 id 列表。
|
||||
//
|
||||
// 非法项(非数字 / 0 / 负数)直接丢弃:这个键可能被手工改过,
|
||||
// 让大屏因为一个坏 id 整块不显示,比忽略它更糟。
|
||||
func ParseLiveEventIDs(raw string) []int64 {
|
||||
out := make([]int64, 0, MaxLiveEvents)
|
||||
seen := make(map[int64]bool)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
if len(out) == MaxLiveEvents {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatLiveEventIDs 把 id 列表写成 display.live_events 的存储格式(逗号分隔,最多 MaxLiveEvents 个)。
|
||||
func FormatLiveEventIDs(ids []int64) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
if len(parts) == MaxLiveEvents {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// NormalizeLiveSource 归一 live_source;无法识别时返回空串,由调用方决定报错还是落默认。
|
||||
func NormalizeLiveSource(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", LiveSourceAuto:
|
||||
return LiveSourceAuto
|
||||
case LiveSourceManual:
|
||||
return LiveSourceManual
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// EventSettingsStandings 是榜单口径配置。
|
||||
@@ -112,6 +178,9 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
||||
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
|
||||
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
|
||||
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
|
||||
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
|
||||
out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
|
||||
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
|
||||
|
||||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||||
|
||||
@@ -2,6 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -154,6 +155,97 @@ func TestParseEventSettingsFonts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 「实时赛况」的两个设置键:live_events 存逗号分隔 id(最多 2 个),live_source 只认 auto/manual。
|
||||
func TestParseLiveEventSettings(t *testing.T) {
|
||||
got := ParseEventSettings(map[string]string{
|
||||
SettingKeyLiveEvents: "12, 15",
|
||||
SettingKeyLiveSource: "MANUAL",
|
||||
})
|
||||
if len(got.Display.LiveEvents) != 2 || got.Display.LiveEvents[0] != 12 || got.Display.LiveEvents[1] != 15 {
|
||||
t.Fatalf("应解析出人工指定的两个 id,得到 %+v", got.Display.LiveEvents)
|
||||
}
|
||||
if got.Display.LiveSource != LiveSourceManual {
|
||||
t.Fatalf("大小写应归一成 manual,得到 %q", got.Display.LiveSource)
|
||||
}
|
||||
|
||||
// 缺键 / 空值:空数组 + 默认 auto。
|
||||
empty := ParseEventSettings(map[string]string{})
|
||||
if empty.Display.LiveEvents == nil || len(empty.Display.LiveEvents) != 0 {
|
||||
t.Fatalf("缺键应下发空数组而不是 null,得到 %+v", empty.Display.LiveEvents)
|
||||
}
|
||||
if empty.Display.LiveSource != LiveSourceAuto {
|
||||
t.Fatalf("缺键应默认 auto,得到 %q", empty.Display.LiveSource)
|
||||
}
|
||||
raw, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化: %v", err)
|
||||
}
|
||||
var display map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
|
||||
t.Fatalf("解析 display: %v", err)
|
||||
}
|
||||
if string(display["liveEvents"]) != "[]" || string(display["liveSource"]) != `"auto"` {
|
||||
t.Fatalf("缺键时应下发 [] 与 auto: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析要能容忍手工改库留下的脏值:坏项丢弃、去重、超过 2 个截断。
|
||||
func TestParseLiveEventIDs(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"12", "12"},
|
||||
{"12,15", "12,15"},
|
||||
{" 3 , 4 , 5 ", "3,4"},
|
||||
{"7,7,8", "7,8"},
|
||||
{"abc", ""},
|
||||
{"0,-1,6", "6"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ParseLiveEventIDs(tc.raw)
|
||||
joined := ""
|
||||
for i, id := range got {
|
||||
if i > 0 {
|
||||
joined += ","
|
||||
}
|
||||
joined += strconv.FormatInt(id, 10)
|
||||
}
|
||||
if joined != tc.want {
|
||||
t.Fatalf("ParseLiveEventIDs(%q) = %q,期望 %q", tc.raw, joined, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLiveEventIDs(t *testing.T) {
|
||||
if got := FormatLiveEventIDs([]int64{9, 11, 13}); got != "9,11" {
|
||||
t.Fatalf("最多存 2 个,得到 %q", got)
|
||||
}
|
||||
if got := FormatLiveEventIDs(nil); got != "" {
|
||||
t.Fatalf("空列表应存空串,得到 %q", got)
|
||||
}
|
||||
if got := FormatLiveEventIDs([]int64{0, -2, 5}); got != "5" {
|
||||
t.Fatalf("非法 id 应丢弃,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLiveSource(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": LiveSourceAuto,
|
||||
"auto": LiveSourceAuto,
|
||||
"AUTO": LiveSourceAuto,
|
||||
"manual": LiveSourceManual,
|
||||
" Manual": LiveSourceManual,
|
||||
"x": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := NormalizeLiveSource(raw); got != want {
|
||||
t.Fatalf("NormalizeLiveSource(%q) = %q,期望 %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mustField 取出顶层字段的原始 JSON,供字段级断言使用。
|
||||
func mustField(t *testing.T, raw []byte, key string) []byte {
|
||||
t.Helper()
|
||||
|
||||
@@ -100,21 +100,23 @@ type profileDTO struct {
|
||||
}
|
||||
|
||||
type entryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AthleteID *int64 `json:"athleteId"`
|
||||
MemberNames []string `json:"memberNames"`
|
||||
Bib *string `json:"bib"`
|
||||
Seed *string `json:"seed"`
|
||||
Status string `json:"status"`
|
||||
Remark *string `json:"remark"`
|
||||
AthleteName *string `json:"athleteName,omitempty"`
|
||||
TeamCode string `json:"teamCode,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
ShortName *string `json:"shortName,omitempty"`
|
||||
CategoryName string `json:"categoryName,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AthleteID *int64 `json:"athleteId"`
|
||||
MemberNames []string `json:"memberNames"`
|
||||
Bib *string `json:"bib"`
|
||||
Seed *string `json:"seed"`
|
||||
// Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。
|
||||
Heat *int64 `json:"heat"`
|
||||
Status string `json:"status"`
|
||||
Remark *string `json:"remark"`
|
||||
AthleteName *string `json:"athleteName,omitempty"`
|
||||
TeamCode string `json:"teamCode,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
ShortName *string `json:"shortName,omitempty"`
|
||||
CategoryName string `json:"categoryName,omitempty"`
|
||||
}
|
||||
|
||||
type entryWithResultDTO struct {
|
||||
@@ -122,6 +124,13 @@ type entryWithResultDTO struct {
|
||||
Result *resultDTO `json:"result"`
|
||||
}
|
||||
|
||||
// entryHeatResultDTO 是批量设置批次的结果:本批条目当前统一的批次号(null = 未分批)。
|
||||
type entryHeatResultDTO struct {
|
||||
Updated int `json:"updated"`
|
||||
Heat *int64 `json:"heat"`
|
||||
EntryIDs []int64 `json:"entryIds"`
|
||||
}
|
||||
|
||||
type resultDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// scoringSnapshot 是「发布完全部成绩」后的计分快照:名次 + 每条报名的积分 + 单位总分。
|
||||
//
|
||||
// entries 的 id 由 seed / seedBatch 按固定顺序插入决定,两个库里完全一致,
|
||||
// 所以可以直接按 entryID 比对两份快照。
|
||||
type scoringSnapshot struct {
|
||||
ranks map[int64]int
|
||||
entryPoints map[int64]float64
|
||||
teamPoints map[int64]float64
|
||||
}
|
||||
|
||||
func entryRank(t *testing.T, env *testEnv, id int64) int {
|
||||
t.Helper()
|
||||
var rank int
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
|
||||
t.Fatalf("读名次: %v", err)
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// publishEightWithHeats 造一个 8 人的组别,可选地先分好批次,再全量发布,返回计分快照。
|
||||
func publishEightWithHeats(t *testing.T, assignHeats func(env *testEnv, client *http.Client, entries []int64)) scoringSnapshot {
|
||||
t.Helper()
|
||||
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if assignHeats != nil {
|
||||
assignHeats(env, client, entries)
|
||||
}
|
||||
|
||||
times := make([]float64, len(entries))
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, len(entries), 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
snap := scoringSnapshot{
|
||||
ranks: make(map[int64]int, len(entries)),
|
||||
entryPoints: make(map[int64]float64),
|
||||
teamPoints: make(map[int64]float64),
|
||||
}
|
||||
for _, id := range entries {
|
||||
snap.ranks[id] = entryRank(t, env, id)
|
||||
}
|
||||
rows, err := env.ev.Query(
|
||||
`SELECT entry_id, team_id, SUM(points) FROM score_ledger
|
||||
WHERE event_id = ? AND voided = 0 AND source_type = 'result'
|
||||
GROUP BY entry_id, team_id`, ids.event)
|
||||
if err != nil {
|
||||
t.Fatalf("读积分流水: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
teamID int64
|
||||
points float64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &teamID, &points); err != nil {
|
||||
t.Fatalf("扫描积分流水: %v", err)
|
||||
}
|
||||
snap.entryPoints[entryID] = points
|
||||
snap.teamPoints[teamID] += points
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历积分流水: %v", err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// 批次只做录入 / 展示分组:同一个组别分两批之后,发布出来的名次与积分必须与「没分批」完全一致。
|
||||
// 这条断言是防止后人把批次当成分组参与算分的护栏。
|
||||
func TestEntryHeatDoesNotAffectScoring(t *testing.T) {
|
||||
plain := publishEightWithHeats(t, nil)
|
||||
|
||||
batched := publishEightWithHeats(t, func(env *testEnv, client *http.Client, entries []int64) {
|
||||
status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": entries[:4],
|
||||
"heat": 1,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("给前 4 人设第 1 批应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
status, e = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": entries[4:],
|
||||
"heat": 2,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("给后 4 人设第 2 批应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
// 确认批次真的落库、且确实分成了两批,否则这条用例会假通过。
|
||||
assertCount(t, env, `SELECT COUNT(DISTINCT heat) FROM entries WHERE heat IS NOT NULL`, 2)
|
||||
})
|
||||
|
||||
if !reflect.DeepEqual(plain, batched) {
|
||||
t.Fatalf("分了批次的计分快照与未分批不一致:\n未分批 %+v\n分批次 %+v", plain, batched)
|
||||
}
|
||||
|
||||
// 逐项显式断言,失败时给出可读的差异,而不是只有一个 DeepEqual。
|
||||
// seed / seedBatch 依次插入,前 8 条报名的 id 就是 1..8。
|
||||
for i, points := range []float64{10, 8, 6, 3, 2, 1} {
|
||||
id := int64(i + 1)
|
||||
if got := batched.ranks[id]; got != i+1 {
|
||||
t.Fatalf("entry %d 名次 = %d,期望 %d", id, got, i+1)
|
||||
}
|
||||
if got := batched.entryPoints[id]; got != points {
|
||||
t.Fatalf("entry %d 积分 = %v,期望 %v", id, got, points)
|
||||
}
|
||||
}
|
||||
// 第 7、8 名没有积分;总分仍是 10+8+6+3+2+1。
|
||||
if _, ok := batched.entryPoints[7]; ok {
|
||||
t.Fatalf("第 7 名不该有积分流水: %+v", batched.entryPoints)
|
||||
}
|
||||
var total float64
|
||||
for _, points := range batched.teamPoints {
|
||||
total += points
|
||||
}
|
||||
if total != 30 {
|
||||
t.Fatalf("单位总分合计 = %v,期望 30", total)
|
||||
}
|
||||
t.Logf("分两批发布后:名次 %v,每条报名积分 %v,单位总分 %+v", batched.ranks, batched.entryPoints, batched.teamPoints)
|
||||
}
|
||||
|
||||
// 批次号大小不影响名次:同一批次里的人名次照常由成绩决定,慢的那个仍排后面。
|
||||
func TestEntryHeatKeepsRanksByResult(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 4)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{entries[0], entries[1]},
|
||||
"heat": 7,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 第 1、2 人同属第 7 批,但成绩比第 3、4 人慢。
|
||||
times := []float64{13.0, 12.0, 11.0, 10.0}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 4, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 名次只看成绩(用时越短越好):13.0 → 第 4、12.0 → 第 3、11.0 → 第 2、10.0 → 第 1。
|
||||
for i, want := range []int{4, 3, 2, 1} {
|
||||
if got := entryRank(t, env, entries[i]); got != want {
|
||||
t.Fatalf("entry %d 名次 = %d,期望 %d(批次号不参与名次)", entries[i], got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。
|
||||
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
|
||||
|
||||
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
|
||||
func TestSetEntriesHeatSetsAndClears(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, ids.entryB},
|
||||
"heat": 2,
|
||||
}, true)
|
||||
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(2) || result["heat"] != float64(2) {
|
||||
t.Fatalf("批量设置结果不对: %+v", result)
|
||||
}
|
||||
|
||||
var heat int
|
||||
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&heat); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if heat != 2 {
|
||||
t.Fatalf("entryA 的批次应为 2,得到 %d", heat)
|
||||
}
|
||||
|
||||
// 下发路径也要带上 heat:录入页靠 GET /api/events/{id}/entries 拿批次。
|
||||
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
items := decodeData[[]map[string]any](t, env2)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["heat"] != float64(2) {
|
||||
t.Fatalf("报名列表应下发 heat=2,得到 %+v", item["heat"])
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:同一批再设一次批次号,结果一致。
|
||||
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, ids.entryB},
|
||||
"heat": 2,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("重复设置同一批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if again := decodeData[map[string]any](t, env2); again["updated"] != float64(2) {
|
||||
t.Fatalf("重复设置应同样更新 2 条,得到 %+v", again)
|
||||
}
|
||||
|
||||
// 传 null 清除为「未分批」。
|
||||
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, ids.entryB},
|
||||
"heat": nil,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("清除批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if cleared := decodeData[map[string]any](t, env2); cleared["heat"] != nil {
|
||||
t.Fatalf("清除后 heat 应为 null,得到 %+v", cleared["heat"])
|
||||
}
|
||||
var nullHeat any
|
||||
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&nullHeat); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if nullHeat != nil {
|
||||
t.Fatalf("清除后 entries.heat 应为 NULL,得到 %v", nullHeat)
|
||||
}
|
||||
}
|
||||
|
||||
// 跨小项批量设置一定是选错行:必须拒绝,且一条都不能改。
|
||||
func TestSetEntriesHeatRejectsCrossEvent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
otherEvent := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
otherCategory := env.insert(t,
|
||||
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, otherEvent)
|
||||
otherEntry := env.insert(t,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`,
|
||||
otherEvent, otherCategory, ids.teamB, ids.athB)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, otherEntry},
|
||||
"heat": 1,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("跨小项设置批次应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
assertNoHeat(t, env, ids.entryA)
|
||||
assertNoHeat(t, env, otherEntry)
|
||||
}
|
||||
|
||||
// entryIds 为空 / 全部非法、以及 heat 越界都必须被拒。
|
||||
func TestSetEntriesHeatValidatesInput(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"entryIds 为空", map[string]any{"entryIds": []int64{}, "heat": 1}},
|
||||
{"entryIds 缺失", map[string]any{"heat": 1}},
|
||||
{"entryIds 全是非法 id", map[string]any{"entryIds": []int64{0, -3}, "heat": 1}},
|
||||
{"heat 为 0", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 0}},
|
||||
{"heat 为负", map[string]any{"entryIds": []int64{ids.entryA}, "heat": -1}},
|
||||
{"heat 超上限", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 100000}},
|
||||
{"报名项不存在", map[string]any{"entryIds": []int64{ids.entryA, 999999}, "heat": 1}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", tc.body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
continue
|
||||
}
|
||||
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Errorf("%s 应返回 VALIDATION_FAILED,得到 %+v", tc.name, env2.Error)
|
||||
}
|
||||
}
|
||||
assertNoHeat(t, env, ids.entryA)
|
||||
}
|
||||
|
||||
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
|
||||
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
|
||||
env, ids, _ := recorderWithSeed(t)
|
||||
|
||||
guest, _ := env.client()
|
||||
status, env2 := env.do(guest, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA}, "heat": 1,
|
||||
}, false)
|
||||
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||||
t.Fatalf("guest 设置批次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
assertNoHeat(t, env, ids.entryA)
|
||||
|
||||
recorder, _ := env.client()
|
||||
env.login(recorder, "recorder", "recorder-pass")
|
||||
status, env2 = env.do(recorder, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA}, "heat": 3,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("recorder 设置批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var (
|
||||
action, after string
|
||||
)
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT action, after_json FROM audit_logs WHERE action = 'entry.heat' ORDER BY id DESC LIMIT 1`).
|
||||
Scan(&action, &after); err != nil {
|
||||
t.Fatalf("查批次审计: %v", err)
|
||||
}
|
||||
if !strings.Contains(after, `"heat":3`) {
|
||||
t.Fatalf("审计里应记下批次号 3,得到 %s", after)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoHeat(t *testing.T, env *testEnv, entryID int64) {
|
||||
t.Helper()
|
||||
var heat any
|
||||
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, entryID).Scan(&heat); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if heat != nil {
|
||||
t.Fatalf("entry %d 的批次应保持未分批,得到 %v", entryID, heat)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入带 heat 列:正常值入库、留空 = 未分批、非法值走行级拦截且其余行照常导入。
|
||||
func TestImportEntriesWithHeatColumn(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderHeat + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,,,1,registered,\n" +
|
||||
"M100,男子,A,李强,110101199001011235,男,,,,2,registered,\n" +
|
||||
"M100,男子,B,王芳,110101199001011236,女,,,,3,registered,\n" +
|
||||
"M100,男子,A,赵六,110101199001011237,男,,,,abc,registered,\n" +
|
||||
"M100,男子,B,孙七,110101199001011238,男,,,,0,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(3) || result["skipped"] != float64(2) {
|
||||
t.Fatalf("应新建 3 条、跳过 2 条,得到 %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("应有 2 条行级错误,得到 %+v", errs)
|
||||
}
|
||||
for _, raw := range errs {
|
||||
text, _ := raw.(string)
|
||||
if !strings.Contains(text, "heat") {
|
||||
t.Errorf("行级错误应说明 heat 非法,得到 %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// 正常值入库;heat 非法的两行被拦,不影响同一份表里的其余行。
|
||||
for idCard, want := range map[string]int64{
|
||||
"110101199001011234": 1,
|
||||
"110101199001011235": 2,
|
||||
"110101199001011236": 3,
|
||||
} {
|
||||
var heat int64
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard).
|
||||
Scan(&heat); err != nil {
|
||||
t.Fatalf("读 %s 的报名批次: %v", idCard, err)
|
||||
}
|
||||
if heat != want {
|
||||
t.Errorf("%s 的批次应为 %d,得到 %d", idCard, want, heat)
|
||||
}
|
||||
}
|
||||
// 非法值的两行没有落库。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM athletes WHERE id_card IN ('110101199001011237','110101199001011238')`, 0)
|
||||
// 表头没有 heat 列时(老模板)照常导入,批次为未分批。
|
||||
status, env2 = env.postImport(t, client, "/api/entries/import", importHeader+
|
||||
"\nM100,男子,A,周八,110101199001011239,男,,,,registered,\n", "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("无 heat 列的老模板应照常导入,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
var heat any
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011239'`).
|
||||
Scan(&heat); err != nil {
|
||||
t.Fatalf("读老模板导入的报名批次: %v", err)
|
||||
}
|
||||
if heat != nil {
|
||||
t.Fatalf("无 heat 列时应为未分批,得到 %v", heat)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入留空 = 未分批:显式带 heat 列但留空,写入 NULL。
|
||||
func TestImportEntriesHeatBlankMeansUnbatched(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderHeat + "\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)
|
||||
}
|
||||
if result := decodeData[map[string]any](t, env2); result["created"] != float64(1) {
|
||||
t.Fatalf("应新建 1 条,得到 %+v", result)
|
||||
}
|
||||
var heat any
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011234'`).
|
||||
Scan(&heat); err != nil {
|
||||
t.Fatalf("读报名批次: %v", err)
|
||||
}
|
||||
if heat != nil {
|
||||
t.Fatalf("留空应为未分批(NULL),得到 %v", heat)
|
||||
}
|
||||
}
|
||||
@@ -690,6 +690,21 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// setEntriesHeatHandler 批量设置 / 清除一批报名项的批次(PUT /api/entries/heat)。
|
||||
func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in entryHeatPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.setEntriesHeat(r.Context(), in.EntryIDs, in.Heat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entry.heat", "entry", 0, nil,
|
||||
map[string]any{"entryIds": result.EntryIDs, "heat": result.Heat, "updated": result.Updated})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
@@ -828,6 +843,14 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
|
||||
heat, ok := parseImportHeat(cell("heat"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line,
|
||||
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
|
||||
continue
|
||||
}
|
||||
|
||||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
@@ -931,6 +954,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(cell("bib")),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
Status: status,
|
||||
})
|
||||
switch {
|
||||
@@ -958,6 +982,18 @@ func parseImportAge(raw string) (*int, bool) {
|
||||
return &n, true
|
||||
}
|
||||
|
||||
// parseImportHeat 解析可选的 heat 列:留空表示未分批(nil),非整数或超出 1-maxEntryHeat 视为非法。
|
||||
func parseImportHeat(raw string) (*int64, bool) {
|
||||
if raw == "" {
|
||||
return nil, true
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || n < 1 || n > maxEntryHeat {
|
||||
return nil, false
|
||||
}
|
||||
return &n, true
|
||||
}
|
||||
|
||||
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||||
func normalizeGender(raw string) string {
|
||||
switch raw {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
)
|
||||
|
||||
// 「实时赛况」大屏页的当前项目判定。
|
||||
//
|
||||
// 现场节奏:一个项目跑完一批就发布一批,下一场开始前大屏要一直显示上一场。
|
||||
// 判定顺序(display.live_source=auto 时):
|
||||
// 1. 人工指定了 display.live_events → 用人工指定的(最多 2 个);
|
||||
// 2. 否则取 scheduled_at 落在「现在 ± liveEventWindow」内的小项,按离现在多近排序,最多 2 个;
|
||||
// 3. 都没有 → 取最近一次发布过成绩的小项(用户要求的「下一场开始前持续显示上一场」)。
|
||||
//
|
||||
// manual 时只认人工指定,不做任何回退。
|
||||
const (
|
||||
// liveEventWindow 是排期窗口的半径:项目开始时间前后各 30 分钟都算「正在进行」。
|
||||
// 取 30 分钟是因为一个田径小项(含多轮次)通常不会超过一小时;
|
||||
// 想更精确可以后续做成设置项,这里先给一个现场够用的固定值。
|
||||
liveEventWindow = 30 * time.Minute
|
||||
|
||||
liveReasonManual = "manual"
|
||||
liveReasonScheduled = "scheduled"
|
||||
liveReasonRecent = "recent"
|
||||
)
|
||||
|
||||
// liveScheduledCandidate 是排期命中的候选小项。
|
||||
type liveScheduledCandidate struct {
|
||||
ID int64
|
||||
At time.Time
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// liveSelection 是判定结果:id 列表与每个 id 的入选原因。
|
||||
type liveSelection struct {
|
||||
Source string
|
||||
IDs []int64
|
||||
Reasons map[int64]string
|
||||
}
|
||||
|
||||
// pickLiveEvents 是纯函数形式的回退链,方便单测覆盖各种组合。
|
||||
// scheduled 传入的候选必须已经按窗口筛过。
|
||||
func pickLiveEvents(source string, manual []int64, scheduled []liveScheduledCandidate, recent int64, now time.Time) liveSelection {
|
||||
out := liveSelection{Source: source, IDs: make([]int64, 0, dto.MaxLiveEvents), Reasons: map[int64]string{}}
|
||||
take := func(ids []int64, reason string) {
|
||||
for _, id := range ids {
|
||||
if id <= 0 || len(out.IDs) >= dto.MaxLiveEvents {
|
||||
return
|
||||
}
|
||||
if _, ok := out.Reasons[id]; ok {
|
||||
continue
|
||||
}
|
||||
out.IDs = append(out.IDs, id)
|
||||
out.Reasons[id] = reason
|
||||
}
|
||||
}
|
||||
|
||||
if source == dto.LiveSourceManual {
|
||||
take(manual, liveReasonManual)
|
||||
return out
|
||||
}
|
||||
if len(manual) > 0 {
|
||||
take(manual, liveReasonManual)
|
||||
return out
|
||||
}
|
||||
|
||||
if len(scheduled) > 0 {
|
||||
sort.SliceStable(scheduled, func(i, j int) bool {
|
||||
di := absDuration(scheduled[i].At.Sub(now))
|
||||
dj := absDuration(scheduled[j].At.Sub(now))
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
if scheduled[i].SortOrder != scheduled[j].SortOrder {
|
||||
return scheduled[i].SortOrder < scheduled[j].SortOrder
|
||||
}
|
||||
return scheduled[i].ID < scheduled[j].ID
|
||||
})
|
||||
ids := make([]int64, 0, len(scheduled))
|
||||
for _, c := range scheduled {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
take(ids, liveReasonScheduled)
|
||||
return out
|
||||
}
|
||||
|
||||
if recent > 0 {
|
||||
take([]int64{recent}, liveReasonRecent)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func absDuration(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveLiveEvents 读取设置与赛事库,判定当前项目。
|
||||
func (s *server) resolveLiveEvents(ctx context.Context, now time.Time) (liveSelection, error) {
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
source := dto.NormalizeLiveSource(values[dto.SettingKeyLiveSource])
|
||||
|
||||
manual, err := s.existingEventIDs(ctx, dto.ParseLiveEventIDs(values[dto.SettingKeyLiveEvents]))
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
// manual 模式不必查排期与最近发布,省掉两次查询。
|
||||
if source == dto.LiveSourceManual {
|
||||
return pickLiveEvents(source, manual, nil, 0, now), nil
|
||||
}
|
||||
scheduled, err := s.scheduledLiveCandidates(ctx, now)
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
recent, err := s.mostRecentPublishedEvent(ctx)
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
return pickLiveEvents(source, manual, scheduled, recent, now), nil
|
||||
}
|
||||
|
||||
// existingEventIDs 丢掉已不存在的小项 id:人工指定的小项被删除后,
|
||||
// 大屏不该因为一个残留 id 报错或整块空白。
|
||||
func (s *server) existingEventIDs(ctx context.Context, ids []int64) ([]int64, error) {
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
var one int
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&one)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, internal(err)
|
||||
default:
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scheduledLiveCandidates 取 scheduled_at 落在当前窗口内的小项。
|
||||
func (s *server) scheduledLiveCandidates(ctx context.Context, now time.Time) ([]liveScheduledCandidate, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, scheduled_at, sort_order FROM events
|
||||
WHERE scheduled_at IS NOT NULL AND TRIM(scheduled_at) <> ''`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]liveScheduledCandidate, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c liveScheduledCandidate
|
||||
raw string
|
||||
sortOrd int64
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &raw, &sortOrd); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
at, ok := parseScheduledAt(raw)
|
||||
if !ok || absDuration(at.Sub(now)) > liveEventWindow {
|
||||
continue
|
||||
}
|
||||
c.At = at
|
||||
c.SortOrder = int(sortOrd)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// mostRecentPublishedEvent 取最近一次发布过成绩的小项,取不到时返回 0(全新赛事)。
|
||||
func (s *server) mostRecentPublishedEvent(ctx context.Context) (int64, error) {
|
||||
var id int64
|
||||
err := s.ev.QueryRowContext(ctx, `
|
||||
SELECT r.event_id
|
||||
FROM results r
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
WHERE r.state = 'published' AND r.status <> 'withdrawn'
|
||||
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
|
||||
LIMIT 1`).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, internal(err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// parseScheduledAt 解析 events.scheduled_at。
|
||||
//
|
||||
// 管理端存的是 datetime-local 转出的 RFC3339(UTC),但手工改库或早期数据可能是
|
||||
// 其它几种常见写法;认不出来的一律当成「没有排期」,不让一个大屏因为格式怪就整块不显示。
|
||||
func parseScheduledAt(raw string) (time.Time, bool) {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if at, err := time.Parse(layout, v); err == nil {
|
||||
return at, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// dedupeIDs 去重并丢掉非法 id(0 / 负数),保持原顺序。
|
||||
func dedupeIDs(ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 接口 ----------
|
||||
|
||||
// liveEventsHandler 是 GET /api/display/live:大屏「实时赛况」页拉当前项目与已发布成绩。
|
||||
// 同样对 guest 开放——局域网大屏不带会话,判定结论本身不含任何管理配置。
|
||||
func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
ctx := r.Context()
|
||||
sel, err := s.resolveLiveEvents(ctx, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := dto.LiveEvents{Source: sel.Source, EventIDs: sel.IDs, Events: make([]dto.LiveEvent, 0, len(sel.IDs))}
|
||||
for _, id := range sel.IDs {
|
||||
ev, err := loadEventQ(ctx, s.ev, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := s.liveResultGroups(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Events = append(out.Events, dto.LiveEvent{
|
||||
Event: dto.EventFromDomain(ev),
|
||||
Reason: sel.Reasons[id],
|
||||
Groups: groups,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。
|
||||
//
|
||||
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序),
|
||||
// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 20 条截断。
|
||||
func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) {
|
||||
rows, err := s.ev.QueryContext(ctx, `
|
||||
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''),
|
||||
e.category_id, COALESCE(cat.name, ''),
|
||||
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
|
||||
r.entry_id, r.rank, e.team_id,
|
||||
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
|
||||
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
|
||||
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN teams t ON t.id = e.team_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.event_id = ? AND r.state = 'published' AND r.status <> 'withdrawn'
|
||||
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
index := make(map[int64]int)
|
||||
out := make([]dto.EventResultGroup, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
eventCode, eventName string
|
||||
categoryID int64
|
||||
categoryName string
|
||||
publishedAt string
|
||||
row dto.EventResultRow
|
||||
rank sql.NullInt64
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
athleteName string
|
||||
shortName string
|
||||
logoPath sql.NullString
|
||||
memberNames sql.NullString
|
||||
isTie int
|
||||
)
|
||||
if err := rows.Scan(&eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
|
||||
&row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath,
|
||||
&athleteID, &athleteName, &memberNames,
|
||||
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if rank.Valid {
|
||||
v := int(rank.Int64)
|
||||
row.Rank = &v
|
||||
}
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
row.ResultValue = &v
|
||||
}
|
||||
if athleteID.Valid && athleteID.Int64 != 0 {
|
||||
v := athleteID.Int64
|
||||
row.AthleteID = &v
|
||||
}
|
||||
row.AthleteName = nilIfEmpty(athleteName)
|
||||
row.ShortName = nilIfEmpty(shortName)
|
||||
row.LogoPath = nilIfEmpty(logoPath.String)
|
||||
row.MemberNames = parseNames(memberNames)
|
||||
row.IsTie = isTie != 0
|
||||
|
||||
key := categoryID
|
||||
i, ok := index[key]
|
||||
if !ok {
|
||||
i = len(out)
|
||||
index[key] = i
|
||||
out = append(out, dto.EventResultGroup{
|
||||
EventID: eventID,
|
||||
EventCode: eventCode,
|
||||
EventName: eventName,
|
||||
CategoryID: categoryID,
|
||||
CategoryName: categoryName,
|
||||
PublishedAt: nilIfEmpty(publishedAt),
|
||||
Rows: []dto.EventResultRow{},
|
||||
})
|
||||
}
|
||||
out[i].Rows = append(out[i].Rows, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
// 行是按发布时间倒序取回的,组内要改回名次顺序;无名次的排最后。
|
||||
for i := range out {
|
||||
sort.SliceStable(out[i].Rows, func(a, b int) bool {
|
||||
ra, rb := out[i].Rows[a], out[i].Rows[b]
|
||||
switch {
|
||||
case ra.Rank == nil && rb.Rank == nil:
|
||||
return ra.EntryID < rb.EntryID
|
||||
case ra.Rank == nil:
|
||||
return false
|
||||
case rb.Rank == nil:
|
||||
return true
|
||||
default:
|
||||
return *ra.Rank < *rb.Rank
|
||||
}
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func liveCandidate(id int64, offset time.Duration, order int) liveScheduledCandidate {
|
||||
return liveScheduledCandidate{ID: id, At: time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC).Add(offset), SortOrder: order}
|
||||
}
|
||||
|
||||
func liveNow() time.Time {
|
||||
return time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// manual 只认人工指定:人工没指定就是空,绝不回退。
|
||||
func TestPickLiveEventsManualOnly(t *testing.T) {
|
||||
now := liveNow()
|
||||
sel := pickLiveEvents("manual", []int64{7, 9, 11}, []liveScheduledCandidate{liveCandidate(1, 0, 0)}, 3, now)
|
||||
if sel.Source != "manual" {
|
||||
t.Fatalf("source 应原样保留,得到 %q", sel.Source)
|
||||
}
|
||||
if len(sel.IDs) != 2 || sel.IDs[0] != 7 || sel.IDs[1] != 9 {
|
||||
t.Fatalf("manual 应取人工指定且最多 2 个,得到 %+v", sel.IDs)
|
||||
}
|
||||
if sel.Reasons[7] != "manual" || sel.Reasons[9] != "manual" {
|
||||
t.Fatalf("manual 模式的入选原因应为 manual,得到 %+v", sel.Reasons)
|
||||
}
|
||||
|
||||
empty := pickLiveEvents("manual", nil, nil, 3, now)
|
||||
if len(empty.IDs) != 0 {
|
||||
t.Fatalf("manual 未指定时不应回退,得到 %+v", empty.IDs)
|
||||
}
|
||||
}
|
||||
|
||||
// auto:人工指定优先,其余候选取最近的,最多 2 个。
|
||||
func TestPickLiveEventsAutoChain(t *testing.T) {
|
||||
now := liveNow()
|
||||
|
||||
manualFirst := pickLiveEvents("auto", []int64{4}, []liveScheduledCandidate{liveCandidate(1, 0, 0)}, 9, now)
|
||||
if len(manualFirst.IDs) != 1 || manualFirst.IDs[0] != 4 || manualFirst.Reasons[4] != "manual" {
|
||||
t.Fatalf("auto 下人工指定应优先,得到 %+v / %+v", manualFirst.IDs, manualFirst.Reasons)
|
||||
}
|
||||
|
||||
// 三个排期候选:按离现在的远近排序,取最近的两个。
|
||||
scheduled := pickLiveEvents("auto", nil, []liveScheduledCandidate{
|
||||
liveCandidate(11, 20*time.Minute, 0),
|
||||
liveCandidate(12, -5*time.Minute, 2),
|
||||
liveCandidate(13, 3*time.Minute, 1),
|
||||
}, 9, now)
|
||||
if len(scheduled.IDs) != 2 || scheduled.IDs[0] != 13 || scheduled.IDs[1] != 12 {
|
||||
t.Fatalf("排期应按离现在远近取前 2 个,得到 %+v", scheduled.IDs)
|
||||
}
|
||||
if scheduled.Reasons[13] != "scheduled" {
|
||||
t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", scheduled.Reasons)
|
||||
}
|
||||
|
||||
// 距离相同时按 sort_order / id 稳定排序。
|
||||
tie := pickLiveEvents("auto", nil, []liveScheduledCandidate{
|
||||
liveCandidate(21, 5*time.Minute, 1),
|
||||
liveCandidate(22, -5*time.Minute, 0),
|
||||
}, 0, now)
|
||||
if len(tie.IDs) != 2 || tie.IDs[0] != 22 || tie.IDs[1] != 21 {
|
||||
t.Fatalf("距离相同应按 sort_order 排序,得到 %+v", tie.IDs)
|
||||
}
|
||||
|
||||
recent := pickLiveEvents("auto", nil, nil, 9, now)
|
||||
if len(recent.IDs) != 1 || recent.IDs[0] != 9 || recent.Reasons[9] != "recent" {
|
||||
t.Fatalf("都不命中时应回退到最近发布过成绩的项目,得到 %+v / %+v", recent.IDs, recent.Reasons)
|
||||
}
|
||||
|
||||
none := pickLiveEvents("auto", nil, nil, 0, now)
|
||||
if len(none.IDs) != 0 {
|
||||
t.Fatalf("空赛事不应选出任何项目,得到 %+v", none.IDs)
|
||||
}
|
||||
if none.Source != "auto" {
|
||||
t.Fatalf("source 应原样保留,得到 %q", none.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledAt(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
ok bool
|
||||
want int // 期望的 UTC 小时
|
||||
}{
|
||||
{"2026-09-11T10:30:00.000Z", true, 10},
|
||||
{"2026-09-11T10:30:00+08:00", true, 2},
|
||||
{"2026-09-11T10:30:00", true, 10},
|
||||
{"2026-09-11T10:30", true, 10},
|
||||
{"2026-09-11 10:30:00", true, 10},
|
||||
{"2026-09-11", true, 0},
|
||||
{"", false, 0},
|
||||
{"待定", false, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := parseScheduledAt(tc.raw)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("parseScheduledAt(%q) ok=%v,期望 %v", tc.raw, ok, tc.ok)
|
||||
}
|
||||
if ok && got.UTC().Hour() != tc.want {
|
||||
t.Fatalf("parseScheduledAt(%q) = %v,期望 UTC %d 点", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type liveEventsBody struct {
|
||||
Source string `json:"source"`
|
||||
EventIDs []int64 `json:"eventIds"`
|
||||
Events []struct {
|
||||
Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"event"`
|
||||
Reason string `json:"reason"`
|
||||
Groups []struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string
|
||||
Rows []struct {
|
||||
EntryID int64 `json:"entryId"`
|
||||
Rank *int `json:"rank"`
|
||||
ResultText string `json:"resultText"`
|
||||
} `json:"rows"`
|
||||
} `json:"groups"`
|
||||
} `json:"events"`
|
||||
}
|
||||
|
||||
type settingsBody struct {
|
||||
Display struct {
|
||||
LiveEvents []int64 `json:"liveEvents"`
|
||||
LiveSource string `json:"liveSource"`
|
||||
} `json:"display"`
|
||||
}
|
||||
|
||||
// 设置读写:display.live_events / display.live_source 要能存取,非法值必须当场拒绝。
|
||||
func TestSettingsLiveEventsRoundTrip(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"display": map[string]any{"liveEvents": []int64{ids.event}, "liveSource": "manual"},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存实时赛况设置应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
status, e := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读取设置应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
got := decodeData[settingsBody](t, e)
|
||||
if got.Display.LiveSource != "manual" || len(got.Display.LiveEvents) != 1 || got.Display.LiveEvents[0] != ids.event {
|
||||
t.Fatalf("设置应原样读回,得到 %+v", got.Display)
|
||||
}
|
||||
|
||||
// 库里以逗号分隔的 id 文本存储,随赛事包一起走。
|
||||
var raw string
|
||||
if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.live_events'`).Scan(&raw); err != nil {
|
||||
t.Fatalf("读取存储值: %v", err)
|
||||
}
|
||||
if raw != strconv.FormatInt(ids.event, 10) {
|
||||
t.Fatalf("display.live_events 应存逗号分隔 id,得到 %q", raw)
|
||||
}
|
||||
|
||||
// 清空人工指定:传空数组。
|
||||
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"display": map[string]any{"liveEvents": []int64{}},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("清空人工指定应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
_, e = env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
if cleared := decodeData[settingsBody](t, e); len(cleared.Display.LiveEvents) != 0 {
|
||||
t.Fatalf("清空后应回空数组,得到 %+v", cleared.Display.LiveEvents)
|
||||
}
|
||||
|
||||
// 非法取值全部拒绝。
|
||||
for _, patch := range []map[string]any{
|
||||
{"display": map[string]any{"liveSource": "sometimes"}},
|
||||
{"display": map[string]any{"liveEvents": []int64{ids.event, ids.event + 1, ids.event + 2}}},
|
||||
{"display": map[string]any{"liveEvents": []int64{999999}}},
|
||||
} {
|
||||
status, e := env.do(client, http.MethodPut, "/api/settings", patch, true)
|
||||
if status != http.StatusBadRequest || e.Error == nil || e.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("非法设置应 400 VALIDATION_FAILED,得到 %d %+v(%+v)", status, e.Error, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前项目的判定:manual 认人工指定;auto 先排期命中;都不命中回退到最近发布过成绩的项目;空赛事不炸。
|
||||
func TestDisplayLiveEndpoint(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")
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
adminClient, _ := env.client()
|
||||
env.login(adminClient, "admin", "admin-pass")
|
||||
|
||||
// 第二、三个小项:只用来验证人工指定(最多 2 个)。
|
||||
other := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
|
||||
other2 := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M400', '400米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
|
||||
|
||||
guest, _ := env.client()
|
||||
live := func() liveEventsBody {
|
||||
t.Helper()
|
||||
status, e := env.do(guest, http.MethodGet, "/api/display/live", nil, false)
|
||||
if status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
return decodeData[liveEventsBody](t, e)
|
||||
}
|
||||
setDisplay := func(patch map[string]any) {
|
||||
t.Helper()
|
||||
if status, e := env.do(adminClient, http.MethodPut, "/api/settings",
|
||||
map[string]any{"display": patch}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存设置应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 1) manual 只认人工指定(两个)。
|
||||
setDisplay(map[string]any{"liveSource": "manual", "liveEvents": []int64{other, other2}})
|
||||
got := live()
|
||||
if got.Source != "manual" || len(got.EventIDs) != 2 || got.EventIDs[0] != other || got.EventIDs[1] != other2 {
|
||||
t.Fatalf("manual 应只认人工指定的两个项目 %d/%d,得到 %+v", other, other2, got)
|
||||
}
|
||||
if len(got.Events) != 2 || got.Events[0].Reason != "manual" || got.Events[0].Event.ID != other {
|
||||
t.Fatalf("manual 命中原因应为 manual,得到 %+v", got.Events)
|
||||
}
|
||||
if len(got.Events[0].Groups) != 0 {
|
||||
t.Fatalf("还没发布成绩时应回空组,得到 %+v", got.Events[0].Groups)
|
||||
}
|
||||
|
||||
// 2) auto:排期落在当前窗口内的小项命中。
|
||||
scheduled := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339)
|
||||
env.exec(t, `UPDATE events SET scheduled_at = ? WHERE id = ?`, scheduled, ids.event)
|
||||
setDisplay(map[string]any{"liveSource": "auto", "liveEvents": []int64{}})
|
||||
got = live()
|
||||
if got.Source != "auto" || len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event {
|
||||
t.Fatalf("auto 应命中排期窗口内的小项 %d,得到 %+v", ids.event, got)
|
||||
}
|
||||
if got.Events[0].Reason != "scheduled" {
|
||||
t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", got.Events[0])
|
||||
}
|
||||
|
||||
// 3) auto:排期都不命中时回退到最近发布过成绩的小项。
|
||||
env.exec(t, `UPDATE events SET scheduled_at = NULL WHERE id = ?`, ids.event)
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "status": "ok"},
|
||||
},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
got = live()
|
||||
if len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event {
|
||||
t.Fatalf("无排期命中时应回退到最近发布的小项 %d,得到 %+v", ids.event, got)
|
||||
}
|
||||
if got.Events[0].Reason != "recent" {
|
||||
t.Fatalf("回退的原因应为 recent,得到 %+v", got.Events[0])
|
||||
}
|
||||
if len(got.Events[0].Groups) != 1 || len(got.Events[0].Groups[0].Rows) != 1 {
|
||||
t.Fatalf("最近发布的小项应带上已发布成绩(1 条),得到 %+v", got.Events[0].Groups)
|
||||
}
|
||||
if rank := got.Events[0].Groups[0].Rows[0].Rank; rank == nil || *rank != 1 {
|
||||
t.Fatalf("已发布成绩应带名次,得到 %+v", got.Events[0].Groups[0].Rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 全新赛事(一个小项都没有)时不能报错。
|
||||
func TestDisplayLiveEmptyEvent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client, _ := env.client()
|
||||
status, e := env.do(client, http.MethodGet, "/api/display/live", nil, false)
|
||||
if status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("空赛事读取实时赛况应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
got := decodeData[liveEventsBody](t, e)
|
||||
if len(got.EventIDs) != 0 || len(got.Events) != 0 {
|
||||
t.Fatalf("空赛事应回空列表,得到 %+v", got)
|
||||
}
|
||||
if got.Source != "auto" {
|
||||
t.Fatalf("未配置时默认 auto,得到 %q", got.Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedBatch 在 seed 的基础上补齐到 30 条报名项(第 1、2 条来自 seed),
|
||||
// 供「现场分批比赛」场景使用:跑道只有 8 条,一次只跑完一批。
|
||||
func seedBatch(t *testing.T, env *testEnv, ids seedIDs, total int) []int64 {
|
||||
t.Helper()
|
||||
entries := []int64{ids.entryA, ids.entryB}
|
||||
for i := len(entries) + 1; i <= total; i++ {
|
||||
team := ids.teamA
|
||||
if i%2 == 0 {
|
||||
team = ids.teamB
|
||||
}
|
||||
athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||||
fmt.Sprintf("运动员%02d", i), team)
|
||||
entries = append(entries, env.insert(t,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, team, athlete))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// 批次草稿:前 filled 条填成绩,其余留空,全量发给服务端(与录入页 buildDraftPayload 一致)。
|
||||
func batchDraft(entries []int64, filled, from int, times []float64) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(entries)-from)
|
||||
for i := from; i < len(entries); i++ {
|
||||
row := map[string]any{"entryId": entries[i], "status": "ok"}
|
||||
if i < filled {
|
||||
value := times[i]
|
||||
row["resultValue"] = value
|
||||
row["resultText"] = fmt.Sprintf("%.2f", value)
|
||||
row["unit"] = "s"
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, env *testEnv, query string, want int, args ...any) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := env.ev.QueryRow(query, args...).Scan(&got); err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("期望 %d,得到 %d(%s)", want, got, query)
|
||||
}
|
||||
}
|
||||
|
||||
func rankOfEntry(t *testing.T, env *testEnv, entryID int64) int {
|
||||
t.Helper()
|
||||
var rank int
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
|
||||
t.Fatalf("读取名次: %v", err)
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// 现场分批比赛:30 人报名,先跑完 8 人发布,再跑完 8 人发布。
|
||||
// 关键要求:没跑/没录的行不阻塞发布、不被发布;已发布的先算名次积分,补发后重算要纳入先前批次。
|
||||
func TestPartialPublishKeepsUnfinishedRowsDraft(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 30)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 第一批:第 1–8 人跑完,成绩由慢到快;其余 22 人留空。
|
||||
first := make([]float64, 30)
|
||||
for i := range first {
|
||||
first[i] = 20.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 8, 0, first),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存第一批草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("缺 22 条成绩时发布应成功(不报错),得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 只有 8 条被发布,其余 22 条仍是草稿。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 22, ids.event)
|
||||
// 未录行必须保持空值:发布不能给它们塞任何成绩。
|
||||
assertCount(t, env,
|
||||
`SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft' AND result_value IS NULL AND rank IS NULL`,
|
||||
22, ids.event)
|
||||
for i := 0; i < 8; i++ {
|
||||
if got := rankOfEntry(t, env, entries[i]); got != i+1 {
|
||||
t.Fatalf("第一批第 %d 人应第 %d 名,得到 %d", i+1, i+1, got)
|
||||
}
|
||||
}
|
||||
// 积分只按已发布的 8 人算:前六名 10+8+6+3+2+1=30 分。
|
||||
assertCount(t, env,
|
||||
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`,
|
||||
30, ids.event)
|
||||
|
||||
// 第二批:第 9–16 人跑完且明显更快。已发布的 1–8 不能再存草稿,只补未发布的行。
|
||||
second := make([]float64, 30)
|
||||
for i := range second {
|
||||
second[i] = 10.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 16, 8, second),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("补录第二批草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 16, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 14, ids.event)
|
||||
// 先前的 8 人必须被重算纳入:第二批更快,第一批整体退到第 9–16 名。
|
||||
if got := rankOfEntry(t, env, entries[0]); got != 9 {
|
||||
t.Fatalf("补发后第一批第一名应退到第 9 名(证明重算纳入了先前批次),得到 %d", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, entries[8]); got != 1 {
|
||||
t.Fatalf("第二批最快者应第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, entries[15]); got != 8 {
|
||||
t.Fatalf("第二批最慢者应第 8 名,得到 %d", got)
|
||||
}
|
||||
// 16 人里仍只有前六名得分:30 分;流水不能因为重算而翻倍。
|
||||
assertCount(t, env,
|
||||
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`,
|
||||
30, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||
}
|
||||
|
||||
// 一条有效成绩 + 29 条空成绩:发布必须成功,且只有那一条上屏。
|
||||
// 这也是「服务端是否会因缺成绩报错」的回归断言。
|
||||
func TestPublishWithSingleFilledRow(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 30)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 30)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 1, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true)
|
||||
if status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("缺 29 条成绩不是错误,发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 29, ids.event)
|
||||
if got := rankOfEntry(t, env, entries[0]); got != 1 {
|
||||
t.Fatalf("唯一有效成绩应第 1 名,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS / DQ 这类「没有成绩但确实是现场结果」的行必须能发布(不能被当成空行留在草稿)。
|
||||
func TestPublishKeepsNonOKStatusWithoutValue(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")
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "status": "dns"},
|
||||
},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("含 DNS 的组应能发布,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 0, ids.event)
|
||||
}
|
||||
@@ -38,6 +38,13 @@ type voidResultPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// entryHeatPayload 是批量设置批次的请求体:heat 为 null / 缺省表示清除批次(未分批)。
|
||||
type entryHeatPayload struct {
|
||||
EntryIDs []int64 `json:"entryIds"`
|
||||
Heat *int64 `json:"heat"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type adjustmentInput struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Delta float64 `json:"delta"`
|
||||
@@ -98,6 +105,11 @@ type settingsDisplayPatch struct {
|
||||
// FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。
|
||||
FontTitle *string `json:"fontTitle"`
|
||||
FontBody *string `json:"fontBody"`
|
||||
// LiveEvents 是「实时赛况」人工指定的当前项目 id(最多 2 个)。
|
||||
// nil 表示不改动;传 [] 表示清空人工指定。
|
||||
LiveEvents *[]int64 `json:"liveEvents"`
|
||||
// LiveSource 是当前项目的判定方式(manual / auto);nil 表示不改动。
|
||||
LiveSource *string `json:"liveSource"`
|
||||
}
|
||||
|
||||
type settingsStandingsPatch struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -385,7 +386,7 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
// ---------- entries ----------
|
||||
|
||||
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
|
||||
e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
|
||||
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
|
||||
|
||||
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
var (
|
||||
@@ -393,6 +394,7 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
athleteID sql.NullInt64
|
||||
memberNames sql.NullString
|
||||
bib, seed, rem sql.NullString
|
||||
heat sql.NullInt64
|
||||
athleteName sql.NullString
|
||||
teamCode sql.NullString
|
||||
teamName sql.NullString
|
||||
@@ -400,13 +402,14 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
categoryName sql.NullString
|
||||
)
|
||||
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
|
||||
&bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
|
||||
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.AthleteID = i64Ptr(athleteID)
|
||||
e.MemberNames = parseNames(memberNames)
|
||||
e.Bib = strPtr(bib)
|
||||
e.Seed = strPtr(seed)
|
||||
e.Heat = i64Ptr(heat)
|
||||
e.Remark = strPtr(rem)
|
||||
e.AthleteName = strPtr(athleteName)
|
||||
e.TeamCode = teamCode.String
|
||||
@@ -483,10 +486,10 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -506,9 +509,9 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
|
||||
bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -532,3 +535,75 @@ func (s *server) deleteEntry(ctx context.Context, id int64) error {
|
||||
}
|
||||
return requireChanges(res, "报名项不存在")
|
||||
}
|
||||
|
||||
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
|
||||
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
|
||||
const maxEntryHeat = 999
|
||||
|
||||
// setEntriesHeat 给一批报名项统一设置批次(heat 为 nil 表示清除批次 / 未分批)。
|
||||
//
|
||||
// 只改 entries.heat,不碰任何成绩与积分:批次不参与算分,排名与积分仍按「小项 + 组别」算。
|
||||
// 幂等:重复提交同一个批次号结果一致。要求所有报名项属于同一个小项(跨小项批量设置是误操作)。
|
||||
func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int64) (entryHeatResultDTO, error) {
|
||||
ids := dedupeIDs(entryIDs)
|
||||
if len(ids) == 0 {
|
||||
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
|
||||
}
|
||||
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
|
||||
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
found := make(map[int64]bool, len(ids))
|
||||
events := make(map[int64]bool)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
eventID int64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &eventID); err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
found[entryID] = true
|
||||
events[eventID] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
missing := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
if !found[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return entryHeatResultDTO{}, badRequest("部分报名项不存在", missing)
|
||||
}
|
||||
// 批次挂在「小项 + 组别」之下,批量设置跨小项一定是选错了行。
|
||||
if len(events) > 1 {
|
||||
return entryHeatResultDTO{}, badRequest("不能跨小项批量设置批次,请在同一小项内操作", nil)
|
||||
}
|
||||
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET heat = ? WHERE id IN (`+placeholders(len(ids))+`)`,
|
||||
append([]any{nullI64(heat)}, args...)...)
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
updated, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
return entryHeatResultDTO{Updated: int(updated), Heat: heat, EntryIDs: ids}, nil
|
||||
}
|
||||
@@ -287,10 +287,10 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, er
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil {
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered")); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
@@ -298,8 +298,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, er
|
||||
return false, err
|
||||
default:
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`,
|
||||
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed),
|
||||
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, heat = ?, status = ? WHERE id = ?`,
|
||||
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat),
|
||||
orDefault(in.Status, "registered"), existing); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -147,6 +148,36 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
// 实时赛况的当前项目:最多 2 个,且必须是真实存在的小项。
|
||||
// 存不存在的 id 只会让大屏整块空白,现场还得回管理端排查,不如写入时就拒绝。
|
||||
if p.LiveEvents != nil {
|
||||
if len(*p.LiveEvents) > dto.MaxLiveEvents {
|
||||
return dto.EventSettings{}, badRequest(fmt.Sprintf("display.liveEvents 最多 %d 个", dto.MaxLiveEvents), len(*p.LiveEvents))
|
||||
}
|
||||
ids := dedupeIDs(*p.LiveEvents)
|
||||
for _, id := range ids {
|
||||
var exists int
|
||||
err := tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return dto.EventSettings{}, badRequest("display.liveEvents 引用了不存在的小项", id)
|
||||
}
|
||||
if err != nil {
|
||||
return dto.EventSettings{}, internal(err)
|
||||
}
|
||||
}
|
||||
if err := set(dto.SettingKeyLiveEvents, dto.FormatLiveEventIDs(ids)); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
if p.LiveSource != nil {
|
||||
source := dto.NormalizeLiveSource(*p.LiveSource)
|
||||
if source == "" {
|
||||
return dto.EventSettings{}, badRequest("display.liveSource 只能是 manual / auto", *p.LiveSource)
|
||||
}
|
||||
if err := set(dto.SettingKeyLiveSource, source); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if p := patch.Standings; p != nil {
|
||||
if p.IndividualToTeamWeight != nil {
|
||||
|
||||
@@ -435,6 +435,16 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
|
||||
// ---------- 发布 / 撤销 / 作废 ----------
|
||||
|
||||
// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」:
|
||||
// - 有解析出来的成绩值(计时 / 距离 / 计数 / 评分项目);
|
||||
// - 有手工名次(resultMode=manual 或 compare=none 的项目,名次本身就是成绩);
|
||||
// - 状态不是 ok(DNS / DQ / DNF / 退赛 也是现场明确给出的结果,要能上屏)。
|
||||
//
|
||||
// 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft:
|
||||
// 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行,
|
||||
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
|
||||
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
|
||||
|
||||
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return nil, badRequest("eventId 必填", nil)
|
||||
@@ -458,9 +468,12 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
|
||||
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
|
||||
// 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。
|
||||
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE event_id = ?`
|
||||
WHERE event_id = ? AND ` + publishableResultCond
|
||||
args := []any{nullID(userID), in.EventID}
|
||||
if catID > 0 {
|
||||
setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
|
||||
|
||||
@@ -215,6 +215,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
|
||||
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
|
||||
|
||||
// 成绩
|
||||
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
||||
@@ -239,6 +241,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
// 大屏控制与运维
|
||||
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
|
||||
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
|
||||
// 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。
|
||||
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
|
||||
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
|
||||
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||||
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
||||
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
||||
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody"})
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"})
|
||||
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
|
||||
|
||||
|
||||
+6
-2
@@ -28,8 +28,12 @@ import (
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
// version 由构建时注入:-ldflags "-X main.version=..."
|
||||
var version = "dev_0.1.0"
|
||||
// version 是服务端版本号,供 /api/health 与管理端「关于」页使用。
|
||||
// 真实版本来自仓库根的 VERSION 文件,由 deploy/build.bat 在构建时用
|
||||
// -ldflags "-X main.version=..." 注入;下面这行只是没走构建脚本(例如 go run)时的兜底。
|
||||
// 兜底值故意不写具体版本号:写死一个数字迟早会和 VERSION 漂移,报出一个"看着像真版本"的假值;
|
||||
// 这样直接跑源码时一眼就能看出「这不是构建产物」。
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "backup" {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 批次是「组别之下」的录入 / 展示分组,不参与算分(排名与积分仍按「小项 + 组别」算)。
|
||||
-- 历史报名没有批次,允许为空;空值(NULL)表示「未分批」。
|
||||
ALTER TABLE entries ADD COLUMN heat INTEGER;
|
||||
@@ -529,3 +529,90 @@ func TestMigration0018AddsDeletedAtForExistingTeams(t *testing.T) {
|
||||
t.Fatalf("清 deleted_at: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 老库升级:0019 只给 entries 补一列 heat(可空),已有报名一行不少、批次一律为空(未分批)。
|
||||
// 批次只是录入 / 展示分组,迁移不能改动任何成绩或积分数据。
|
||||
func TestMigration0019AddsHeatForExistingEntries(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
applyBefore(t, db, "0019")
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||||
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
|
||||
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
var probe sql.NullInt64
|
||||
if err := db.QueryRow(`SELECT heat FROM entries`).Scan(&probe); err == nil {
|
||||
t.Fatal("升级前不该有 heat 列")
|
||||
}
|
||||
|
||||
// 走真实升级路径;ALTER TABLE ADD COLUMN 只登记一次,重放会报 duplicate column。
|
||||
for i := 1; i <= 2; i++ {
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||||
}
|
||||
for table, want := range map[string]int{
|
||||
"entries": 1, "results": 1, "score_ledger": 1,
|
||||
} {
|
||||
if got := countRows(t, db, table); got != want {
|
||||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 老报名一律「未分批」,成绩与积分原样保留。
|
||||
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if probe.Valid {
|
||||
t.Errorf("老报名的 heat 应为空(未分批),得到 %d", probe.Int64)
|
||||
}
|
||||
var (
|
||||
rank int
|
||||
state string
|
||||
)
|
||||
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
|
||||
t.Fatalf("读成绩: %v", err)
|
||||
}
|
||||
if rank != 1 || state != "published" {
|
||||
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
|
||||
}
|
||||
var points float64
|
||||
if err := db.QueryRow(`SELECT points FROM score_ledger WHERE entry_id = 1`).Scan(&points); err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
if points != 10 {
|
||||
t.Errorf("老积分被改动: %v", points)
|
||||
}
|
||||
|
||||
// 新列可写可清。
|
||||
if _, err := db.Exec(`UPDATE entries SET heat = 3 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("写 heat: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if !probe.Valid || probe.Int64 != 3 {
|
||||
t.Errorf("写入的 heat 未生效: %+v", probe)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE entries SET heat = NULL WHERE id = 1`); err != nil {
|
||||
t.Fatalf("清 heat: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
AuditLog,
|
||||
CurrentUser,
|
||||
DisplayCommand,
|
||||
EntryHeatPayload,
|
||||
EntryHeatResult,
|
||||
EntryWithResult,
|
||||
Event,
|
||||
EventSettings,
|
||||
HealthInfo,
|
||||
LiveEvents,
|
||||
Page,
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
@@ -139,6 +142,13 @@ export const adminApi = {
|
||||
entries: (eventId: number): Promise<EntryWithResult[]> =>
|
||||
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
|
||||
|
||||
/**
|
||||
* 批量设置 / 清除批次(heat)。写操作不降级:示例数据模式下没有后端可落库,
|
||||
* 页面会先看 data.isMock 再决定是否调用。
|
||||
*/
|
||||
setEntriesHeat: (payload: EntryHeatPayload): Promise<EntryHeatResult> =>
|
||||
callWrite(() => client.entries.setHeat(payload)),
|
||||
|
||||
saveDraft: (payload: SaveDraftPayload): Promise<Result[]> =>
|
||||
callWrite(() => client.results.saveDraft(payload)),
|
||||
|
||||
@@ -198,6 +208,18 @@ export const adminApi = {
|
||||
() => [],
|
||||
),
|
||||
|
||||
/**
|
||||
* 「实时赛况」当前项目(服务端按设置与时序判定)。
|
||||
*
|
||||
* 判定的依据是排期与最近一次发布,随赛事进程自己变化;示例数据里没有这套时序,
|
||||
* 连不上时给空结论、由页面显示「当前没有可显示的项目」,比弹错更有用。
|
||||
*/
|
||||
displayLive: (): Promise<LiveEvents> =>
|
||||
call(
|
||||
() => client.display.live(),
|
||||
() => ({ source: 'auto', eventIds: [], events: [] }),
|
||||
),
|
||||
|
||||
login: (username: string, password: string): Promise<CurrentUser> =>
|
||||
callWrite(() => client.auth.login(username, password)),
|
||||
|
||||
|
||||
@@ -369,6 +369,7 @@ class MockDb {
|
||||
memberNames: seed.eventType === 'individual' ? null : [`${team.shortName ?? team.name}代表队`],
|
||||
bib: seed.eventType === 'individual' ? String(entryId % 1000) : null,
|
||||
seed: null,
|
||||
heat: null,
|
||||
status: 'registered',
|
||||
remark: null,
|
||||
athleteName,
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
const data = useBaseData()
|
||||
const toast = useToastStore()
|
||||
|
||||
// heat 列是批次:留空 = 未分批,也可以直接填正整数(如 1、2)。
|
||||
const TEMPLATE_CSV = [
|
||||
REQUIRED_COLUMNS.join(','),
|
||||
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,registered,',
|
||||
'W100,女子组,F2,王芳,,女,,202,,registered,',
|
||||
'BB,混合组,F2,,,,,,种子1,registered,第一分公司代表队',
|
||||
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,1,registered,',
|
||||
'W100,女子组,F2,王芳,,女,,202,,2,registered,',
|
||||
'BB,混合组,F2,,,,,,种子1,,registered,第一分公司代表队',
|
||||
].join('\n')
|
||||
|
||||
const MAX_SIZE = 16 * 1024 * 1024
|
||||
@@ -189,8 +190,9 @@ function downloadTemplate(): void {
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age 可缺省,
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||
证件号与号码布都是人员唯一标识:
|
||||
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,11 @@ import type {
|
||||
ImportConflictField,
|
||||
} from '@/shared'
|
||||
|
||||
/** 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 */
|
||||
/**
|
||||
* 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。
|
||||
*
|
||||
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
|
||||
*/
|
||||
export const REQUIRED_COLUMNS = [
|
||||
'event_code',
|
||||
'category',
|
||||
@@ -24,12 +28,13 @@ export const REQUIRED_COLUMNS = [
|
||||
'age',
|
||||
'bib',
|
||||
'seed',
|
||||
'heat',
|
||||
'status',
|
||||
'member_names',
|
||||
] as const
|
||||
|
||||
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age'] as const
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const
|
||||
|
||||
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
|
||||
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
|
||||
@@ -92,6 +97,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
'',
|
||||
athlete.bib ?? '',
|
||||
'',
|
||||
// 批次留空 = 未分批:批次在录入页按现场编排(批量设置 / 一键均分)。
|
||||
'',
|
||||
status,
|
||||
'',
|
||||
])
|
||||
@@ -142,6 +149,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
// 批次留空 = 未分批,与个人项目一致。
|
||||
'',
|
||||
status,
|
||||
team.memberNames.join('/'),
|
||||
])
|
||||
|
||||
@@ -118,6 +118,12 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="mx-app admin-shell">
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-backdrop"
|
||||
aria-hidden="true"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
<aside class="mx-sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="mx-logo brand">
|
||||
<img class="app-icon" src="/scoreboard.png" alt="" width="28" height="28" />
|
||||
@@ -157,7 +163,7 @@ onMounted(async () => {
|
||||
<MxIcon name="logs" :size="18" />
|
||||
</button>
|
||||
<div class="mx-breadcrumb">
|
||||
<span>管理面板</span>
|
||||
<span class="crumb-root">管理面板</span>
|
||||
<span class="mx-breadcrumb-sep">/</span>
|
||||
<span>{{ currentTitle }}</span>
|
||||
</div>
|
||||
@@ -237,9 +243,31 @@ onMounted(async () => {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
/*
|
||||
* 桌面壳是 .mx-app(≥1024 为 row)的 flex 子项:vendor 给的是 width:100%,
|
||||
* 会按容器整宽撑开,再叠加 240px 侧边栏就把右侧内容顶到视口外;
|
||||
* .admin-shell 又是 overflow:hidden,多出来的部分直接被裁掉(1024 下表格右侧列、
|
||||
* 顶栏按钮全部消失)。flex:1 + min-width:0 让壳按剩余宽度收缩,
|
||||
* 宽表格的横向滚动条才能落在卡片内部而不是撑破整页。
|
||||
*/
|
||||
.mx-desktop-shell {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.mx-desktop-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.menu-btn {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 480;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.brand {
|
||||
padding: 6px 10px 14px;
|
||||
}
|
||||
@@ -270,6 +298,18 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/*
|
||||
* 顶栏左侧:vendor 只有 .mx-topbar-left,没有 .topbar-left,
|
||||
* 不补这条的话菜单按钮与面包屑会各占一行堆成 58px 高,把 52px 顶栏顶穿
|
||||
* (按钮顶端被视口裁掉,手机上点不到)。
|
||||
*/
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* 窄屏下 vendor 不提供桌面顶栏样式,这里补一份,保证 1024 以下也能用。 */
|
||||
.topbar {
|
||||
display: flex;
|
||||
@@ -339,6 +379,17 @@ onMounted(async () => {
|
||||
/* vendor 的 layout.css 在窄屏隐藏桌面壳,管理端必须始终显示主内容区。 */
|
||||
.mx-desktop-shell {
|
||||
display: flex !important;
|
||||
/* vendor 只在 ≥1024 的规则里给了 flex-direction:column;
|
||||
窄屏下必须自己补一份,否则顶栏与主内容区并排成一行(row),
|
||||
主内容区被压成几十像素宽——窄屏"整页空白"就是这么来的。 */
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.mx-desktop-main {
|
||||
/* 窄屏收紧留白,把宽度让给内容 */
|
||||
padding: 14px 14px 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
.menu-btn {
|
||||
display: inline-flex;
|
||||
@@ -347,15 +398,71 @@ onMounted(async () => {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 500;
|
||||
width: min(280px, 84vw);
|
||||
max-width: 100%;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--mx-d2) var(--mx-ease);
|
||||
display: block;
|
||||
box-shadow: var(--mx-sh3);
|
||||
}
|
||||
.mx-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar-backdrop {
|
||||
display: block;
|
||||
}
|
||||
.who-text {
|
||||
display: none;
|
||||
}
|
||||
/* 抽屉在平板宽度也要用,vendor 只在 ≤767 给了 44px,这里补到整个窄屏区间。 */
|
||||
.mx-nav-item {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* 手机端顶栏只保留:菜单 / 当前页名 / 连接徽标 / 主题 / 刷新 / 头像 / 退出。
|
||||
版本号与面包屑前缀会把 390px 顶栏顶到屏幕外,这里逐项减负。 */
|
||||
.topbar {
|
||||
padding: 0 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.topbar-left {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.topbar-right {
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.version {
|
||||
display: none;
|
||||
}
|
||||
/* 只留当前页名:前缀"管理面板 /"占掉一半宽度,会把标题挤成省略号。 */
|
||||
.crumb-root,
|
||||
.mx-breadcrumb-sep {
|
||||
display: none;
|
||||
}
|
||||
.mx-breadcrumb {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.readonly-banner,
|
||||
.mock-banner {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.readonly-banner .mx-btn,
|
||||
.error-banner .mx-btn {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
/* 超窄屏再把头像让出来给当前页名,登录人身份在窄屏不是必须信息。 */
|
||||
.who {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -133,6 +133,23 @@ async function submit(): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/*
|
||||
* 这几个输入框没有 .mx-input-wrap 撑高,而 vendor 的 .mx-input 是 height:100%,
|
||||
* 结果塌成 22px 的一条线:既看不出是输入框,手机上也不好点。
|
||||
* 管理端的裸 .mx-input 在这里补一个实际高度。
|
||||
*/
|
||||
.password-form .mx-input {
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
background: var(--mx-s3);
|
||||
border: 1px solid var(--mx-border);
|
||||
border-radius: var(--mx-r-sm);
|
||||
}
|
||||
.password-form .mx-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--mx-primary);
|
||||
box-shadow: 0 0 0 3px var(--mx-primary-glow);
|
||||
}
|
||||
.password-form .mx-label:not(:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import { formatDateTime, type EventSettings, type LiveEvents } from '@/shared'
|
||||
import type { DisplayInstance } from '@/shared/api'
|
||||
import { DISPLAY_PAGES, type DisplayPageKey } from '@/display/types'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { settingsApi } from '../components/system/systemApi'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -31,15 +32,129 @@ const pending = ref<string | null>(null)
|
||||
*
|
||||
* 先取启动时加载到的设置,再向 GET /api/settings 核对一次:公告是别的管理员推的、
|
||||
* 或者本页面开着时被推过,本地这份设置可能是旧的。
|
||||
* 同一份设置也带「实时赛况」的当前项目配置,一起刷新省一次请求。
|
||||
*/
|
||||
const currentAnnounce = ref<string | null>(data.settings?.display?.announce ?? null)
|
||||
/** 最近一次读到的完整设置,保存实时赛况时要拿它拼出完整的 display 块。 */
|
||||
const settingsSnapshot = ref<EventSettings | null>(data.settings ?? null)
|
||||
|
||||
// ── 实时赛况:当前项目(人工指定 / 自动判定) ──────────────────────────────
|
||||
|
||||
const LIVE_SOURCE_OPTIONS: Array<{ value: 'auto' | 'manual'; label: string; hint: string }> = [
|
||||
{ value: 'auto', label: '自动判定', hint: '人工指定 → 排期命中(开始时间前后 30 分钟)→ 最近发布过成绩的项目' },
|
||||
{ value: 'manual', label: '人工指定', hint: '只显示下面指定的项目,不做任何回退' },
|
||||
]
|
||||
|
||||
const liveSource = ref<'auto' | 'manual'>('auto')
|
||||
/** 人工指定的当前项目 id,最多 2 个;0 表示该槽位为空。 */
|
||||
const liveEvents = ref<number[]>([])
|
||||
const liveSaving = ref(false)
|
||||
const liveResolved = ref<LiveEvents | null>(null)
|
||||
const liveLoading = ref(false)
|
||||
|
||||
const LIVE_REASON_LABELS: Record<string, string> = {
|
||||
manual: '人工指定',
|
||||
scheduled: '排期命中',
|
||||
recent: '最近发布',
|
||||
}
|
||||
|
||||
const liveResolvedText = computed(() => {
|
||||
const resolved = liveResolved.value
|
||||
if (resolved === null || resolved.events.length === 0) return '当前没有可显示的项目'
|
||||
return resolved.events
|
||||
.map((item) => `${item.event.name}(${LIVE_REASON_LABELS[item.reason] ?? item.reason})`)
|
||||
.join('、')
|
||||
})
|
||||
|
||||
/** 下拉里只列真实小项;已删除的项目不会出现在这里。 */
|
||||
const eventOptions = computed(() => data.events.map((event) => ({ id: event.id, name: event.name })))
|
||||
|
||||
function pickAt(index: number): number {
|
||||
return liveEvents.value[index] ?? 0
|
||||
}
|
||||
|
||||
/** 同一个项目不能同时占两个槽位:写入时去重,空槽位(0)自然被挤到后面。 */
|
||||
function setPick(index: number, event: Event): void {
|
||||
const value = Number((event.target as HTMLSelectElement).value)
|
||||
const next = [...liveEvents.value]
|
||||
next[index] = Number.isFinite(value) ? value : 0
|
||||
const cleaned: number[] = []
|
||||
for (const id of next) {
|
||||
if (id > 0 && !cleaned.includes(id)) cleaned.push(id)
|
||||
}
|
||||
liveEvents.value = cleaned.slice(0, 2)
|
||||
}
|
||||
|
||||
function clearPick(index: number): void {
|
||||
liveEvents.value = liveEvents.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
async function refreshLive(): Promise<void> {
|
||||
liveLoading.value = true
|
||||
try {
|
||||
liveResolved.value = await adminApi.displayLive()
|
||||
} catch {
|
||||
// 兜底:页面出错也不要把「当前项目」清成未知。
|
||||
} finally {
|
||||
liveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLive(): Promise<void> {
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可保存', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
// 按钮虽然已置灰,键盘/程序化触发仍可能走到这里;给一条明确的权限回答而不是等 403。
|
||||
if (!isAdmin.value) {
|
||||
toast.error('需要管理员权限', '实时赛况保存在赛事设置里(PUT /api/settings),仅管理员可修改')
|
||||
return
|
||||
}
|
||||
if (data.isMock) {
|
||||
toast.error('示例数据模式不能保存', '后端未就绪,改动不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
const base = settingsSnapshot.value
|
||||
if (base === null) {
|
||||
toast.error('读不到当前设置', '请先刷新页面再试')
|
||||
return
|
||||
}
|
||||
const ids = liveEvents.value.filter((id) => id > 0).slice(0, 2)
|
||||
liveSaving.value = true
|
||||
try {
|
||||
const result = await settingsApi.save({
|
||||
display: { ...base.display, liveEvents: ids, liveSource: liveSource.value },
|
||||
})
|
||||
settingsSnapshot.value = result.data
|
||||
liveEvents.value = [...(result.data.display?.liveEvents ?? ids)]
|
||||
await refreshLive()
|
||||
toast.success('实时赛况设置已保存', '大屏将按新配置显示当前项目')
|
||||
} catch (caught) {
|
||||
toast.error('保存实时赛况失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
liveSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSettings(): Promise<void> {
|
||||
try {
|
||||
const settings = await adminApi.client.settings.get()
|
||||
settingsSnapshot.value = settings
|
||||
currentAnnounce.value = settings.display?.announce ?? null
|
||||
liveSource.value = settings.display?.liveSource === 'manual' ? 'manual' : 'auto'
|
||||
liveEvents.value = [...(settings.display?.liveEvents ?? [])]
|
||||
} catch {
|
||||
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
|
||||
}
|
||||
}
|
||||
|
||||
/** 只核对公告:推送 / 清除公告后调用,不能顺手覆盖管理员正在编辑的实时赛况配置。 */
|
||||
async function refreshAnnounce(): Promise<void> {
|
||||
try {
|
||||
const settings = await adminApi.client.settings.get()
|
||||
currentAnnounce.value = settings.display?.announce ?? null
|
||||
} catch {
|
||||
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
|
||||
// 同上:读不到就保留本地这份。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +319,8 @@ function onVisibilityChange(): void {
|
||||
|
||||
onMounted(() => {
|
||||
void loadDisplays()
|
||||
void refreshAnnounce()
|
||||
void refreshSettings()
|
||||
void refreshLive()
|
||||
void refreshInstances()
|
||||
startPolling()
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
@@ -218,6 +334,21 @@ onBeforeUnmount(() => {
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
const onlineCount = computed(() => instances.value.filter((instance) => instance.online).length)
|
||||
|
||||
/**
|
||||
* 权限口径与系统设置各面板一致(不新增角色)。
|
||||
*
|
||||
* 本页大多数写操作走 POST /api/display/command(recorder 即可,canWrite 已覆盖),
|
||||
* 但「实时赛况」这一组保存走 PUT /api/settings,是 admin-only:记录员点保存只会吃 403。
|
||||
* 既然权限不放开,界面就按同一口径提前置灰并说明原因,别让人填完才失败。
|
||||
*/
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
/** 非 null 表示实时赛况这组控件被权限挡住;同一句话既做提示条也做 title 悬浮说明。 */
|
||||
const liveGateReason = computed(() => {
|
||||
if (auth.readOnly) return '请先登录后再修改实时赛况。'
|
||||
if (!isAdmin.value) return '只有管理员能修改实时赛况,当前账号为只读。'
|
||||
return null
|
||||
})
|
||||
|
||||
/**
|
||||
* 实例的显示名:有屏号用屏号,局域网浏览器没有屏号,退回来源 IP,都用不上才退回实例 id。
|
||||
* 只用于提示语,表格里始终显示完整 id。
|
||||
@@ -487,6 +618,90 @@ async function send(
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">实时赛况</span>
|
||||
<span class="mx-badge" :class="liveSource === 'manual' ? 'mx-badge-neutral' : 'mx-badge-info'">
|
||||
{{ liveSource === 'manual' ? '人工指定' : '自动判定' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p v-if="liveGateReason !== null" class="notice">{{ liveGateReason }}</p>
|
||||
<div class="live-source">
|
||||
<label
|
||||
v-for="option in LIVE_SOURCE_OPTIONS"
|
||||
:key="option.value"
|
||||
class="live-source-option"
|
||||
:class="{ active: liveSource === option.value, locked: liveGateReason !== null }"
|
||||
:title="liveGateReason ?? undefined"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="live-source"
|
||||
:value="option.value"
|
||||
:checked="liveSource === option.value"
|
||||
:disabled="liveGateReason !== null || liveSaving"
|
||||
:title="liveGateReason ?? undefined"
|
||||
@change="liveSource = option.value"
|
||||
/>
|
||||
<span class="live-source-text">{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint live-hint">
|
||||
{{ LIVE_SOURCE_OPTIONS.find((option) => option.value === liveSource)?.hint }}
|
||||
</p>
|
||||
|
||||
<div class="live-picks">
|
||||
<div v-for="index in 2" :key="index" class="live-pick">
|
||||
<span class="live-pick-label">项目 {{ index }}</span>
|
||||
<select
|
||||
class="mx-cell-input live-pick-select"
|
||||
:value="pickAt(index - 1)"
|
||||
:disabled="liveGateReason !== null || liveSaving"
|
||||
:title="liveGateReason ?? undefined"
|
||||
@change="setPick(index - 1, $event)"
|
||||
>
|
||||
<option :value="0">未指定</option>
|
||||
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="pickAt(index - 1) > 0"
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="liveGateReason !== null || liveSaving"
|
||||
:title="liveGateReason ?? undefined"
|
||||
@click="clearPick(index - 1)"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="announce-foot">
|
||||
<span class="live-resolved">
|
||||
当前生效:<strong v-if="!liveLoading">{{ liveResolvedText }}</strong>
|
||||
<span v-else>读取中…</span>
|
||||
</span>
|
||||
<div class="announce-actions">
|
||||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="liveLoading" @click="refreshLive">
|
||||
<MxIcon name="refresh" :size="15" /> 刷新
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="liveGateReason !== null || liveSaving"
|
||||
:title="liveGateReason ?? undefined"
|
||||
@click="saveLive"
|
||||
>
|
||||
<MxIcon name="check" :size="15" />
|
||||
{{ liveSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">当前项目最多 2 个。保存后大屏的「实时赛况」页按此显示。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">显示器</span>
|
||||
@@ -715,6 +930,19 @@ async function send(
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
}
|
||||
/*
|
||||
* 权限提示条:与系统设置各面板(EventInfoPanel / UsersPanel / BackupPanel / ExportPage)
|
||||
* 的 .notice 同一套写法与取值,避免同一句「只有管理员能…」在这里长成另一个样子。
|
||||
*/
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.7;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s3);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.textarea-wrap {
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
@@ -785,8 +1013,7 @@ async function send(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.display-pick-label {
|
||||
}.display-pick-label {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
white-space: nowrap;
|
||||
@@ -796,6 +1023,60 @@ async function send(
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.live-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.live-source-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.live-source-option.active .live-source-text {
|
||||
color: var(--mx-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 权限不足时整组控件是禁用的,光标别再做可点的样子。 */
|
||||
.live-source-option.locked {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.live-hint {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.live-picks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.live-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.live-pick-label {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-pick-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.live-resolved {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.live-resolved strong {
|
||||
color: var(--mx-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.display-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
import { resultCellIssue } from './entryGuards'
|
||||
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -146,24 +146,47 @@ function unitOf(type: Event['resultType']): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 发布前防呆:与录入页共用同一套文案,避免整组发布缺成绩 / 无法解析的行。 */
|
||||
function rowLabel(row: DraftRow, index: number): string {
|
||||
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||||
return `第 ${index + 1} 行 ${name}`
|
||||
}
|
||||
|
||||
function needsRankOf(group: DraftGroup): boolean {
|
||||
return group.event.resultMode === 'manual' || group.event.compare === 'none'
|
||||
}
|
||||
|
||||
/** 单行判定与录入页共用同一套纯函数:空成绩=未录(不挡),写错/缺名次=有错(挡)。 */
|
||||
function judgeRow(group: DraftGroup, row: DraftRow, index: number): EntryRowJudgement {
|
||||
return judgeEntryRow({
|
||||
label: rowLabel(row, index),
|
||||
resultText: row.resultText,
|
||||
unit: unitOf(group.event.resultType),
|
||||
status: row.status,
|
||||
rank: row.rank,
|
||||
needsRank: needsRankOf(group),
|
||||
})
|
||||
}
|
||||
|
||||
/** 发布前防呆:与录入页共用同一套判定,只有「写错」才挡发布,空成绩只是本次跳过。 */
|
||||
function publishBlockers(group: DraftGroup): string[] {
|
||||
const unit = unitOf(group.event.resultType)
|
||||
const manual = group.event.resultMode === 'manual' || group.event.compare === 'none'
|
||||
const blockers: string[] = []
|
||||
for (const [index, row] of group.rows.entries()) {
|
||||
if (row.status !== 'ok') continue
|
||||
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||||
const label = `第 ${index + 1} 行 ${name}`
|
||||
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
|
||||
const emptyAllowed = row.resultText.trim() === '' && manual && row.rank !== null
|
||||
const issue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit)
|
||||
if (issue !== null) blockers.push(issue.message)
|
||||
if (manual && row.rank === null) blockers.push(`${label}:缺少名次`)
|
||||
const blocker = judgeRow(group, row, index).blocker
|
||||
if (blocker !== null) blockers.push(blocker)
|
||||
}
|
||||
return blockers
|
||||
}
|
||||
|
||||
/** 本次发布会跳过的「还没录」行数,确认框必须说清楚只会发布已填的部分。 */
|
||||
function skippedCount(group: DraftGroup): number {
|
||||
return group.rows.filter((row, index) => judgeRow(group, row, index).state === 'missing').length
|
||||
}
|
||||
|
||||
/** 本次发布真正会带上屏的行数。 */
|
||||
function publishableCount(group: DraftGroup): number {
|
||||
return group.rows.length - skippedCount(group)
|
||||
}
|
||||
|
||||
function sportNameOf(event: Event): string {
|
||||
return data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'
|
||||
}
|
||||
@@ -173,15 +196,16 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
const drafts = scoped.filter((entry) => entry.result?.state === 'draft')
|
||||
if (drafts.length === 0) return null
|
||||
|
||||
const scorable: ScorableRow[] = scoped.map((entry) => ({
|
||||
entryId: entry.id,
|
||||
teamId: entry.teamId,
|
||||
athleteId: entry.athleteId,
|
||||
resultValue: entry.result?.resultValue ?? null,
|
||||
rank: entry.result?.rank ?? null,
|
||||
status: entry.result?.status ?? 'ok',
|
||||
isTie: entry.result?.isTie ?? false,
|
||||
}))
|
||||
const scorableOf = (list: EntryWithResult[]): ScorableRow[] =>
|
||||
list.map((entry) => ({
|
||||
entryId: entry.id,
|
||||
teamId: entry.teamId,
|
||||
athleteId: entry.athleteId,
|
||||
resultValue: entry.result?.resultValue ?? null,
|
||||
rank: entry.result?.rank ?? null,
|
||||
status: entry.result?.status ?? 'ok',
|
||||
isTie: entry.result?.isTie ?? false,
|
||||
}))
|
||||
const context: ScoreContext = {
|
||||
event,
|
||||
category,
|
||||
@@ -189,7 +213,15 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
profiles: data.profiles,
|
||||
weight: weight.value,
|
||||
}
|
||||
const score = computeEventScore(scorable, context)
|
||||
// 全量(含已发布)口径 = 本次发布后服务端会算出的结果。
|
||||
const score = computeEventScore(scorableOf(scoped), context)
|
||||
// 只含已发布行的一次独立算分 = 该组此刻在榜上的结果。
|
||||
// 分批发布时不能拿全量口径里已发布行的分去扣:新一批会挤掉老一批的名次,
|
||||
// 扣的分必须来自「只有已发布行」的那次算分,差值才是本次发布的真实影响。
|
||||
const publishedScore = computeEventScore(
|
||||
scorableOf(scoped.filter((entry) => entry.result?.state === 'published')),
|
||||
context,
|
||||
)
|
||||
|
||||
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
|
||||
// rowTeamPoints 已按通道 scope 折算好个人→单位系数,与服务端流水口径一致。
|
||||
@@ -198,7 +230,7 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
if (entry.result?.state !== 'published') continue
|
||||
published.set(
|
||||
entry.teamId,
|
||||
(published.get(entry.teamId) ?? 0) + round(score.rowTeamPoints.get(entry.id) ?? 0),
|
||||
(published.get(entry.teamId) ?? 0) + round(publishedScore.rowTeamPoints.get(entry.id) ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,6 +345,10 @@ function requestPublish(group: DraftGroup): void {
|
||||
toast.error(`有 ${blockers.length} 处校验未通过`, blockers[0] ?? null)
|
||||
return
|
||||
}
|
||||
if (publishableCount(group) === 0) {
|
||||
toast.error('没有可发布的成绩', '这组全是未录成绩的行,补录至少一条后再发布')
|
||||
return
|
||||
}
|
||||
publishTarget.value = group
|
||||
}
|
||||
|
||||
@@ -513,6 +549,7 @@ watch(
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center">录入</th>
|
||||
<th>单位</th>
|
||||
<th>运动员 / 队</th>
|
||||
<th>成绩</th>
|
||||
@@ -524,11 +561,29 @@ watch(
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in selectedGroup.rows"
|
||||
v-for="(row, index) in selectedGroup.rows"
|
||||
:key="row.entryId"
|
||||
:class="{ selected: row.entryId === selectedEntryId }"
|
||||
@click="selectedEntryId = row.entryId"
|
||||
>
|
||||
<td class="center">
|
||||
<span
|
||||
class="mx-badge"
|
||||
:class="{
|
||||
'mx-badge-error': judgeRow(selectedGroup, row, index).state === 'error',
|
||||
'mx-badge-warning': judgeRow(selectedGroup, row, index).state === 'missing',
|
||||
'mx-badge-success': judgeRow(selectedGroup, row, index).state === 'filled',
|
||||
}"
|
||||
>
|
||||
{{
|
||||
judgeRow(selectedGroup, row, index).state === 'error'
|
||||
? '有错'
|
||||
: judgeRow(selectedGroup, row, index).state === 'missing'
|
||||
? '未录'
|
||||
: '已填'
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
</td>
|
||||
@@ -581,7 +636,9 @@ watch(
|
||||
:message="
|
||||
publishTarget === null
|
||||
? ''
|
||||
: `本次将影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||||
: skippedCount(publishTarget) > 0
|
||||
? `本组还有 ${skippedCount(publishTarget)} 条没有成绩,本次将发布已填的 ${publishableCount(publishTarget)} 条;其余保持未发布,补录后再发布即可。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||||
: `本次将发布该组全部 ${publishableCount(publishTarget)} 条。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||||
"
|
||||
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
||||
confirm-text="确认发布"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseResultValue, parseSecondsDetail } from '@/shared'
|
||||
import { formatPoints, parseResultValue, parseSecondsDetail } from '@/shared'
|
||||
|
||||
/**
|
||||
* 成绩录入 / 发布前的防呆守卫(纯函数)。
|
||||
@@ -13,6 +13,13 @@ export type ResultCellIssueKind = 'empty' | 'seconds-overflow' | 'unrecognized'
|
||||
|
||||
export interface ResultCellIssue {
|
||||
kind: ResultCellIssueKind
|
||||
/**
|
||||
* 是否必须修好才能发布。
|
||||
*
|
||||
* 空成绩只是「还没跑 / 还没录」,在分批比赛的现场属于中性状态,绝不能挡住发布;
|
||||
* 写错(秒位越界、写法不认识)会导致算错,必须挡住。
|
||||
*/
|
||||
blocking: boolean
|
||||
/** 可直接展示的报错文案。 */
|
||||
message: string
|
||||
}
|
||||
@@ -33,29 +40,137 @@ function formatHint(unit: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个成绩格是否阻止发布;通过时返回 null。
|
||||
* 成绩格报错的核心文案(不含行标签)。
|
||||
*
|
||||
* 录入页的实时提示与发布前防呆必须共用这里:两处各写一份,迟早会出现
|
||||
* 「输入时说没问题、发布时被拦下」这种自相矛盾的提示。
|
||||
*/
|
||||
function cellErrorMessage(text: string, unit: string): string {
|
||||
if (unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow') {
|
||||
return `秒位不能 ≥ 60(当前「${cellText(text)}」),请写成 1:06 或 66 这类`
|
||||
}
|
||||
return `成绩写法无法识别(当前「${cellText(text)}」),${formatHint(unit)}`
|
||||
}
|
||||
|
||||
// ── 聚焦成绩格的实时提示(三档:✅解析结果 / ⚠️可疑 / 🚫不合法) ──────────
|
||||
|
||||
export type ResultCellHintLevel = 'ok' | 'warn' | 'error'
|
||||
|
||||
export interface ResultCellHint {
|
||||
level: ResultCellHintLevel
|
||||
/** 可直接展示的提示文案。 */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 合法成绩的展示单位;未知单位不编造单位名。 */
|
||||
const UNIT_SUFFIX: Record<string, string> = { s: '秒', m: '米', 次: '次', pts: '分' }
|
||||
|
||||
/** 合法成绩的解析结果:让录入员确认「系统理解的和我想的是同一个数」。 */
|
||||
function parsedValueMessage(value: number, unit: string): string {
|
||||
const suffix = UNIT_SUFFIX[unit] ?? ''
|
||||
return suffix === '' ? `= ${formatPoints(value)}` : `= ${formatPoints(value)} ${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦某一格时应显示什么、什么级别;空格子返回 null(空格子是正常状态,不该弹东西)。
|
||||
*
|
||||
* 判定与 resultCellIssue / judgeEntryRow 完全同源:写错 = error(发布会被挡),
|
||||
* 成绩 ≤ 0 = warn(放行但提个醒),能解析 = ok(把解析结果告诉录入员)。
|
||||
*/
|
||||
export function resultCellHint(text: string, unit: string): ResultCellHint | null {
|
||||
if (text.trim() === '') return null
|
||||
const value = parseResultValue(text, unit)
|
||||
if (value === null) return { level: 'error', message: cellErrorMessage(text, unit) }
|
||||
if (value <= 0) return { level: 'warn', message: '成绩 ≤ 0,疑似录入错误' }
|
||||
return { level: 'ok', message: parsedValueMessage(value, unit) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个成绩格的问题等级;没有问题时返回 null。
|
||||
*
|
||||
* 三种失败原因的文案必须互不混淆,且都带上那一格的实际内容:
|
||||
* 「空的」和「填错格子」看起来一样,只写「缺少成绩」会把人引去改本来就对的格式。
|
||||
*
|
||||
* 只有「写错」才是 blocking;空格子是「还没录」,属于提示,不阻塞发布——
|
||||
* 现场分批比赛时(跑道 8 条、人数 30),要能先把跑完的这一批发出去。
|
||||
*/
|
||||
export function resultCellIssue(label: string, text: string, unit: string): ResultCellIssue | null {
|
||||
if (text.trim() === '') {
|
||||
return { kind: 'empty', message: `${label}:成绩为空(当前「${cellText(text)}」),请填写成绩` }
|
||||
return {
|
||||
kind: 'empty',
|
||||
blocking: false,
|
||||
message: `${label}:还没录成绩(当前「${cellText(text)}」),本次发布不计入这一行`,
|
||||
}
|
||||
}
|
||||
if (parseResultValue(text, unit) !== null) return null
|
||||
|
||||
if (unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow') {
|
||||
return {
|
||||
kind: 'seconds-overflow',
|
||||
message: `${label}:秒位不能 ≥ 60(当前「${cellText(text)}」),请写成 1:06 或 66 这类`,
|
||||
}
|
||||
}
|
||||
const overflow = unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow'
|
||||
return {
|
||||
kind: 'unrecognized',
|
||||
message: `${label}:成绩写法无法识别(当前「${cellText(text)}」),${formatHint(unit)}`,
|
||||
kind: overflow ? 'seconds-overflow' : 'unrecognized',
|
||||
blocking: true,
|
||||
message: `${label}:${cellErrorMessage(text, unit)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单行录入状态(✅已填 / ⚠️未录 / 🚫有错) ────────────────────────────────
|
||||
|
||||
/** 一行报名项在录入页上的状态:已填 / 未录(中性,不阻塞发布)/ 有错(阻塞发布)。 */
|
||||
export type EntryRowState = 'filled' | 'missing' | 'error'
|
||||
|
||||
export interface EntryRowJudgement {
|
||||
state: EntryRowState
|
||||
/** 阻止发布的原因;只有 state=error 时非空。 */
|
||||
blocker: string | null
|
||||
/** 只提示、不阻塞的原因(未录、成绩 ≤ 0 之类的疑似错误)。 */
|
||||
warning: string | null
|
||||
}
|
||||
|
||||
export interface EntryRowInput {
|
||||
/** 行标签,如「第 3 行 张三」。 */
|
||||
label: string
|
||||
resultText: string
|
||||
unit: string
|
||||
/** 成绩状态(ok / dns / dq / dnf / withdrawn)。 */
|
||||
status: string
|
||||
rank: number | null
|
||||
/** 该项目是否以手工名次为成绩(manual / compare=none)。 */
|
||||
needsRank: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一行是否可发布,并给出徽标状态。
|
||||
*
|
||||
* 三类互斥:
|
||||
* - 🚫 有错:写法无法识别、秒位越界、手动项目缺名次 —— 会算错,必须挡住;
|
||||
* - ⚠️ 未录:成绩为空(手动项目已填名次的除外)—— 还没跑完,跳过即可;
|
||||
* - ✅ 已填:其余(含 DNS / DQ / DNF / 退赛 这类没有数值但确实录了的现场结果)。
|
||||
*/
|
||||
export function judgeEntryRow(input: EntryRowInput): EntryRowJudgement {
|
||||
const text = input.resultText.trim()
|
||||
const filled = (warning: string | null): EntryRowJudgement => ({ state: 'filled', blocker: null, warning })
|
||||
|
||||
// 非 ok 状态是操作员明确录入的比赛结果,即使没有成绩数值也算「已填」。
|
||||
if (input.status !== 'ok') return filled(null)
|
||||
|
||||
// 手动名次项目:名次本身就是成绩,缺名次才是错误;只填了名次、没填文本是允许的。
|
||||
if (input.needsRank && input.rank === null) {
|
||||
return { state: 'error', blocker: `${input.label}:缺少名次`, warning: null }
|
||||
}
|
||||
if (text === '') {
|
||||
if (input.needsRank) return filled(null)
|
||||
const empty = resultCellIssue(input.label, input.resultText, input.unit)
|
||||
return { state: 'missing', blocker: null, warning: empty?.message ?? null }
|
||||
}
|
||||
|
||||
const cellIssue = resultCellIssue(input.label, input.resultText, input.unit)
|
||||
if (cellIssue !== null && cellIssue.blocking) {
|
||||
return { state: 'error', blocker: cellIssue.message, warning: null }
|
||||
}
|
||||
const value = parseResultValue(input.resultText, input.unit)
|
||||
if (value !== null && value <= 0) return filled(`${input.label}:成绩 ≤ 0,疑似录入错误`)
|
||||
return filled(null)
|
||||
}
|
||||
|
||||
// ── 未保存修改的离开守卫 ────────────────────────────────────────────────────
|
||||
|
||||
export const DISCARD_CONFIRM_TITLE = '有未保存的成绩修改'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 批次(heat)相关的纯函数:下拉选项推导、按批次筛选、按人数切片分配批次号。
|
||||
*
|
||||
* 抽出来的原因:`.vue` 组件跑不进 `node --test`,而「一键均分」的两条现场规则
|
||||
* ——已分批的行一个都不动、新批次号接着最大号往后排——最容易写错也最难在界面上核对。
|
||||
*
|
||||
* 批次只是录入 / 展示分组,这里算出来的任何东西都不参与名次与积分。
|
||||
*/
|
||||
|
||||
/** 批次筛选值:'all' = 全部,'none' = 未分批,正整数 = 第 N 批。 */
|
||||
export type HeatFilter = number | 'all' | 'none'
|
||||
|
||||
/** 批次号上限与服务端 maxEntryHeat 保持一致,超出会被接口拒绝。 */
|
||||
export const MAX_HEAT = 999
|
||||
|
||||
/** 一键均分的默认每批人数:一条跑道 8 道,正好一批跑完。 */
|
||||
export const DEFAULT_HEAT_SIZE = 8
|
||||
|
||||
export interface HeatOption {
|
||||
value: HeatFilter
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 只要求带批次号,便于表格行与测试夹具复用。 */
|
||||
export interface HeatRow {
|
||||
heat: number | null
|
||||
}
|
||||
|
||||
export interface HeatEntryRow extends HeatRow {
|
||||
entryId: number
|
||||
}
|
||||
|
||||
/** 批次展示文案:null = 未分批。 */
|
||||
export function heatLabel(heat: number | null): string {
|
||||
return heat === null ? '未分批' : `第 ${heat} 批`
|
||||
}
|
||||
|
||||
/**
|
||||
* 批次下拉选项:全部 → 当前组别已有的批次(去重升序)→ 未分批。
|
||||
*
|
||||
* 「未分批」只在真的还有未分批的行时才给,否则选中它只会得到一张空表。
|
||||
*/
|
||||
export function heatOptions(rows: readonly HeatRow[]): HeatOption[] {
|
||||
const options: HeatOption[] = [{ value: 'all', label: '全部' }]
|
||||
const heats = [...new Set(rows.map((row) => row.heat).filter((heat): heat is number => heat !== null))].sort(
|
||||
(a, b) => a - b,
|
||||
)
|
||||
for (const heat of heats) options.push({ value: heat, label: heatLabel(heat) })
|
||||
if (rows.some((row) => row.heat === null)) options.push({ value: 'none', label: heatLabel(null) })
|
||||
return options
|
||||
}
|
||||
|
||||
/** 按批次筛选表格行;顺序保持传入顺序(即表格显示顺序)。 */
|
||||
export function filterRowsByHeat<T extends HeatRow>(rows: readonly T[], filter: HeatFilter): T[] {
|
||||
if (filter === 'all') return [...rows]
|
||||
if (filter === 'none') return rows.filter((row) => row.heat === null)
|
||||
return rows.filter((row) => row.heat === filter)
|
||||
}
|
||||
|
||||
/** 当前最大的已有批次号;一个批次都没有时返回 0,新批次就从 1 开始。 */
|
||||
export function maxHeat(rows: readonly HeatRow[]): number {
|
||||
return rows.reduce((max, row) => (row.heat !== null && row.heat > max ? row.heat : max), 0)
|
||||
}
|
||||
|
||||
/** 一批要下发的批次号与报名项;heat 为 null 表示清除为「未分批」。 */
|
||||
export interface HeatAllocation {
|
||||
heat: number | null
|
||||
entryIds: number[]
|
||||
}
|
||||
|
||||
/** 「一键均分」的编排结果,同时供确认框展示与提交使用。 */
|
||||
export interface HeatSplitPlan {
|
||||
/** 参与编排的未分批行数。 */
|
||||
rowCount: number
|
||||
/** 每批人数。 */
|
||||
perHeat: number
|
||||
batchCount: number
|
||||
/** 第一个新批次号(已有最大号 + 1)。 */
|
||||
startHeat: number
|
||||
allocations: HeatAllocation[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键均分:按传入顺序(= 当前表格显示顺序)把未分批的行每 perHeat 人切一批,
|
||||
* 批次号接着已有最大批次号往后排。已经分批的行不参与,也因此不会被冲掉。
|
||||
*
|
||||
* perHeat 不是正整数、或没有未分批的行时返回 null,调用方据此提示并停用按钮。
|
||||
*/
|
||||
export function planHeatSplit(rows: readonly HeatEntryRow[], perHeat: number): HeatSplitPlan | null {
|
||||
if (!Number.isInteger(perHeat) || perHeat < 1) return null
|
||||
const unbatched = rows.filter((row) => row.heat === null)
|
||||
if (unbatched.length === 0) return null
|
||||
|
||||
const startHeat = maxHeat(rows) + 1
|
||||
const allocations: HeatAllocation[] = []
|
||||
for (let i = 0; i < unbatched.length; i += perHeat) {
|
||||
allocations.push({
|
||||
heat: startHeat + allocations.length,
|
||||
entryIds: unbatched.slice(i, i + perHeat).map((row) => row.entryId),
|
||||
})
|
||||
}
|
||||
return {
|
||||
rowCount: unbatched.length,
|
||||
perHeat,
|
||||
batchCount: allocations.length,
|
||||
startHeat,
|
||||
allocations,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置成功后同步本地行:只改命中的 entryId,其余行(含正在编辑的成绩)原样保留。
|
||||
*
|
||||
* 录入页不能因为改批次就整表重载——那样会把还没保存的成绩草稿一起丢掉。
|
||||
*/
|
||||
export function applyHeatToRows<T extends HeatEntryRow>(
|
||||
rows: readonly T[],
|
||||
entryIds: readonly number[],
|
||||
heat: number | null,
|
||||
): T[] {
|
||||
const target = new Set(entryIds)
|
||||
return rows.map((row) => (target.has(row.entryId) ? { ...row, heat } : row))
|
||||
}
|
||||
@@ -66,6 +66,12 @@ body {
|
||||
transition:
|
||||
border-color var(--mx-d1) var(--mx-ease),
|
||||
box-shadow var(--mx-d1) var(--mx-ease);
|
||||
/*
|
||||
* 表格是 table-layout:auto,width:100% 对列的 min-content 没有贡献;
|
||||
* 窄屏下这些格子会被压到二十几像素,连一个字都看不到。
|
||||
* 给一个下限,让宽表撑出横向滚动而不是把输入框压扁。
|
||||
*/
|
||||
min-width: 80px;
|
||||
}
|
||||
.mx-cell-input:focus {
|
||||
border-color: var(--mx-primary);
|
||||
@@ -96,3 +102,79 @@ body {
|
||||
.mx-table-entry tbody tr.is-focus {
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
|
||||
/*
|
||||
* 窄屏表格:回退成「表头 + 横向滚动」,不用 vendor 的卡片化。
|
||||
*
|
||||
* vendor 的 @media(max-width:767px) 卡片模式要求每个 td 带 data-label(::before 靠它
|
||||
* 显示字段名)才成立;管理端所有表格都没有这个属性,套上去的结果是表头消失、
|
||||
* 每格只留一个无名值,而且同一段规则还把 .mx-table-wrap 的 overflow-x 改成了 visible,
|
||||
* 宽表(运动员、日志、录入)会直接顶破卡片和整页。现场手机端更需要"能横向滑着看",
|
||||
* 所以这里在管理端范围内把表格恢复成常规表格并保留横向滚动。
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.admin-shell .mx-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.admin-shell .mx-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
.admin-shell .mx-table tbody {
|
||||
display: table-row-group;
|
||||
}
|
||||
.admin-shell .mx-table tbody tr {
|
||||
display: table-row;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.admin-shell .mx-table tbody td {
|
||||
display: table-cell;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--mx-divider);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-shell .mx-table tbody td::before {
|
||||
content: none;
|
||||
}
|
||||
.admin-shell .mx-table tbody tr:hover td {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* 手机端表格内输入框触控目标补齐到 ~40px(vendor 的 ≤767 规则停在 34px)。 */
|
||||
.admin-shell .mx-cell-input {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 卡片头在窄屏允许换行:像「破纪录」页把标题与一排筛选页签放同一行时,
|
||||
* 页签作为 flex 子项的 min-width:auto 会按 min-content 撑开(320px 尤其明显),
|
||||
* 把卡片头顶出屏幕。允许换行 + 让页签容器可以收缩,标题与筛选都还能用。
|
||||
*/
|
||||
.admin-shell .mx-card-head {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-shell .mx-card-head .mx-tabs {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 分页按钮 / 弹窗关闭按钮:vendor 在窄屏把分页压到 32px、关闭按钮 28px,
|
||||
* 现场用手机或平板点这些很容易点空。1024 以下统一补到 40px。
|
||||
*/
|
||||
@media (max-width: 1023px) {
|
||||
.admin-shell .mx-page-btn,
|
||||
.admin-shell .mx-page-ellipsis {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.admin-shell .mx-modal-close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -133,3 +133,42 @@ test('后端连不上时实例列表给空数组,不抛错', async () => {
|
||||
|
||||
assert.deepEqual(await adminApi.displayInstances(), [])
|
||||
})
|
||||
|
||||
// 「实时赛况」当前项目由服务端判定,前端只消费结论:source / eventIds / events 原样透传。
|
||||
test('实时赛况接口透传判定结果与入选原因', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
source: 'auto',
|
||||
eventIds: [7],
|
||||
events: [
|
||||
{
|
||||
event: { id: 7, name: '100米跑' },
|
||||
reason: 'recent',
|
||||
groups: [{ eventId: 7, categoryId: 1, categoryName: '男子', rows: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const live = await adminApi.displayLive()
|
||||
|
||||
assert.equal(lastRequest.url, '/api/display/live')
|
||||
assert.equal(lastRequest.method, 'GET')
|
||||
assert.equal(live.source, 'auto')
|
||||
assert.deepEqual(live.eventIds, [7])
|
||||
assert.equal(live.events[0]?.event.name, '100米跑')
|
||||
assert.equal(live.events[0]?.reason, 'recent')
|
||||
assert.equal(live.events[0]?.groups.length, 1)
|
||||
})
|
||||
|
||||
// 判定依赖排期与最近发布,示例数据里没有这套时序,连不上时给空结论而不是抛错。
|
||||
test('后端连不上时实时赛况给空结论', async () => {
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
assert.deepEqual(await adminApi.displayLive(), { source: 'auto', eventIds: [], events: [] })
|
||||
})
|
||||
@@ -1,13 +1,20 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
|
||||
import { createLeaveGuard, judgeEntryRow, resultCellIssue } from '../pages/entryGuards'
|
||||
|
||||
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
|
||||
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
|
||||
test('空格子:文案说明成绩为空并带出「(空)」', () => {
|
||||
// 现场分批比赛后,空格子只是「还没录」,归为提示、不阻塞发布。
|
||||
test('空格子:文案说明还没录并带出「(空)」,且不阻塞发布', () => {
|
||||
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
|
||||
assert.equal(issue?.kind, 'empty')
|
||||
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
|
||||
assert.equal(issue?.blocking, false)
|
||||
assert.equal(issue?.message, '第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行')
|
||||
})
|
||||
|
||||
test('写错的成绩阻塞发布:秒位越界与写法不认识都是 blocking', () => {
|
||||
assert.equal(resultCellIssue('第 3 行 张三', '1:70', 's')?.blocking, true)
|
||||
assert.equal(resultCellIssue('第 3 行 张三', '约1分06秒', 's')?.blocking, true)
|
||||
})
|
||||
|
||||
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
|
||||
@@ -53,6 +60,61 @@ test('距离 / 计数项目的文案不套用时间格式提示', () => {
|
||||
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
|
||||
})
|
||||
|
||||
// 三类行徽标:已填 / 未录 / 有错。空成绩不再算错误,但要有提示。
|
||||
test('行状态三类互斥:空成绩=未录(提示),写错=有错(阻塞)', () => {
|
||||
const missing = judgeEntryRow({
|
||||
label: '第 22 行 赵六', resultText: ' ', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(missing.state, 'missing')
|
||||
assert.equal(missing.blocker, null)
|
||||
assert.ok(missing.warning?.includes('还没录成绩'), missing.warning ?? '')
|
||||
|
||||
const error = judgeEntryRow({
|
||||
label: '第 3 行 张三', resultText: '1:70', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(error.state, 'error')
|
||||
assert.ok(error.blocker?.includes('秒位不能 ≥ 60'), error.blocker ?? '')
|
||||
|
||||
const filled = judgeEntryRow({
|
||||
label: '第 1 行 李四', resultText: '66.33', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(filled.state, 'filled')
|
||||
assert.equal(filled.blocker, null)
|
||||
})
|
||||
|
||||
test('非 ok 状态(DNS/DQ/DNF/退赛)没有数值也算已填', () => {
|
||||
for (const status of ['dns', 'dq', 'dnf', 'withdrawn']) {
|
||||
const row = judgeEntryRow({
|
||||
label: '第 2 行 王五', resultText: '', unit: 's', status, rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(row.state, 'filled', status)
|
||||
assert.equal(row.warning, null)
|
||||
}
|
||||
})
|
||||
|
||||
test('手动名次项目:只填名次即可发布,缺名次才阻塞', () => {
|
||||
const rankOnly = judgeEntryRow({
|
||||
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: 1, needsRank: true,
|
||||
})
|
||||
assert.equal(rankOnly.state, 'filled')
|
||||
assert.equal(rankOnly.blocker, null)
|
||||
|
||||
const noRank = judgeEntryRow({
|
||||
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: null, needsRank: true,
|
||||
})
|
||||
assert.equal(noRank.state, 'error')
|
||||
assert.ok(noRank.blocker?.includes('缺少名次'), noRank.blocker ?? '')
|
||||
})
|
||||
|
||||
test('成绩 ≤ 0 只提示不挡:状态仍是已填', () => {
|
||||
const row = judgeEntryRow({
|
||||
label: '第 5 行 田径', resultText: '0', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(row.state, 'filled')
|
||||
assert.equal(row.blocker, null)
|
||||
assert.ok(row.warning?.includes('≤ 0'), row.warning ?? '')
|
||||
})
|
||||
|
||||
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
|
||||
test('未保存时离开被拦下,确认后重放放行', () => {
|
||||
let dirty = true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createApiClient } from '@/shared'
|
||||
|
||||
/** 真打一遍客户端的请求构造:路径、方法、请求体形状必须与服务端契约一致。 */
|
||||
test('setHeat 走 PUT /entries/heat 并带上 entryIds + heat', async () => {
|
||||
const captured: { url?: string; method?: string; body?: unknown } = {}
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
requestId: () => 'req-fixed',
|
||||
fetchImpl: async (input, init) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method
|
||||
captured.body = init?.body
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, data: { updated: 3, heat: 2, entryIds: [4, 5, 6] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.entries.setHeat({ entryIds: [4, 5, 6], heat: 2 })
|
||||
|
||||
assert.equal(captured.url, '/api/entries/heat')
|
||||
assert.equal(captured.method, 'PUT')
|
||||
// 写操作由客户端补 requestId(幂等重放),其余字段原样。
|
||||
assert.equal(captured.body, JSON.stringify({ entryIds: [4, 5, 6], heat: 2, requestId: 'req-fixed' }))
|
||||
assert.deepEqual(result, { updated: 3, heat: 2, entryIds: [4, 5, 6] })
|
||||
})
|
||||
|
||||
test('setHeat 传 null 表示清除批次', async () => {
|
||||
const captured: { body?: unknown } = {}
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
requestId: () => 'req-fixed',
|
||||
fetchImpl: async (_input, init) => {
|
||||
captured.body = init?.body
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, data: { updated: 2, heat: null, entryIds: [1, 2] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.entries.setHeat({ entryIds: [1, 2], heat: null })
|
||||
|
||||
assert.equal(captured.body, JSON.stringify({ entryIds: [1, 2], heat: null, requestId: 'req-fixed' }))
|
||||
assert.equal(result.heat, null)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
applyHeatToRows,
|
||||
filterRowsByHeat,
|
||||
heatLabel,
|
||||
heatOptions,
|
||||
maxHeat,
|
||||
planHeatSplit,
|
||||
type HeatEntryRow,
|
||||
} from '../pages/entryHeat'
|
||||
|
||||
type Row = HeatEntryRow & { name: string }
|
||||
|
||||
function rows(spec: Array<[string, number | null]>): Row[] {
|
||||
return spec.map(([name, heat], index) => ({ entryId: index + 1, name, heat }))
|
||||
}
|
||||
|
||||
test('heatLabel:未分批与第 N 批', () => {
|
||||
assert.equal(heatLabel(null), '未分批')
|
||||
assert.equal(heatLabel(1), '第 1 批')
|
||||
assert.equal(heatLabel(12), '第 12 批')
|
||||
})
|
||||
|
||||
test('heatOptions:全部 → 已有批次去重升序 → 未分批', () => {
|
||||
const options = heatOptions(rows([['甲', 2], ['乙', null], ['丙', 1], ['丁', 2]]))
|
||||
assert.deepEqual(options, [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 1, label: '第 1 批' },
|
||||
{ value: 2, label: '第 2 批' },
|
||||
{ value: 'none', label: '未分批' },
|
||||
])
|
||||
})
|
||||
|
||||
test('heatOptions:没有未分批的行时不给「未分批」选项,避免选中得到空表', () => {
|
||||
assert.deepEqual(heatOptions(rows([['甲', 1], ['乙', 3]])), [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 1, label: '第 1 批' },
|
||||
{ value: 3, label: '第 3 批' },
|
||||
])
|
||||
})
|
||||
|
||||
test('heatOptions:一条报名都没有时只有「全部」', () => {
|
||||
assert.deepEqual(heatOptions([]), [{ value: 'all', label: '全部' }])
|
||||
})
|
||||
|
||||
test('filterRowsByHeat:全部 / 指定批次 / 未分批,顺序不变', () => {
|
||||
const list = rows([['甲', 1], ['乙', null], ['丙', 2], ['丁', 1]])
|
||||
assert.deepEqual(
|
||||
filterRowsByHeat(list, 'all').map((row) => row.name),
|
||||
['甲', '乙', '丙', '丁'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
filterRowsByHeat(list, 1).map((row) => row.name),
|
||||
['甲', '丁'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
filterRowsByHeat(list, 'none').map((row) => row.name),
|
||||
['乙'],
|
||||
)
|
||||
assert.deepEqual(filterRowsByHeat(list, 9), [])
|
||||
// 返回新数组,不改动入参。
|
||||
assert.equal(list.length, 4)
|
||||
})
|
||||
|
||||
test('maxHeat:取已有最大批次号,没有批次时是 0', () => {
|
||||
assert.equal(maxHeat([]), 0)
|
||||
assert.equal(maxHeat(rows([['甲', null], ['乙', null]])), 0)
|
||||
assert.equal(maxHeat(rows([['甲', 2], ['乙', null], ['丙', 5]])), 5)
|
||||
})
|
||||
|
||||
test('planHeatSplit:未分批的行按每批人数顺序切片,批次号从 1 开始', () => {
|
||||
const list = rows([
|
||||
['甲', null], ['乙', null], ['丙', null], ['丁', null], ['戊', null],
|
||||
['己', null], ['庚', null], ['辛', null], ['壬', null], ['癸', null],
|
||||
])
|
||||
const plan = planHeatSplit(list, 8)
|
||||
assert.ok(plan !== null)
|
||||
assert.equal(plan.rowCount, 10)
|
||||
assert.equal(plan.batchCount, 2)
|
||||
assert.equal(plan.startHeat, 1)
|
||||
assert.equal(plan.perHeat, 8)
|
||||
assert.deepEqual(plan.allocations, [
|
||||
{ heat: 1, entryIds: [1, 2, 3, 4, 5, 6, 7, 8] },
|
||||
{ heat: 2, entryIds: [9, 10] },
|
||||
])
|
||||
})
|
||||
|
||||
test('planHeatSplit:已经分批的行不动,新批次号接着最大号往后排', () => {
|
||||
const list = rows([
|
||||
['甲', 3], ['乙', null], ['丙', null], ['丁', 1], ['戊', null],
|
||||
])
|
||||
const plan = planHeatSplit(list, 2)
|
||||
assert.ok(plan !== null)
|
||||
assert.equal(plan.startHeat, 4, '接着最大批次号 3 往后排,而不是从 1 覆盖')
|
||||
assert.deepEqual(plan.allocations, [
|
||||
{ heat: 4, entryIds: [2, 3] },
|
||||
{ heat: 5, entryIds: [5] },
|
||||
])
|
||||
// 已分批的甲 / 丁不在任何一批里。
|
||||
const touched = plan.allocations.flatMap((item) => item.entryIds)
|
||||
assert.equal(touched.includes(1), false)
|
||||
assert.equal(touched.includes(4), false)
|
||||
})
|
||||
|
||||
test('planHeatSplit:每批人数大于剩余人数时只出一批', () => {
|
||||
const plan = planHeatSplit(rows([['甲', null], ['乙', null], ['丙', null]]), 8)
|
||||
assert.deepEqual(plan?.allocations, [{ heat: 1, entryIds: [1, 2, 3] }])
|
||||
})
|
||||
|
||||
test('planHeatSplit:没有未分批的行、或每批人数非法时返回 null', () => {
|
||||
assert.equal(planHeatSplit(rows([['甲', 1]]), 8), null, '全都分过批就不该有编排')
|
||||
assert.equal(planHeatSplit(rows([['甲', null]]), 0), null)
|
||||
assert.equal(planHeatSplit(rows([['甲', null]]), -3), null)
|
||||
assert.equal(planHeatSplit(rows([['甲', null]]), 2.5), null)
|
||||
})
|
||||
|
||||
test('applyHeatToRows:只改命中的行,其余行(含正在编辑的成绩)原样保留', () => {
|
||||
const list = rows([['甲', null], ['乙', 1], ['丙', null]])
|
||||
const next = applyHeatToRows(list, [1, 3], 7)
|
||||
assert.deepEqual(next.map((row) => row.heat), [7, 1, 7])
|
||||
assert.deepEqual(list.map((row) => row.heat), [null, 1, null], '不能改到入参')
|
||||
|
||||
const cleared = applyHeatToRows(next, [1], null)
|
||||
assert.deepEqual(cleared.map((row) => row.heat), [null, 1, 7])
|
||||
// 未命中的批次筛选值不受影响。
|
||||
assert.deepEqual(applyHeatToRows(list, [], 5).map((row) => row.heat), [null, 1, null])
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resultCellHint, resultCellIssue } from '../pages/entryGuards'
|
||||
|
||||
// 聚焦成绩格的实时提示:三档(✅解析结果 / ⚠️可疑 / 🚫不合法)+ 空格子不弹。
|
||||
// 文案与 resultCellIssue 同源,避免「输入时说没问题、发布时被拦下」这种自相矛盾的提示。
|
||||
|
||||
test('合法时间:把解析结果显示出来(1:06 → = 66 秒)', () => {
|
||||
const hint = resultCellHint('1:06', 's')
|
||||
assert.equal(hint?.level, 'ok')
|
||||
assert.equal(hint?.message, '= 66 秒')
|
||||
})
|
||||
|
||||
test('合法小数与全角写法也按秒数显示', () => {
|
||||
assert.equal(resultCellHint('66.33', 's')?.message, '= 66.33 秒')
|
||||
assert.equal(resultCellHint('1:06.33', 's')?.message, '= 66.33 秒')
|
||||
assert.equal(resultCellHint('1:06', 's')?.message, '= 66 秒')
|
||||
})
|
||||
|
||||
test('非时间单位:显示对应数值与单位', () => {
|
||||
assert.equal(resultCellHint('12.34', 'm')?.message, '= 12.34 米')
|
||||
assert.equal(resultCellHint('12', '次')?.message, '= 12 次')
|
||||
assert.equal(resultCellHint('96.5', 'pts')?.message, '= 96.5 分')
|
||||
})
|
||||
|
||||
test('空成绩不弹提示', () => {
|
||||
assert.equal(resultCellHint('', 's'), null)
|
||||
assert.equal(resultCellHint(' ', 's'), null)
|
||||
})
|
||||
|
||||
test('成绩 ≤ 0:可疑(warn),不阻塞但提醒疑似错误', () => {
|
||||
const hint = resultCellHint('0', 's')
|
||||
assert.equal(hint?.level, 'warn')
|
||||
assert.equal(hint?.message, '成绩 ≤ 0,疑似录入错误')
|
||||
})
|
||||
|
||||
test('秒位 ≥ 60:不合法(error),带出那一格的原文', () => {
|
||||
const hint = resultCellHint('1:70', 's')
|
||||
assert.equal(hint?.level, 'error')
|
||||
assert.ok(hint?.message.includes('秒位不能 ≥ 60'), hint?.message ?? '')
|
||||
assert.ok(hint?.message.includes('「1:70」'), hint?.message ?? '')
|
||||
// 边界:恰好 60 也算越界(60 秒要写成 1:00)。
|
||||
assert.equal(resultCellHint('1:60', 's')?.level, 'error')
|
||||
})
|
||||
|
||||
test('写法不认识:不合法(error),带出原文与可填写法', () => {
|
||||
const hint = resultCellHint('约1分06秒', 's')
|
||||
assert.equal(hint?.level, 'error')
|
||||
assert.ok(hint?.message.includes('成绩写法无法识别'), hint?.message ?? '')
|
||||
assert.ok(hint?.message.includes('「约1分06秒」'), hint?.message ?? '')
|
||||
})
|
||||
|
||||
test('非时间单位不套用时间格式提示', () => {
|
||||
const hint = resultCellHint('1.2.3', 'm')
|
||||
assert.equal(hint?.level, 'error')
|
||||
assert.ok(hint?.message.includes('米'), hint?.message ?? '')
|
||||
assert.ok(!hint?.message.includes('1:06'), '不该拿时间格式去教距离项目')
|
||||
})
|
||||
|
||||
test('实时提示与发布前防呆同源:错误文案去掉行标签后完全一致', () => {
|
||||
for (const text of ['1:70', '约1分06秒', '1.2.3', '快点跑']) {
|
||||
const hint = resultCellHint(text, 's')
|
||||
const issue = resultCellIssue('第 3 行 张三', text, 's')
|
||||
assert.equal(hint?.message, issue?.message.replace('第 3 行 张三:', ''), text)
|
||||
}
|
||||
})
|
||||
@@ -29,22 +29,24 @@ 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', 'age',
|
||||
'bib', 'seed', 'status', 'member_names',
|
||||
'bib', 'seed', 'heat', 'status', 'member_names',
|
||||
],
|
||||
)
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age'])
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat'])
|
||||
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
|
||||
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件号 / 号码布', () => {
|
||||
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
@@ -58,13 +60,13 @@ test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 名运动员')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', 'registered', ''])
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', ''])
|
||||
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', 'registered', ''])
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', ''])
|
||||
// 只有证件号时 bib 列留空。
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||
@@ -76,8 +78,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||
@@ -90,7 +92,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
||||
athletes: [{ name: '王芳' }],
|
||||
memberNames: ['刘洋', '王芳'],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
|
||||
@@ -101,7 +103,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
|
||||
mode: 'individual',
|
||||
athletes: [{ name: '无标识' }],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
|
||||
@@ -114,7 +116,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', ()
|
||||
athletes: [],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
|
||||
@@ -137,13 +139,13 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 个单位')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 11)
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
for (const row of rows.slice(1)) {
|
||||
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
|
||||
}
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', 'registered', '张伟'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟'])
|
||||
})
|
||||
|
||||
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
|
||||
@@ -155,7 +157,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可
|
||||
status: 'scratched',
|
||||
teams: [{ teamCode: 'F1', memberNames: [] }],
|
||||
})
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'scratched', ''])
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', ''])
|
||||
})
|
||||
|
||||
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
|
||||
@@ -273,8 +275,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行',
|
||||
const lines = toCsv(rows).split('\n')
|
||||
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
|
||||
assert.equal(lines.length, 3, '表头 + 两个单位')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,registered,', '只报单位时 member_names 留空')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空')
|
||||
})
|
||||
|
||||
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
|
||||
|
||||
@@ -30,9 +30,9 @@ 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 / age / bib / seed / status / member_names */
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', 'registered', '']
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import WindowedTitleBar from './components/WindowedTitleBar.vue'
|
||||
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
|
||||
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
|
||||
import EventResultsPage from './components/pages/EventResultsPage.vue'
|
||||
import LivePage from './components/pages/LivePage.vue'
|
||||
import StandingsPage from './components/pages/StandingsPage.vue'
|
||||
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
|
||||
|
||||
@@ -22,6 +23,7 @@ const PAGES: Record<DisplayPageKey, Component> = {
|
||||
'team-medals': TeamMedalsPage,
|
||||
'athlete-standings': AthleteStandingsPage,
|
||||
'event-results': EventResultsPage,
|
||||
live: LivePage,
|
||||
announcement: AnnouncementPage,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatResultText } from '@/shared'
|
||||
import type { EventResultRow, ResultStatus } from '@/shared'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
@@ -10,26 +9,6 @@ const { latestResults, options } = useScoreboard()
|
||||
|
||||
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
|
||||
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
|
||||
|
||||
const STATUS_LABELS: Record<ResultStatus, string> = {
|
||||
ok: '',
|
||||
dq: 'DQ',
|
||||
dns: 'DNS',
|
||||
dnf: 'DNF',
|
||||
withdrawn: '退赛',
|
||||
}
|
||||
|
||||
function displayResult(row: EventResultRow): string {
|
||||
if (row.status !== 'ok') return STATUS_LABELS[row.status]
|
||||
if (row.resultValue === null) return row.resultText
|
||||
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
|
||||
}
|
||||
|
||||
function displayName(row: EventResultRow): string {
|
||||
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
|
||||
if (row.memberNames !== null) return row.memberNames.join(' / ')
|
||||
return row.shortName ?? row.teamName
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents } = useScoreboard()
|
||||
|
||||
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
|
||||
|
||||
/**
|
||||
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
|
||||
*
|
||||
* 拉到过结论就用它(不受快照 20 行截断,auto/manual 都一样);还没成功过(老服务端、断网首屏、
|
||||
* 示例数据)才退回「人工指定 + 快照分组」的兜底素材,有总比没有强。
|
||||
*/
|
||||
const sources = computed<LiveEventSource[]>(() =>
|
||||
liveFeed.value !== null
|
||||
? liveFeed.value.events
|
||||
: fallbackLiveSources(
|
||||
settings.value?.display.liveEvents ?? [],
|
||||
latestResults.value,
|
||||
eventNames.value,
|
||||
),
|
||||
)
|
||||
|
||||
const boards = computed(() => pickLiveColumns(sources.value))
|
||||
|
||||
const hint = computed(() => {
|
||||
if (boards.value.length === 0) return ''
|
||||
const base = `进行中 ${boards.value.length} 项`
|
||||
// 不显眼的降级标记:拿不到新结论时画面照旧,只在标题旁说明数据可能不是最新。
|
||||
return liveStale.value ? `${base} · 刷新中断` : base
|
||||
})
|
||||
|
||||
// 还没成功拉到过又没失败 = 第一次请求还在飞,别用「暂无项目」误导现场。
|
||||
const emptyText = computed(() =>
|
||||
liveFeed.value === null && !liveStale.value ? '正在获取当前项目…' : '暂无进行中的项目',
|
||||
)
|
||||
|
||||
// 字号取行数最多的那一栏,两栏行高才一致;两栏时每栏只有半屏宽,再收一档。
|
||||
const rowFont = computed(() => {
|
||||
const count = boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0)
|
||||
return fitRowFont(count, BOARD_AVAILABLE_PX, boards.value.length > 1 ? 0.48 : 0.62)
|
||||
})
|
||||
|
||||
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
||||
// 只在本页显示时跑(锁在别的页上时没必要打服务端);切回本页时挂载会立刻拉一次。
|
||||
let pollTimer = 0
|
||||
|
||||
onMounted(() => {
|
||||
void refreshLiveEvents()
|
||||
pollTimer = window.setInterval(() => {
|
||||
void refreshLiveEvents()
|
||||
}, LIVE_REFRESH_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="实时赛况" :hint="hint">
|
||||
<div
|
||||
v-if="boards.length > 0"
|
||||
class="boards"
|
||||
:class="`boards-${boards.length}`"
|
||||
:style="{ '--row-font': rowFont }"
|
||||
>
|
||||
<article v-for="board in boards" :key="board.eventId" class="board">
|
||||
<header class="board-head">
|
||||
<h3>{{ board.title }}</h3>
|
||||
<span v-if="board.category" class="cat">{{ board.category }}</span>
|
||||
<span v-if="board.hiddenGroups > 0" class="more">另有 {{ board.hiddenGroups }} 组</span>
|
||||
</header>
|
||||
<div v-if="board.rows.length > 0" class="rows">
|
||||
<div class="row head">
|
||||
<span class="rk">名次</span>
|
||||
<span class="nm">运动员 / 队</span>
|
||||
<span class="team">单位</span>
|
||||
<span class="rs">成绩</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in board.rows"
|
||||
:key="row.entryId"
|
||||
class="row"
|
||||
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<span class="team">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty">{{ emptyText }}</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.boards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 26px;
|
||||
}
|
||||
.board {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid var(--primary-line);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 只有一个项目时不必铺满 1920(一行字太长反而难读),居中留白与分项成绩页一致。 */
|
||||
.boards-1 .board {
|
||||
max-width: 1180px;
|
||||
}
|
||||
.board-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
h3 {
|
||||
font-size: 34px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--primary-light);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cat {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.more {
|
||||
flex: none;
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rows {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 300px 220px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: var(--row-font, 34px);
|
||||
line-height: 1.1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
animation: row-in 0.45s ease-out both;
|
||||
}
|
||||
.boards-2 .row {
|
||||
grid-template-columns: 60px 1fr 190px 150px;
|
||||
gap: 8px;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.head {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
.rk {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.top-1 .rk {
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-2 .rk {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.top-3 .rk {
|
||||
color: #e0995e;
|
||||
}
|
||||
.nm {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.28em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.78em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.team-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rs {
|
||||
text-align: right;
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.board-empty,
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+106
-3
@@ -25,6 +25,7 @@ import type {
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
} from '@/shared'
|
||||
import type { LiveEventSource } from './live'
|
||||
|
||||
/**
|
||||
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
|
||||
@@ -89,6 +90,7 @@ const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
|
||||
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
|
||||
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
|
||||
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
|
||||
const LIVE_SOURCES = ['manual', 'auto'] as const satisfies readonly LiveSource[]
|
||||
|
||||
function record(source: unknown): Record<string, unknown> {
|
||||
return isRecord(source) ? source : {}
|
||||
@@ -321,11 +323,77 @@ export function parseRecordEntry(source: unknown): RecordEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「实时赛况」页当前项目的来源:manual = 人工指定,auto = 服务端按时段 / 最近发布自动判定。 */
|
||||
export type LiveSource = 'manual' | 'auto'
|
||||
|
||||
/**
|
||||
* 大屏侧赛事配置。字段与 shared 契约一致;
|
||||
* 实时赛况页的两个赛事设置键(settings 表里的 `display.live_events` / `display.live_source`)。
|
||||
*
|
||||
* 它们是**管理端的配置**:liveEvents 是人工指定的项目,liveSource 决定服务端怎么判定当前项目。
|
||||
* 判定结论由 GET /api/display/live 下发(auto 时 liveEvents 通常是空的),
|
||||
* 这两个键在大屏侧只在接口拿不到时当兜底素材用,缺键时按「没有人工指定 / auto」处理。
|
||||
*/
|
||||
export interface LiveDisplaySettings {
|
||||
/** 人工指定的当前项目 id;auto 模式下未人工指定时为空数组。 */
|
||||
liveEvents: number[]
|
||||
liveSource: LiveSource
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏侧赛事配置。字段与 shared 契约一致,另外补上实时赛况的两个键;
|
||||
* `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。
|
||||
*/
|
||||
export type DisplaySettings = EventSettings
|
||||
export type DisplaySettings = Omit<EventSettings, 'display'> & {
|
||||
display: EventSettings['display'] & LiveDisplaySettings
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏侧快照:settings 用的是解析后的配置,比 shared 契约多「实时赛况」两个键。
|
||||
* 其余字段与共享契约一致,缓存与实时消息两条路径都走它。
|
||||
*/
|
||||
export interface DisplaySnapshot extends Omit<SnapshotMessage, 'settings'> {
|
||||
settings: DisplaySettings
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/display/live 的解析结果。
|
||||
*
|
||||
* 服务端返回 `{ source, eventIds, events: [{ event, reason, groups }] }`(见 dto.LiveEvents);
|
||||
* 大屏只用到「判定方式」与每个项目的名字和成绩,eventIds 与 events 一一对应,
|
||||
* 因此按 events 里的小项 id 落库,reason(manual/scheduled/recent)只作诊断用,不参与渲染。
|
||||
*/
|
||||
export interface DisplayLiveFeed {
|
||||
source: LiveSource
|
||||
events: LiveEventSource[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 /api/display/live。
|
||||
*
|
||||
* `events` 是契约里的必填数组(没有当前项目时服务端也发 `[]`,不是 null),所以:
|
||||
* 拿到数组 → 这就是权威结论,空数组表示「现在确实没有项目」;
|
||||
* 拿不到数组(老服务端 404、代理返回错误页)→ 返回 null,页面当作请求失败,保留上一次画面。
|
||||
*/
|
||||
export function parseLiveFeed(value: unknown): DisplayLiveFeed | null {
|
||||
if (!isRecord(value)) return null
|
||||
if (!Array.isArray(value.events)) return null
|
||||
return {
|
||||
source: oneOf(str(value.source).toLowerCase(), LIVE_SOURCES, 'auto'),
|
||||
events: value.events
|
||||
.map(parseLiveEventSource)
|
||||
.filter((item) => item.eventId > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function parseLiveEventSource(source: unknown): LiveEventSource {
|
||||
const rec = record(source)
|
||||
const event = parseEvent(rec.event)
|
||||
return {
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
groups: list(rec.groups).map(parseEventResultGroup),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套路径取值。
|
||||
@@ -350,6 +418,30 @@ function settingValue(
|
||||
return source[flatKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前项目 id 列表。
|
||||
*
|
||||
* settings 表里存的是文本,服务端可能下发数组(数字或数字字符串)、单个数字,
|
||||
* 或 `1,2` 这样的逗号串;这里一律收窄成正整数 id,去重并保持服务端给的顺序。
|
||||
* 坏值直接丢弃而不是让整包设置解析失败:少一栏总好过整页空态。
|
||||
*/
|
||||
export function parseLiveEventIds(value: unknown): number[] {
|
||||
const raw = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value.split(',')
|
||||
: typeof value === 'number'
|
||||
? [value]
|
||||
: []
|
||||
const ids: number[] = []
|
||||
for (const item of raw) {
|
||||
const id = typeof item === 'number' ? item : Number.parseInt(String(item).trim(), 10)
|
||||
if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) continue
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function parseSettings(source: unknown): DisplaySettings {
|
||||
const rec = record(source)
|
||||
const showIndividualBoard = bool(
|
||||
@@ -362,6 +454,15 @@ export function parseSettings(source: unknown): DisplaySettings {
|
||||
// 字体 id 同理:空白归一成 null,调用方按「用默认字体」处理。
|
||||
const fontTitle = str(settingValue(rec, 'display.fontTitle', 'display.font_title')).trim()
|
||||
const fontBody = str(settingValue(rec, 'display.fontBody', 'display.font_body')).trim()
|
||||
// 实时赛况:服务端判定「当前项目」,这里只做收窄;缺键 = 没有进行中的项目。
|
||||
const liveEvents = parseLiveEventIds(
|
||||
settingValue(rec, 'display.liveEvents', 'display.live_events'),
|
||||
)
|
||||
const liveSource = oneOf(
|
||||
str(settingValue(rec, 'display.liveSource', 'display.live_source')).toLowerCase(),
|
||||
LIVE_SOURCES,
|
||||
'auto',
|
||||
)
|
||||
return {
|
||||
event: {
|
||||
name: str(settingValue(rec, 'event.name', 'event.name')),
|
||||
@@ -380,6 +481,8 @@ export function parseSettings(source: unknown): DisplaySettings {
|
||||
announce: announce === '' ? null : announce,
|
||||
fontTitle: fontTitle === '' ? null : fontTitle,
|
||||
fontBody: fontBody === '' ? null : fontBody,
|
||||
liveEvents,
|
||||
liveSource,
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: num(
|
||||
@@ -432,7 +535,7 @@ export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
|
||||
}
|
||||
|
||||
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
|
||||
export function parseSnapshot(value: unknown): SnapshotMessage | null {
|
||||
export function parseSnapshot(value: unknown): DisplaySnapshot | null {
|
||||
if (!isRecord(value)) return null
|
||||
const version = numOrNull(value.version)
|
||||
if (version === null) return null
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||
|
||||
/**
|
||||
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
|
||||
*
|
||||
* 页面与测试共用这一份判定。全部是纯函数,不依赖 Vue;「当前是哪个项目」由服务端判定
|
||||
* (GET /api/display/live,见 server/internal/httpapi/live_events.go),大屏只负责渲染。
|
||||
*/
|
||||
|
||||
/** 一屏最多并排几个项目;与服务端的 MaxLiveEvents 一致。 */
|
||||
export const MAX_LIVE_COLUMNS = 2
|
||||
|
||||
/**
|
||||
* 一栏最多列几个名次。
|
||||
*
|
||||
* 行高由 flex 均分、字号按行数缩,名次一多字就小到看不清,索性截断;
|
||||
* 实时赛况看的是领先的几行,完整名次该由分项成绩页承担。
|
||||
*/
|
||||
export const MAX_LIVE_ROWS = 10
|
||||
|
||||
/**
|
||||
* 定时兜底刷新间隔。
|
||||
*
|
||||
* 成绩发布有 WS 快照推送,这条定时只负责「靠时间自己过期」的那部分——项目切换的依据是
|
||||
* scheduled_at 落在 ±30 分钟窗口内、以及最近一次发布,快照不会为时间流逝推送。
|
||||
* 30s 把切换延迟压在人的感知以内,同时每块屏只有 2 次/分钟的读请求;
|
||||
* 比 30 分钟窗口小两个数量级,再密只是白打服务端。
|
||||
*/
|
||||
export const LIVE_REFRESH_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 两次请求之间的最小间隔。
|
||||
*
|
||||
* 三种触发(挂载 / WS 快照 / 定时)撞在一起时靠它合并:落在间隔内的触发只登记一次尾随刷新,
|
||||
* 既不会同一秒打两次,也不会漏掉这段时间里发布的新成绩。
|
||||
*/
|
||||
export const LIVE_REFRESH_MIN_GAP_MS = 4_000
|
||||
|
||||
/** 一个「当前项目」的原始素材:小项信息 + 它已发布的全部组别。 */
|
||||
export interface LiveEventSource {
|
||||
eventId: number
|
||||
eventName: string
|
||||
/** 已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。 */
|
||||
groups: readonly EventResultGroup[]
|
||||
}
|
||||
|
||||
export interface LiveColumn {
|
||||
eventId: number
|
||||
/** 小项名;拿不到名字时退回「项目 #id」,不留空白。 */
|
||||
title: string
|
||||
/** 主栏那一组的组别名;没有成绩时为空串。 */
|
||||
category: string
|
||||
/** 同一项目还有几组别没进这一栏(只取最近发布的一组当主栏)。 */
|
||||
hiddenGroups: number
|
||||
/** 栏内的名次行:名次升序、无名次排最后、已截断到 MAX_LIVE_ROWS。 */
|
||||
rows: EventResultRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口判定出的「当前项目」→ 屏幕上要画的栏。
|
||||
*
|
||||
* 给了几个就画几栏(最多 MAX_LIVE_COLUMNS),顺序照服务端的来;没有项目时返回空数组,
|
||||
* 页面显示空态。项目还没发布成绩时仍然保留这一栏(rows 为空),由页面显示占位——
|
||||
* 少一栏会让剩下的一栏铺满全屏,现场看不出另一个项目其实没数据。
|
||||
*/
|
||||
export function pickLiveColumns(events: readonly LiveEventSource[]): LiveColumn[] {
|
||||
const columns: LiveColumn[] = []
|
||||
for (const item of events) {
|
||||
if (!Number.isInteger(item.eventId) || item.eventId <= 0) continue
|
||||
if (columns.some((column) => column.eventId === item.eventId)) continue
|
||||
|
||||
const group = pickLatestGroup(item.groups)
|
||||
columns.push({
|
||||
eventId: item.eventId,
|
||||
title: item.eventName.trim() === '' ? `项目 #${item.eventId}` : item.eventName,
|
||||
category: group?.categoryName ?? '',
|
||||
hiddenGroups: item.groups.length > 1 ? item.groups.length - 1 : 0,
|
||||
rows: liveRows(group),
|
||||
})
|
||||
if (columns.length >= MAX_LIVE_COLUMNS) break
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。
|
||||
*
|
||||
* 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路;
|
||||
* 快照的 latestResults 受 20 行上限约束,所以它只能算「有总比没有强」。
|
||||
* 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。
|
||||
*/
|
||||
export function fallbackLiveSources(
|
||||
ids: readonly number[],
|
||||
available: readonly EventResultGroup[],
|
||||
eventNames: ReadonlyMap<number, string> = new Map(),
|
||||
): LiveEventSource[] {
|
||||
return ids.map((eventId) => ({
|
||||
eventId,
|
||||
eventName: eventNames.get(eventId) ?? '',
|
||||
groups: available.filter((group) => group.eventId === eventId),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 距离下一次可以发请求还要等多久(毫秒);0 表示现在就能发。
|
||||
*
|
||||
* 纯算术,抽出来是为了让「合并去重」这条规则能被测试覆盖;lastAttemptAt 为 0(还没发过)
|
||||
* 时一定返回 0,页面挂载后立刻就能拿到结论。
|
||||
*/
|
||||
export function liveRefreshDelay(
|
||||
lastAttemptAt: number,
|
||||
now: number,
|
||||
minGapMs: number = LIVE_REFRESH_MIN_GAP_MS,
|
||||
): number {
|
||||
if (lastAttemptAt <= 0) return 0
|
||||
return Math.max(0, minGapMs - (now - lastAttemptAt))
|
||||
}
|
||||
|
||||
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
|
||||
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
|
||||
let latest: EventResultGroup | null = null
|
||||
for (const group of groups) {
|
||||
if (latest === null || publishedAtMs(group) > publishedAtMs(latest)) latest = group
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function publishedAtMs(group: EventResultGroup): number {
|
||||
if (group.publishedAt === null) return 0
|
||||
const parsed = Date.parse(group.publishedAt)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 栏内的名次行:按名次升序,无名次的排最后,同名次按录入顺序。
|
||||
*
|
||||
* 服务端下发前已经排过一次,但本地缓存 / 示例数据不保证顺序,所以这里统一收口;
|
||||
* 顺带截断到 MAX_LIVE_ROWS。
|
||||
*/
|
||||
export function liveRows(group: EventResultGroup | null): EventResultRow[] {
|
||||
if (group === null) return []
|
||||
return [...group.rows]
|
||||
.sort((a, b) => {
|
||||
if (a.rank === null && b.rank === null) return a.entryId - b.entryId
|
||||
if (a.rank === null) return 1
|
||||
if (b.rank === null) return -1
|
||||
return a.rank - b.rank || a.entryId - b.entryId
|
||||
})
|
||||
.slice(0, MAX_LIVE_ROWS)
|
||||
}
|
||||
+35
-25
@@ -7,6 +7,7 @@ import type {
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import type { DisplaySettings } from './guards'
|
||||
|
||||
/**
|
||||
* 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。
|
||||
@@ -247,35 +248,44 @@ function buildAthletes(): AthleteStanding[] {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例配置。单列出来是因为它比共享契约多「实时赛况」两个键(后端未就绪时也要能演示):
|
||||
* 直接写在 SnapshotMessage 字面量里会被多余属性检查拦下。
|
||||
*/
|
||||
const MOCK_SETTINGS: DisplaySettings = {
|
||||
event: {
|
||||
name: '2026 职工运动会',
|
||||
subtitle: null,
|
||||
host: '示例组委会',
|
||||
logoPath: null,
|
||||
primaryColor: '#4fd8ff',
|
||||
},
|
||||
display: {
|
||||
background: null,
|
||||
logo: null,
|
||||
title: '2026 职工运动会',
|
||||
subtitle: '积分榜 · 示例数据',
|
||||
primaryColor: '#4fd8ff',
|
||||
rotateSeconds: 8,
|
||||
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
|
||||
liveEvents: [1, 2],
|
||||
liveSource: 'manual',
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
}
|
||||
|
||||
export function createMockSnapshot(): SnapshotMessage {
|
||||
return {
|
||||
// version 固定 0 且不写入缓存:真实快照一到就能直接覆盖,不会因为版本号更大而被丢弃。
|
||||
version: 0,
|
||||
settings: {
|
||||
event: {
|
||||
name: '2026 职工运动会',
|
||||
subtitle: null,
|
||||
host: '示例组委会',
|
||||
logoPath: null,
|
||||
primaryColor: '#4fd8ff',
|
||||
},
|
||||
display: {
|
||||
background: null,
|
||||
logo: null,
|
||||
title: '2026 职工运动会',
|
||||
subtitle: '积分榜 · 示例数据',
|
||||
primaryColor: '#4fd8ff',
|
||||
rotateSeconds: 8,
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
},
|
||||
settings: MOCK_SETTINGS,
|
||||
teams: MOCK_TEAMS,
|
||||
events: buildEvents(),
|
||||
standings: buildStandings(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { formatResultText } from '@/shared'
|
||||
import type { EventResultRow, ResultStatus } from '@/shared'
|
||||
|
||||
/**
|
||||
* 成绩行的展示口径:分项成绩页与实时赛况页共用,避免同一行数据在两页显示成两种样子。
|
||||
*/
|
||||
|
||||
const STATUS_LABELS: Record<ResultStatus, string> = {
|
||||
ok: '',
|
||||
dq: 'DQ',
|
||||
dns: 'DNS',
|
||||
dnf: 'DNF',
|
||||
withdrawn: '退赛',
|
||||
}
|
||||
|
||||
/** 成绩列:异常状态优先,其次按数值 + 单位格式化,最后退回服务端给的原文。 */
|
||||
export function displayResult(row: EventResultRow): string {
|
||||
if (row.status !== 'ok') return STATUS_LABELS[row.status]
|
||||
if (row.resultValue === null) return row.resultText
|
||||
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
|
||||
}
|
||||
|
||||
/** 运动员列:个人项目用姓名,团体项目用成员名单,都缺时退回单位简称。 */
|
||||
export function displayName(row: EventResultRow): string {
|
||||
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
|
||||
if (row.memberNames !== null) return row.memberNames.join(' / ')
|
||||
return row.shortName ?? row.teamName
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SnapshotMessage } from '@/shared'
|
||||
import type { DisplaySnapshot } from './guards'
|
||||
import { isRecord, num, parseSnapshot } from './guards'
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ const SNAPSHOT_TTL_MS = 12 * 60 * 60 * 1000
|
||||
export interface CachedSnapshot {
|
||||
version: number
|
||||
savedAt: number
|
||||
snapshot: SnapshotMessage
|
||||
snapshot: DisplaySnapshot
|
||||
}
|
||||
|
||||
export function readCachedSnapshot(): CachedSnapshot | null {
|
||||
@@ -37,7 +37,7 @@ export function readCachedSnapshot(): CachedSnapshot | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedSnapshot(snapshot: SnapshotMessage): void {
|
||||
export function writeCachedSnapshot(snapshot: DisplaySnapshot): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SNAPSHOT_KEY,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||
import {
|
||||
MAX_LIVE_ROWS,
|
||||
fallbackLiveSources,
|
||||
liveRefreshDelay,
|
||||
liveRows,
|
||||
pickLiveColumns,
|
||||
} from '../live'
|
||||
import type { LiveEventSource } from '../live'
|
||||
import { parseLiveEventIds, parseLiveFeed, parseSettings } from '../guards'
|
||||
import { DISPLAY_PAGES, normalizePage } from '../types'
|
||||
|
||||
// 实时赛况页的判定都是纯函数:服务端判定「当前项目」,大屏只决定画几栏、画哪些行。
|
||||
|
||||
function row(entryId: number, rank: number | null): EventResultRow {
|
||||
return {
|
||||
entryId,
|
||||
rank,
|
||||
teamId: 1,
|
||||
teamCode: 'T01',
|
||||
teamName: '第一分公司',
|
||||
shortName: null,
|
||||
logoPath: null,
|
||||
athleteId: entryId,
|
||||
athleteName: `运动员${entryId}`,
|
||||
memberNames: null,
|
||||
resultText: '11.58',
|
||||
resultValue: 11.58,
|
||||
unit: 's',
|
||||
isTie: false,
|
||||
status: 'ok',
|
||||
}
|
||||
}
|
||||
|
||||
function group(
|
||||
eventId: number,
|
||||
categoryId = 1,
|
||||
publishedAt: string | null = null,
|
||||
rows: EventResultRow[] = [row(1, 1)],
|
||||
): EventResultGroup {
|
||||
return {
|
||||
eventId,
|
||||
eventCode: `E${eventId}`,
|
||||
eventName: `项目${eventId}`,
|
||||
categoryId,
|
||||
categoryName: `组别${categoryId}`,
|
||||
publishedAt,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
function event(eventId: number, groups: EventResultGroup[] = []): LiveEventSource {
|
||||
return { eventId, eventName: `项目${eventId}`, groups }
|
||||
}
|
||||
|
||||
test('没有当前项目时返回空栏,页面显示空态', () => {
|
||||
assert.deepEqual(pickLiveColumns([]), [])
|
||||
// 全是无效 id 的条目等同于没有项目,不能凭空造出一栏。
|
||||
assert.deepEqual(pickLiveColumns([event(0), event(-3)]), [])
|
||||
})
|
||||
|
||||
test('一个项目只分一栏,标题/组别/行都来自接口', () => {
|
||||
const columns = pickLiveColumns([event(2, [group(2, 5, '2026-09-09T15:10:00+08:00')])])
|
||||
assert.equal(columns.length, 1)
|
||||
assert.equal(columns[0]?.eventId, 2)
|
||||
assert.equal(columns[0]?.title, '项目2')
|
||||
assert.equal(columns[0]?.category, '组别5')
|
||||
assert.equal(columns[0]?.hiddenGroups, 0)
|
||||
assert.equal(columns[0]?.rows.length, 1)
|
||||
})
|
||||
|
||||
test('两个项目左右分栏,顺序照服务端给的来', () => {
|
||||
const columns = pickLiveColumns([event(2, [group(2)]), event(1, [group(1)])])
|
||||
assert.deepEqual(
|
||||
columns.map((column) => column.eventId),
|
||||
[2, 1],
|
||||
)
|
||||
assert.deepEqual(
|
||||
columns.map((column) => column.title),
|
||||
['项目2', '项目1'],
|
||||
)
|
||||
})
|
||||
|
||||
test('超过两个项目时只分两栏,重复 id 只算一栏', () => {
|
||||
const columns = pickLiveColumns([event(1), event(1), event(2), event(3)])
|
||||
assert.deepEqual(
|
||||
columns.map((column) => column.eventId),
|
||||
[1, 2],
|
||||
)
|
||||
})
|
||||
|
||||
test('项目还没发布成绩时保留占位栏(不静默少一栏)', () => {
|
||||
const columns = pickLiveColumns([event(1, [group(1)]), event(9)])
|
||||
assert.equal(columns.length, 2)
|
||||
assert.equal(columns[0]?.rows.length, 1)
|
||||
assert.equal(columns[1]?.eventId, 9)
|
||||
assert.equal(columns[1]?.title, '项目9')
|
||||
assert.equal(columns[1]?.category, '')
|
||||
assert.deepEqual(columns[1]?.rows, [])
|
||||
})
|
||||
|
||||
test('接口没给名字时标题退回「项目 #id」', () => {
|
||||
const columns = pickLiveColumns([{ eventId: 12, eventName: ' ', groups: [] }])
|
||||
assert.equal(columns[0]?.title, '项目 #12')
|
||||
})
|
||||
|
||||
test('同一项目多组别时取发布时间最近的一组,其余组只报数量', () => {
|
||||
const older = group(1, 1, '2026-09-09T09:00:00+08:00')
|
||||
const newer = group(1, 2, '2026-09-09T15:00:00+08:00')
|
||||
const columns = pickLiveColumns([event(1, [older, newer])])
|
||||
assert.equal(columns[0]?.category, '组别2')
|
||||
assert.equal(columns[0]?.hiddenGroups, 1)
|
||||
|
||||
// 发布时间缺失按最旧处理,不会把没有时间的组顶掉有时间的组。
|
||||
const noTime = group(1, 3, null)
|
||||
assert.equal(pickLiveColumns([event(1, [noTime, newer])])[0]?.category, '组别2')
|
||||
assert.equal(pickLiveColumns([event(1, [noTime, older])])[0]?.category, '组别1')
|
||||
})
|
||||
|
||||
test('栏内按名次排序、无名次排最后,并截断到上限', () => {
|
||||
const rows = [row(9, null), row(3, 3), row(1, 1), row(2, 2)]
|
||||
assert.deepEqual(
|
||||
liveRows(group(1, 1, null, rows)).map((item) => item.rank),
|
||||
[1, 2, 3, null],
|
||||
)
|
||||
assert.deepEqual(liveRows(null), [])
|
||||
|
||||
const many = Array.from({ length: MAX_LIVE_ROWS + 5 }, (_, index) => row(index + 1, index + 1))
|
||||
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
|
||||
})
|
||||
|
||||
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
|
||||
const names = new Map([[7, '男子 1500 米']])
|
||||
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
|
||||
assert.deepEqual(
|
||||
sources.map((item) => ({ id: item.eventId, name: item.eventName })),
|
||||
[
|
||||
{ id: 7, name: '男子 1500 米' },
|
||||
{ id: 3, name: '' },
|
||||
],
|
||||
)
|
||||
// 快照里没有的项目保留空组,页面显示占位栏而不是少一栏。
|
||||
assert.equal(sources[0]?.groups.length, 1)
|
||||
assert.deepEqual(sources[1]?.groups, [])
|
||||
|
||||
const columns = pickLiveColumns(sources)
|
||||
assert.equal(columns.length, 2)
|
||||
assert.equal(columns[0]?.title, '男子 1500 米')
|
||||
assert.equal(columns[1]?.title, '项目 #3')
|
||||
})
|
||||
|
||||
test('parseLiveFeed 解析 /api/display/live 的真实形状', () => {
|
||||
const feed = parseLiveFeed({
|
||||
source: 'AUTO',
|
||||
eventIds: [7, 3],
|
||||
events: [
|
||||
{
|
||||
event: { id: 7, name: '男子 1500 米', status: 'ongoing' },
|
||||
reason: 'scheduled',
|
||||
groups: [
|
||||
{
|
||||
eventId: 7,
|
||||
eventCode: 'E7',
|
||||
eventName: '男子 1500 米',
|
||||
categoryId: 1,
|
||||
categoryName: '青年组',
|
||||
publishedAt: '2026-09-11T15:20:00+08:00',
|
||||
rows: [{ entryId: 11, rank: 1, teamId: 1, teamName: '第一分公司', resultText: '4:10.88' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
// 没成绩的项目:groups 为空数组,仍要成为一栏。
|
||||
{ event: { id: 3, name: '女子跳高' }, reason: 'scheduled', groups: [] },
|
||||
],
|
||||
})
|
||||
assert.equal(feed?.source, 'auto')
|
||||
assert.equal(feed?.events.length, 2)
|
||||
assert.equal(feed?.events[0]?.eventId, 7)
|
||||
assert.equal(feed?.events[0]?.groups[0]?.categoryName, '青年组')
|
||||
assert.equal(feed?.events[0]?.groups[0]?.rows[0]?.rank, 1)
|
||||
assert.deepEqual(feed?.events[1]?.groups, [])
|
||||
|
||||
// 空 events 是权威结论:现在确实没有进行中的项目(页面显示空态,而不是保留旧画面)。
|
||||
assert.deepEqual(parseLiveFeed({ source: 'auto', eventIds: [], events: [] })?.events, [])
|
||||
|
||||
// 形状不对时返回 null,由调用方保留上一次画面。
|
||||
assert.equal(parseLiveFeed(null), null)
|
||||
assert.equal(parseLiveFeed('{}'), null)
|
||||
assert.equal(parseLiveFeed({ source: 'auto' }), null)
|
||||
assert.equal(parseLiveFeed({ events: {} }), null)
|
||||
// 小项 id 非法(event 缺失/坏数据)的条目直接丢掉,不让它占一栏。
|
||||
assert.equal(parseLiveFeed({ events: [{ reason: 'recent', groups: [] }] })?.events.length, 0)
|
||||
})
|
||||
|
||||
test('请求间隔:首次立刻发,间隔内返回剩余时间,超过间隔返回 0', () => {
|
||||
const gap = 4_000
|
||||
assert.equal(liveRefreshDelay(0, 1_000_000, gap), 0)
|
||||
assert.equal(liveRefreshDelay(1_000_000, 1_000_500, gap), 3_500)
|
||||
assert.equal(liveRefreshDelay(1_000_000, 1_004_000, gap), 0)
|
||||
assert.equal(liveRefreshDelay(1_000_000, 1_060_000, gap), 0)
|
||||
})
|
||||
|
||||
test('实时赛况的两个设置键容错解析', () => {
|
||||
assert.deepEqual(parseLiveEventIds([3, '4', 0, 'x', 3]), [3, 4])
|
||||
assert.deepEqual(parseLiveEventIds('5, 6'), [5, 6])
|
||||
assert.deepEqual(parseLiveEventIds(7), [7])
|
||||
assert.deepEqual(parseLiveEventIds(null), [])
|
||||
assert.deepEqual(parseLiveEventIds({ eventId: 1 }), [])
|
||||
})
|
||||
|
||||
test('parseSettings 归一化 liveEvents / liveSource,缺键时按没有项目处理', () => {
|
||||
const parsed = parseSettings({
|
||||
display: { liveEvents: [2, '1'], liveSource: 'MANUAL' },
|
||||
})
|
||||
assert.deepEqual(parsed.display.liveEvents, [2, 1])
|
||||
assert.equal(parsed.display.liveSource, 'manual')
|
||||
|
||||
// settings 表里是点号键时也要能读到(旧版缓存形态)。
|
||||
const flat = parseSettings({ 'display.live_events': '8,9', 'display.live_source': 'auto' })
|
||||
assert.deepEqual(flat.display.liveEvents, [8, 9])
|
||||
|
||||
const missing = parseSettings({})
|
||||
assert.deepEqual(missing.display.liveEvents, [])
|
||||
assert.equal(missing.display.liveSource, 'auto')
|
||||
})
|
||||
|
||||
test('page=live 能过页名归一化,页名是「实时赛况」', () => {
|
||||
assert.equal(DISPLAY_PAGES.find((page) => page.key === 'live')?.title, '实时赛况')
|
||||
for (const value of ['live', 'LIVE', ' live ', '实时赛况', '实时', '赛况']) {
|
||||
assert.equal(normalizePage(value), 'live')
|
||||
}
|
||||
// 不能把别的页名错归到实时赛况上。
|
||||
assert.equal(normalizePage('event-results'), 'event-results')
|
||||
})
|
||||
|
||||
test('页面按栏数切 class,三种文案都不留白', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 分栏比例由 CSS 决定(.boards-1 / .boards-2 各一套网格),class 必须跟着栏数走。
|
||||
assert.match(source, /boards-\$\{boards\.length\}/)
|
||||
// 首屏加载中 / 确实没有项目 / 指定项目还没成绩,三种情况都要有明确中文。
|
||||
assert.match(source, /正在获取当前项目…/)
|
||||
assert.match(source, /暂无进行中的项目/)
|
||||
assert.match(source, /该项目暂无已发布的成绩/)
|
||||
assert.match(source, /:path="row\.logoPath"/)
|
||||
// 定时兜底必须挂在页面上,否则项目切换会停在旧结论。
|
||||
assert.match(source, /LIVE_REFRESH_INTERVAL_MS/)
|
||||
// 数据来自接口结论(liveFeed),且挂载时立刻拉一次。
|
||||
assert.match(source, /liveFeed/)
|
||||
assert.match(source, /onMounted\(\(\) => \{\s*void refreshLiveEvents\(\)/)
|
||||
})
|
||||
@@ -8,6 +8,7 @@ export const DISPLAY_PAGES = [
|
||||
{ key: 'team-medals', title: '团体奖牌榜' },
|
||||
{ key: 'athlete-standings', title: '个人积分奖牌榜' },
|
||||
{ key: 'event-results', title: '分项成绩' },
|
||||
{ key: 'live', title: '实时赛况' },
|
||||
{ key: 'announcement', title: '公告' },
|
||||
] as const
|
||||
|
||||
@@ -34,6 +35,11 @@ const PAGE_ALIASES: Record<string, DisplayPageKey> = {
|
||||
results: 'event-results',
|
||||
分项成绩: 'event-results',
|
||||
成绩: 'event-results',
|
||||
live: 'live',
|
||||
'live-results': 'live',
|
||||
实时赛况: 'live',
|
||||
实时: 'live',
|
||||
赛况: 'live',
|
||||
announcement: 'announcement',
|
||||
announce: 'announcement',
|
||||
公告: 'announcement',
|
||||
|
||||
@@ -4,21 +4,23 @@ import type {
|
||||
AthleteStanding,
|
||||
Change,
|
||||
DisplayCommand,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
RealtimeStatus,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
} from '@/shared'
|
||||
import {
|
||||
parseLiveFeed,
|
||||
parseSettings,
|
||||
parseSnapshot,
|
||||
parseStandingRow,
|
||||
parseStandingRows,
|
||||
parseTeamBreakdown,
|
||||
} from './guards'
|
||||
import type { DisplaySettings } from './guards'
|
||||
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
|
||||
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
|
||||
import { applyDisplayFonts } from './fonts'
|
||||
import { liveRefreshDelay } from './live'
|
||||
import { createMockAthletes, createMockSnapshot } from './mock'
|
||||
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
|
||||
import { DISPLAY_PAGES, normalizePage } from './types'
|
||||
@@ -45,6 +47,17 @@ const settings = ref<DisplaySettings | null>(null)
|
||||
const standings = ref<StandingRow[]>([])
|
||||
const athletes = ref<AthleteStanding[]>([])
|
||||
const latestResults = ref<EventResultGroup[]>([])
|
||||
// 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。
|
||||
const events = ref<Event[]>([])
|
||||
/**
|
||||
* 「实时赛况」的当前项目结论(GET /api/display/live)。
|
||||
*
|
||||
* 判定依赖当前时间与最近一次发布,装不进只在变更时推送的快照,所以单独拉;
|
||||
* null = 还没成功拉到过,页面退回「人工指定 + 快照分组」的兜底素材。
|
||||
*/
|
||||
const liveFeed = ref<DisplayLiveFeed | null>(null)
|
||||
/** 最近一次拉取是否失败;失败时不动 liveFeed(现场要求:下一项目开始前一直显示上一个项目的成绩)。 */
|
||||
const liveStale = ref(false)
|
||||
const rankDelta = ref<Record<number, number>>({})
|
||||
const page = ref<DisplayPageKey>('standings')
|
||||
const locked = ref(true)
|
||||
@@ -84,6 +97,11 @@ let rankTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let detailTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pageTimer: ReturnType<typeof setInterval> | null = null
|
||||
let athletesLoading = false
|
||||
// 「实时赛况」刷新的合并去重状态:见 refreshLiveEvents。
|
||||
let liveLoading = false
|
||||
let liveQueued = false
|
||||
let liveLastAttemptAt = 0
|
||||
let liveQueueTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */
|
||||
let seenSettingsAnnounce = ''
|
||||
|
||||
@@ -143,9 +161,10 @@ function applyStandings(rows: StandingRow[]): void {
|
||||
}, RANK_DELTA_MS)
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
|
||||
function applySnapshot(snapshot: DisplaySnapshot, kind: DisplaySource): void {
|
||||
settings.value = snapshot.settings
|
||||
latestResults.value = snapshot.latestResults
|
||||
events.value = snapshot.events
|
||||
version.value = snapshot.version
|
||||
applyStandings(snapshot.standings)
|
||||
source.value = kind
|
||||
@@ -203,6 +222,73 @@ async function refreshAthletes(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记一条拉取失败的日志:只在「刚变坏」时记。
|
||||
*
|
||||
* 服务端长时间不可用时定时轮询会一直失败,每次都记会把日志刷成噪声;
|
||||
* 现场需要的是「什么时候开始坏、什么时候恢复」,中间的重复失败没有信息量。
|
||||
*/
|
||||
function reportLiveFailure(wasStale: boolean, reason: string): void {
|
||||
if (wasStale) return
|
||||
console.warn('[实时赛况]', `拉取当前项目失败,继续显示上一次成绩:${reason}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉一次「实时赛况」的当前项目(GET /api/display/live)。
|
||||
*
|
||||
* 三种触发都走这里:页面挂载、WS 快照变更(成绩发布 → 快照会推)、定时兜底。
|
||||
* 合并去重靠 liveRefreshDelay:请求还在飞、或距上次尝试不足最小间隔时,只登记一次
|
||||
* 尾随刷新,既不会同一秒打两次,也不会漏掉这段窗口里发布的新成绩。
|
||||
*
|
||||
* 失败只挂 liveStale:现场要求「下一项目开始前持续显示上一个项目的成绩」,
|
||||
* 拿不到新结论时宁可显示旧画面,也不能把屏幕清空(老服务端没有这个接口时同样如此)。
|
||||
*/
|
||||
async function refreshLiveEvents(): Promise<void> {
|
||||
if (liveLoading) {
|
||||
liveQueued = true
|
||||
return
|
||||
}
|
||||
|
||||
const wait = liveRefreshDelay(liveLastAttemptAt, Date.now())
|
||||
if (wait > 0) {
|
||||
liveQueued = true
|
||||
if (liveQueueTimer === null) {
|
||||
liveQueueTimer = setTimeout(() => {
|
||||
liveQueueTimer = null
|
||||
if (!liveQueued) return
|
||||
liveQueued = false
|
||||
void refreshLiveEvents()
|
||||
}, wait)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
liveLastAttemptAt = Date.now()
|
||||
liveLoading = true
|
||||
const wasStale = liveStale.value
|
||||
try {
|
||||
const feed = parseLiveFeed(await api.display.live())
|
||||
if (feed === null) {
|
||||
// 响应形状不对与请求失败同责:都不是一份可信的结论,保留上一份。
|
||||
liveStale.value = true
|
||||
reportLiveFailure(wasStale, '响应格式不符')
|
||||
} else {
|
||||
liveFeed.value = feed
|
||||
liveStale.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
liveStale.value = true
|
||||
reportLiveFailure(wasStale, error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
liveLoading = false
|
||||
if (liveQueued) {
|
||||
liveQueued = false
|
||||
// 立刻再调一次:此时仍在最小间隔内,会由上面那条分支排队到间隔之后再发。
|
||||
void refreshLiveEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 断线兜底与启动
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,6 +329,7 @@ function activateMock(): void {
|
||||
const snapshot = createMockSnapshot()
|
||||
settings.value = parseSettings(snapshot.settings)
|
||||
latestResults.value = snapshot.latestResults
|
||||
events.value = snapshot.events
|
||||
athletes.value = createMockAthletes()
|
||||
standings.value = snapshot.standings
|
||||
source.value = 'mock'
|
||||
@@ -450,6 +537,8 @@ function connect(): void {
|
||||
writeCachedSnapshot(snapshot)
|
||||
// 个人榜不在快照里,每次拿到快照(含 resync 结果)都单独补拉一次。
|
||||
void refreshAthletes()
|
||||
// 当前项目的判定依赖「最近一次发布」,成绩一发布就要重算;快照就是发布的信号。
|
||||
void refreshLiveEvents()
|
||||
},
|
||||
onCatchup: (message) => {
|
||||
applyChanges(message.changes)
|
||||
@@ -498,6 +587,8 @@ function dispose(): void {
|
||||
stopPageRotation()
|
||||
clearDetailTimer()
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
liveQueueTimer = clearTimer(liveQueueTimer)
|
||||
liveQueued = false
|
||||
announcementStore.dispose()
|
||||
client?.close()
|
||||
client = null
|
||||
@@ -521,6 +612,9 @@ export function useScoreboard() {
|
||||
standings,
|
||||
athletes,
|
||||
latestResults,
|
||||
events,
|
||||
liveFeed,
|
||||
liveStale,
|
||||
page,
|
||||
authRequired,
|
||||
options,
|
||||
@@ -530,6 +624,7 @@ export function useScoreboard() {
|
||||
booted,
|
||||
bootstrap,
|
||||
dispose,
|
||||
refreshLiveEvents,
|
||||
rankDeltaFor,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
BackupResult,
|
||||
CurrentUser,
|
||||
DisplayCommand,
|
||||
EntryHeatPayload,
|
||||
EntryHeatResult,
|
||||
EntryImportResult,
|
||||
EntryWithResult,
|
||||
Event,
|
||||
@@ -20,6 +22,7 @@ import type {
|
||||
EventSettings,
|
||||
HealthInfo,
|
||||
ImportConflictDecision,
|
||||
LiveEvents,
|
||||
Page,
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
@@ -182,6 +185,13 @@ export interface EntriesApi {
|
||||
importFile(file: File): Promise<EntryImportResult>
|
||||
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
|
||||
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
|
||||
/**
|
||||
* 批量设置 / 清除批次(heat)。
|
||||
*
|
||||
* 幂等;要求 entryIds 非空、都属于同一个小项,heat 为 1-999 的整数或 null(清除)。
|
||||
* 只改录入分组,不影响任何成绩与积分。
|
||||
*/
|
||||
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
|
||||
}
|
||||
|
||||
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
|
||||
@@ -249,6 +259,8 @@ export interface DisplayApi {
|
||||
command(command: DisplayCommand): Promise<void>
|
||||
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
|
||||
instances(): Promise<DisplayInstance[]>
|
||||
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
|
||||
live(): Promise<LiveEvents>
|
||||
}
|
||||
|
||||
export interface ExportQuery {
|
||||
@@ -595,6 +607,8 @@ export class ApiClient {
|
||||
form.append('decisions', JSON.stringify(decisions))
|
||||
return http.request<EntryImportResult>('/entries/import/resolve', { method: 'POST', body: form })
|
||||
},
|
||||
setHeat: (payload) =>
|
||||
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
|
||||
}
|
||||
|
||||
this.results = createResultsApi(http)
|
||||
@@ -631,6 +645,7 @@ export class ApiClient {
|
||||
}
|
||||
return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null)
|
||||
},
|
||||
live: () => http.request<LiveEvents>('/display/live'),
|
||||
}
|
||||
|
||||
this.exports = createExportApi(http)
|
||||
|
||||
@@ -152,6 +152,8 @@ export interface Entry {
|
||||
memberNames: string[] | null
|
||||
bib: string | null
|
||||
seed: string | null
|
||||
/** 录入 / 展示用的批次号(null = 未分批);批次不参与算分,排名与积分仍按「小项 + 组别」算。 */
|
||||
heat: number | null
|
||||
status: EntryStatus
|
||||
remark: string | null
|
||||
/** 以下为联表展示字段,接口按需带出。 */
|
||||
@@ -212,6 +214,19 @@ export interface EntryImportResult {
|
||||
conflicts: ImportConflict[]
|
||||
}
|
||||
|
||||
/** 批量设置批次(PUT /api/entries/heat)的请求体;heat 为 null 表示清除为「未分批」。 */
|
||||
export interface EntryHeatPayload {
|
||||
entryIds: number[]
|
||||
heat: number | null
|
||||
}
|
||||
|
||||
/** 批量设置批次的结果:这一批报名项当前统一的批次号。 */
|
||||
export interface EntryHeatResult {
|
||||
updated: number
|
||||
heat: number | null
|
||||
entryIds: number[]
|
||||
}
|
||||
|
||||
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'
|
||||
export type ResultState = 'draft' | 'published'
|
||||
|
||||
@@ -434,6 +449,17 @@ export interface EventSettings {
|
||||
*/
|
||||
fontTitle?: string | null
|
||||
fontBody?: string | null
|
||||
/**
|
||||
* 「实时赛况」页人工指定的当前项目 id,最多 2 个;空数组表示没有人工指定。
|
||||
* 旧服务端不下发时可选,解析层归一成 []。
|
||||
*/
|
||||
liveEvents?: number[]
|
||||
/**
|
||||
* 「当前项目」的判定方式:manual 只认 liveEvents;auto 走
|
||||
* 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||||
* 旧服务端不下发时可选,解析层归一成 auto。
|
||||
*/
|
||||
liveSource?: string | null
|
||||
}
|
||||
standings: {
|
||||
individualToTeamWeight: number
|
||||
@@ -729,6 +755,26 @@ export interface EventResultGroup {
|
||||
rows: EventResultRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 「实时赛况」当前项目(`GET /api/display/live`)。
|
||||
*
|
||||
* 判定在服务端做(依赖当前时间与最近一次发布),大屏与「大屏控制」页直接消费结论;
|
||||
* reason 说明入选原因:manual(人工指定)/ scheduled(排期命中)/ recent(最近发布)。
|
||||
*/
|
||||
export interface LiveEvent {
|
||||
event: Event
|
||||
reason: string
|
||||
groups: EventResultGroup[]
|
||||
}
|
||||
|
||||
export interface LiveEvents {
|
||||
/** 本次生效的判定方式:manual 或 auto。 */
|
||||
source: string
|
||||
/** 判定出的当前项目 id,最多 2 个,与 events 一一对应。 */
|
||||
eventIds: number[]
|
||||
events: LiveEvent[]
|
||||
}
|
||||
|
||||
export interface SnapshotMessage {
|
||||
version: number
|
||||
settings: EventSettings
|
||||
|
||||
Reference in new issue
Block a user