Files
Scoreboard/console
AskaEth d11cab31db fix(entry): 补报名弹窗的搜索框支持「号码布」;版本 dev_0.5.7
## 现场问题(主人原话)
> 成绩录入界面的补报名按钮窗口里面的搜索框只能查姓名和工号,不能查号码布,加一下。

号码布是本系统里**运动员的唯一标识**(定稿口径),现场拿号码布找人是常态,所以这条是刚需。

## 改了什么
- 抽出纯函数 `athleteMatchesKeyword(athlete, keyword)` 作为**选人搜索的唯一口径**:姓名、工号、**号码布** 三维并列;
  关键字归一(`trim()` + `toLowerCase()`)与仓库既有写法逐字一致,空搜索仍不筛人。
- `EntryPicker.vue` **两处**搜索都改成走它:
  - `filteredAthletes`(个人报名名单)
  - `membersByTeam`(团体队员名单)
- 两个搜索框的提示文字从「搜索姓名 / 工号」改成「**搜索姓名 / 工号 / 号码布**」。
- ⚠️ 收口成一个函数的意义:两个框**不会再各走各的** —— 这次就是因为两处各写了一份过滤条件,改一处漏一处。

## 验证
- **先红后绿**:新增 `web/src/admin/tests/athlete-search-bib.test.ts`(号码布能搜到、姓名/工号不回归、大小写与空格处理、空搜索不筛人)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 929 例 0 fail / Console 68 例 0 fail。

## 顺带
`debug/使用说明/` 里落后的 4 份(使用手册 / 升级说明 / 报名名单导入规范 / 使用说明.txt)已补刷,与 `docs/` 逐份一致;
`docs/升级说明.md` 新增 `dev_0.5.7` 一节。

## 版本
`dev_0.5.6` → **`dev_0.5.7`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 21:54:05 +08:00
..

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 / 日志转发 / 健康检查 / 自动重启
│   ├── service.test.js   # 守护逻辑的单元测试(注入假 spawn/时钟/HTTP)
│   ├── 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 白名单(窗口控制 + 显示器菜单)

运行方式

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 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留;
  • 「没有旧数据」与「读不到旧目录」分开处理:旧目录不存在/存在但空才是 no-legacy(不写日志);旧目录读不出来(EACCES、ENOTDIR 等)返回 legacy-unreadable 并写一条「读不到旧数据目录」警告,绝不当成「没有旧数据」静默跳过(回归测试见 main/datadir.test.js);
  • 两边都有数据时不做任何动作,只写一条醒目日志提示人工确认,两份都不会被自动删除;
  • 迁移结果与最终数据目录都写进 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(分辨率/缩放变化不重排,行为冻结);display-added / display-removed 一直听着,电视插回来会自动排布回 kiosk。监听常驻、只有 closeAll()(服务停止 / 应用退出)才摘(回归测试见 main/displays.test.js);F11 手动切换全屏,窗口级全屏状态经 display-preload.js 下发给页面,页面据此收起自绘标题栏。窗口化窗口不置顶、不穿透:F11 全屏完全由用户控制,置顶会把它变成一块收不掉、盖住一切的覆盖层
模式与 URL 必须永远一致:模式(窗口化 / kiosk)记在窗口的 entry 上,谁改模式谁按新模式重载 URL。layout() 只对排布自己开的窗口套用全局 single,管理面板手动开的窗口保持自己的模式——否则会出现「窗口化窗口被改成 kiosk 全屏、URL 里还留着 windowed=1」这种页面认知与窗口真实模式脱钩、标题栏画反的问题(回归测试见 main/displays.test.js)
多块屏 除内置屏外的每块屏一个 kiosk 窗口,按 display.bounds(DIP)定位,光标由页面 CSS 隐藏。窗口默认吞掉鼠标、不穿透:setIgnoreMouseEvents(true) 的语义是「本窗口忽略鼠标」,点击会落到它后面的窗口或桌面上(现场想防的误触反而变成误点别的东西),被忽略鼠标的窗口也点不活、抢不回前台;只有确实要把大屏当纯展示层、同一块屏上还要操作别的窗口时才显式配 display.allowClickThrough: true。这个开关是从 display.ignoreMouse 改名来的:旧版默认穿透,而且旧版模板已经把 ignoreMouse: true 写进了现场机器的用户配置(userpaths.js 不覆盖用户文件),只改默认值救不了老机器——换个新键名,旧键自然失效;启动时若发现配置里还留着旧键,会在 logs/console.log 里记一条提示(只记一次),说明它已废弃、现在默认不穿透、想穿透该写哪个键。窗口置顶在 screen-saver 层级(Windows 上 setAlwaysOnTop(true) 的默认层级 floating 仍画在任务栏下面,只有 pop-up-menu 及以上才压得住任务栏),所以别的窗口拖到这块屏上盖不住它、任务栏也盖不住它,被盖住后点一下就能回到最前。调用顺序必须是「先 setBounds 定位 → 再 setKiosk(true) → 最后置顶」:反过来会把窗口从真正的全屏态拽回「恰好铺满该屏的无边框窗口」,既不是 topmost、也压不住任务栏(回归测试见 main/displays.test.js)
选屏 运行时枚举,不写死坐标;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 线性退避(第 N 次等 N × restartDelayMs,最多 5 倍)自动重启(maxRestarts=0 为无限);持续健康 healthyResetMs 后崩溃计数清零;恢复健康后自动重载大屏
服务端按设计退出 切换赛事 / 一键恢复时服务端先回执 200 再自己优雅退出(退出码 0)。这种退出不当崩溃:状态是「重启中」(黄灯,不是红灯)、崩溃计数不 +1、同样按 restartDelayMs 自动拉起。判定见 service.js 的 isPlannedExit:退出码 0 且落在预期退出窗口内(最后一次健康检查通过后最多 15 秒,HealthInterval 更大时按两个探测周期放宽)——刚起来就退 0 或卡死很久才退 0 都仍按崩溃处理
数据目录 默认 %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);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录;管理窗口按同一个 log.maxLines 截断(超上限从头部丢,长跑不变卡)
服务管理窗口的路径显示 「服务端程序」「配置文件」两行分别是解析出的 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 重启基础退避:第 N 次等 N × 本值(×1..×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.allowClickThrough false 多屏 kiosk 窗口是否允许点击穿透(setIgnoreMouseEvents(true))。写 true 后该窗口忽略所有鼠标事件:点击落到它后面的窗口/桌面(现场误触会点到别的东西),大屏也点不活、被盖住后抢不回前台。只把大屏当纯展示层、且要在同一块屏上操作别的窗口时才开
display.ignoreMouse — 已废弃 → 见 display.allowClickThrough。旧版默认 true 且被旧模板写进现场机器的用户配置,只改默认值救不了老机器,所以换了键名;本键现在完全不读,配置文件里留着它只会让启动日志多一条提示,不影响行为
display.reloadEveryHours 24 定时重载兜底,0 = 关闭
display.waitForServerMs 20000 服务端未就绪时开大屏的兜底等待
display.backgroundColor #0b0f1a 窗口底色,减少加载白闪
startup.openAtLogin false 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册)
admin.width / admin.height 1280 / 860 管理窗口初始尺寸(服务端管理面板窗口按 width 不小于 1100 取用)
log.maxLines / log.file / log.maxFileBytes 2000 / logs/console.log / 4194304 日志缓冲与文件;log.file 相对部署包根目录解析(不随配置搬到用户数据目录)

配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。配置里如果还留着已改名的旧键(目前是 display.ignoreMouse),启动时会在 logs/console.log 里记一条中文提示(只在启动记一次,不随大屏重排重复刷),说明该键已废弃、现在默认不穿透、要穿透该写哪个键;旧键的值一律忽略,不会被自动改写进用户的配置文件。

打包(electron-builder)

package.json 里已写好 build 段:

cd console
pnpm install
pnpm check       # 跨平台语法检查(scripts/check-syntax.js)
pnpm dist        # electron-builder --win --x64 → ../dist/console/
pnpm pack        # 只出目录,不生成安装包,便于现场调试
  • 产物:NSIS 安装包 + zip,文件名形如 Scoreboard Console-<版本>-x64.*。<版本> 不是手写的:deploy/prepare-extra.mjs 在打包前读仓库根 VERSION(当前 dev_0.1.8)去掉 dev_ 前缀写进 console/package.json 的 version,artifactName 用 ${productName}-${version}-${arch}.${ext} 展开 —— 也就是说这里不要手改版本号,改仓库根的 VERSION。
  • 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 窗口的置顶(setAlwaysOnTop(true, 'screen-saver'))与任务栏覆盖:能否稳定压住任务栏、别的窗口拖到该屏上会不会盖住它、点击后能否回到最前——screen-saver 层级在 Windows 上的实际效果只能在真机上验;setKiosk / setBounds 的先后顺序同理(Linux 开发机没有 Electron 运行时)。
  • 管理面板显示器菜单:真正的 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 安装包在真机上的安装 / 卸载流程尚未验证。