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:
AskaEth committed 2026-09-11 04:05:02 +08:00
1 parent e5e0944d26
commit 08a5740fb2
64 files changed
+4968 -270

No files matched your search

+2
View File
@@ -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
+2
View File
@@ -15,6 +15,8 @@
- **变更历史**:仿 Git 图谱,一眼看清成绩是怎么变成现在这样的
- **现场公开**:积分明细可下钻,争议时一键推送到大屏
- **全屏公告**:推送后先全屏显示(5–120 秒),随即常驻大屏底部公告栏,直到替换或清除
- **分批发布**:一个项目分多场跑(如跑道 8 条、30 人分 4 场)时,**每跑完一批就能先发布一批**——空成绩只是提示、**不再挡发布**,未录的行自动跳过,补录后再发一次即自动重算;名次与积分先按已发布的那部分算,大屏立刻有内容
- **实时赛况大屏页**:新增「实时赛况」页,显示当前进行中项目的实时排名(名次 / 运动员·队 / 单位 / 成绩);1 个项目单栏、2 个自动左右分栏;当前项目按「人工指定 → 排期命中(开始时间前后 30 分钟)→ 最近发布过成绩的项目」判定,**下一场开始前持续显示上一场**;可在「大屏控制 → 实时赛况」卡片配置
- **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分)
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗;「大屏控制」页的「在线大屏」列表实时显示每个在线实例(含局域网浏览器打开的 `/display`)当前在哪一页,可逐屏 / 逐设备单独切页
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
+1
View File
@@ -0,0 +1 @@
dev_0.1.1
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+49 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 在管理端单独控制某块屏
+25
View File
@@ -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"`
}
+69
View File
@@ -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")
+92
View File
@@ -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()
+24 -15
View File
@@ -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)
}
}
}
+268
View File
@@ -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 {
+380
View File
@@ -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)
}
}
}
+205
View File
@@ -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)
}
+12
View File
@@ -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 {
+82 -7
View File
@@ -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
}
+5 -5
View File
@@ -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
}
+31
View File
@@ -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 {
+14 -1
View File
@@ -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 = ?)`
+4
View File
@@ -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))
+1 -1
View File
@@ -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
View File
@@ -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;
+87
View File
@@ -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)
}
}
+22
View File
@@ -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)),
+1
View File
@@ -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('/'),
])
+108 -1
View File
@@ -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;
}
+286 -5
View File
@@ -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
+82 -25
View File
@@ -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="确认发布"
+126 -11
View File
@@ -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 = '有未保存的成绩修改'
+123
View File
@@ -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))
}
+82
View File
@@ -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: [] })
})
+65 -3
View File
@@ -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)
})
+128
View File
@@ -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])
})
+66
View File
@@ -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)
}
})
+23 -21
View File
@@ -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 只认具体运动员', () => {
+2 -2
View File
@@ -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 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
+2
View File
@@ -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
View File
@@ -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
+150
View File
@@ -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
View File
@@ -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(),
+28
View File
@@ -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
}
+3 -3
View File
@@ -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,
+256
View File
@@ -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\(\)/)
})
+6
View File
@@ -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',
+98 -3
View File
@@ -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,
}
}
+15
View File
@@ -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)
+46
View File
@@ -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