Files
Scoreboard/console/README.md
T
AskaEth 08a5740fb2 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 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

205 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scoreboard Console
赛事积分榜系统的桌面宿主(Electron)。它只做六件事:
1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件;
2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏;
3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏;
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单;
5. 默认注册登录项但不启用(不随系统启动),可在服务管理窗口的「开机自启」开关里开启;
6. 在服务管理窗口展示服务端在线的大屏实例(`GET /api/display/instances`)。
规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。
## 目录结构
```
console/
├── package.json # main 指向 main/index.js;electron-builder 配置
├── config.json # 运行配置模板(打包后首次启动复制到用户数据目录,改这里的只在开发态生效)
├── main/
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
│ ├── config.js # 配置加载与合并、exe 路径解析
│ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移
│ ├── datadir.test.js # 数据目录的单元测试
│ ├── userpaths.js # 配置/规则目录解析 + 首次复制迁移(config.json / config.yaml / rules)
│ ├── userpaths.test.js # 配置/规则路径的单元测试
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
│ ├── displays.test.js # 大屏窗口模式与 URL 一致性的回归测试
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
│ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件)
├── scripts/
│ └── check-syntax.js # pnpm check:跨平台递归 node --check
├── extra/ # 打包前由 deploy/prepare-extra.mjs 暂存 使用说明.txt 与 报名名单导入规范.txt(extraFiles 指向它)
└── renderer/
├── index.html # 管理窗口(含自绘标题栏)
├── styles.css
├── app.js
├── preload.js # 管理窗口的 contextBridge 白名单
├── display-preload.js # 大屏窗口的 window.displayShell 白名单(窗口级全屏状态,供页面收放自绘标题栏)
└── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单(窗口控制 + 显示器菜单)
```
## 运行方式
```bash
cd console
pnpm install # 首次需要下载 Electron
pnpm start # 等价于 electron .
```
开发时把 `scoreboard.exe` 放在以下任一位置都能被找到(`config.json` 里的相对路径会按顺序探测):
1. `console/`
2. 当前工作目录
3. 仓库根目录
4. `dist/`
现场(部署包)形态:
```
scoreboard-windows-amd64/
├── Scoreboard_Console.exe
├── resources/app.asar # 内含 main/ renderer/ config.json
├── config.json # exe 旁边的配置模板(首次启动复制到用户数据目录)
├── scoreboard.exe
├── config.yaml # 服务端配置模板(同上)
├── rules/ # 规则包模板(同上)
└── logs/console.log # 运行时生成(日志仍留在安装目录)
```
数据、配置与规则都不在安装目录里,都在 `%LOCALAPPDATA%\ScoreboardConsole\` 下(见下文两节)。
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → 用户数据目录 `<用户根>\console\config.json`(首次启动从 exe 旁边的模板复制)。开发态(`pnpm start`)三者都不接管,仍用仓库里的 `console/config.json`。
## 数据目录(重要)
electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖安装会先静默跑一遍旧版卸载器**,所以装在安装目录里的 `data\` 会被整体删掉;`%APPDATA%\Scoreboard Console` 卸载时同样会删,因此也不能用 `app.getPath('userData')`。最终方案:
| 场景 | 数据目录 |
| --- | --- |
| 打包态,`server.dataDir` 留空(默认) | `%LOCALAPPDATA%\ScoreboardConsole\data`(卸载器不碰这个位置) |
| 配置了 `server.dataDir` | 配什么用什么:绝对路径直接用;相对路径以安装目录为基准 |
| 开发态(`pnpm start`),`server.dataDir` 留空 | 不接管,沿用服务端默认(工作目录下的 `data`),仓库里的 `data/` 不受影响 |
- Console 把最终目录通过服务端的 `-data <绝对路径>` 传下去,`passwd` 子命令带同一个参数,保证改的是同一个库里的账号;
- 迁移只在**打包态**做一次:**目标目录为空(或不存在)且 `<安装目录>\data` 有数据**时,用 `fs.renameSync` 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留;
- 两边都有数据时**不做任何动作**,只写一条醒目日志提示人工确认,两份都不会被自动删除;
- 迁移结果与最终数据目录都写进 `logs/console.log`(搜「数据目录」「已把旧数据从」);
- 数据目录若仍在安装目录内,启动时会额外打一条警告。
改完 `datadir.js` 跑 `node --test main/datadir.test.js`(Node 内置测试运行器,无额外依赖)。
## 配置与规则目录(重要)
同样因为卸载器会删掉整个安装目录,随包的 `config.json`、`config.yaml`、`rules\` 也不放在安装目录里,而是跟数据目录同一基座:
| 内容 | 打包态(`planUserPaths` mode=`user`) | 开发态(mode=`install`) |
| --- | --- | --- |
| Console 配置 | `<用户根>\console\config.json` | 仓库 `console/config.json` |
| 服务端配置 | `<用户根>\config.yaml` | 仓库 `config.yaml` 的位置不变 |
| 规则目录 | `<用户根>\rules\` | 仓库 `rules/` |
(`<用户根>` = `%LOCALAPPDATA%\ScoreboardConsole`,与数据目录同一处。)
- `userpaths.js` 只做三件事:解析这三个落点、首次复制、失败回落;`planUserPaths` 是纯函数(只算路径),`prepareUserPaths` 才碰磁盘,便于单测;
- **首次运行复制**:目标不存在且安装目录里有那份模板 → `fs.copyFileSync` / `fs.cpSync`(规则目录先拷到 `<target>.tmp-<pid>` 再整体 rename,失败不会留下半份)。安装目录那份**留作模板**,不做移动;
- **已存在就绝不覆盖**:用户目录里已经有的那份一律优先,既不覆盖也不删除(用户改过的配置、自己加的规则包都不会被模板顶掉);
- 拉起服务端时 `-config` 的值被换成 `<用户根>\config.yaml` 的绝对路径(`withConfigArg`:参数里有 `-config` 就改值,没有就补一条),`passwd` 用同一份;这样服务端读的配置不再取决于 cwd;
- 任一项目标目录建不出来或不可写时只记日志并**回落到安装目录里那份**(`serverConfig` 回落时返回 `null`,拉起服务端时沿用 `server.args` 原值),绝不抛异常、绝不删用户文件;
- 拿不到 `%LOCALAPPDATA%` 时三项全回落安装目录并记一条警告;
- 日志文件 `logs/console.log` 是个例外:仍然相对**部署包根目录**(打包态 = 安装目录,开发态 = `console/`)解析,不跟着配置搬(易失的排查材料,搬了反而与文档、现场习惯不一致)。
改完 `userpaths.js` 跑 `node --test main/*.test.js`。
## 行为说明
| 场景 | 行为 |
| --- | --- |
| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏 |
| 模式与 URL | **必须永远一致**:模式(窗口化 / kiosk)记在窗口的 entry 上,谁改模式谁按新模式重载 URL。`layout()` 只对排布自己开的窗口套用全局 `single`,管理面板手动开的窗口保持自己的模式——否则会出现「窗口化窗口被改成 kiosk 全屏、URL 里还留着 `windowed=1`」这种页面认知与窗口真实模式脱钩、标题栏画反的问题(回归测试见 `main/displays.test.js`)|
| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 |
| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` |
| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=<id>&instance=<host>-<id>`,页序按 `display.pages` 轮转 |
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 |
| 配置与规则 | 同样在 `%LOCALAPPDATA%\ScoreboardConsole\`(`console\config.json`、`config.yaml`、`rules\`),打包态首次从安装目录复制、之后只用用户目录那份;拉起服务端时 `-config` 传绝对路径(见「配置与规则目录」一节) |
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
| 开机自启 | 默认「已注册但未启用」;在服务管理窗口的「开机自启」开关里控制(`startup.openAtLogin`),仅打包后的 Windows/macOS 生效,开发态不注册 |
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录 |
| 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) |
## 配置项(`config.json`;打包态生效的是用户数据目录里那份)
| 键 | 默认值 | 说明 |
| --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 相对路径会以部署包根目录(打包态 = exe 所在目录,开发态 = `console/`)、当前目录、上级目录、`dist\` 依次探测,也可写绝对路径 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数;其中 `-config` 的值会被换成解析出的绝对路径(`<用户根>\config.yaml`),其余参数(如 `-project`)原样传 |
| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以部署包根目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 |
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 |
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 |
| `server.maxRestarts` | `0` | 0 = 无限重启 |
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 |
| `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 |
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` |
| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 |
| `display.pages` | `["standings","medals","individual","results"]` | 按屏序轮转锁定;只填大屏支持的页名 |
| `display.pageParam` | `page` | 页面 URL 参数名 |
| `display.kiosk` | `true` | 多屏时是否 kiosk(单屏恒窗口化) |
| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 |
| `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 |
| `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 |
| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 |
| `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) |
| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) |
配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。
## 打包(electron-builder)
`package.json` 里已写好 `build` 段:
```bash
cd console
pnpm install
pnpm check # 跨平台语法检查(scripts/check-syntax.js)
pnpm dist # electron-builder --win --x64 → ../dist/console/
pnpm pack # 只出目录,不生成安装包,便于现场调试
```
- 产物: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` 指向它)。
- 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。
- `nsis.include` 指向 `resources/installer-custom.nsh`(构建时由 `deploy/prepare-extra.mjs` 生成,已 gitignore):许可页顶部文案 + 记住上次安装目录(重装时默认沿用,不退回系统默认路径)。
- 已在 Windows 构建机上通过 `deploy\build.bat` 实跑(含 `pnpm dist`,产出 NSIS 安装包 + zip);Linux 开发机未安装 Electron/electron-builder。
## 尚未实机验证
以下内容只做了静态语法检查,需要在 Windows + 真实多显示器环境跑一遍:
- 多屏 kiosk 定位与 `display-metrics-changed` 重排(改分辨率 / 缩放 / 插拔 HDMI)。
- 管理面板显示器菜单:真正的 kiosk 全屏 / 窗口化结果、`screen.getAllDisplays()` 的 `label` 与主屏判定、`display:list` 返回的 scaleFactor 是否与现场一致。
- `powerSaveBlocker` 在长时间后台下的实际效果。
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
- 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。
- 配置/规则首次复制实机路径:老版本升级到本版本后,安装目录里的 `config.json` / `config.yaml` / `rules\` 是否被复制到 `%LOCALAPPDATA%\ScoreboardConsole\` 下对应位置、改过配置后覆盖安装是否仍然生效、`%LOCALAPPDATA%` 不可写(或被杀毒软件拦截)时是否只记日志并回落到安装目录里那份。
- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。