fix: 大屏背景图被兜底渐变盖住 + 配置/规则也搬到用户数据目录

1) 大屏背景图不生效:App.vue 里 .bg 与 .bg-fallback 都是 absolute inset:0,
   而兜底渐变是不透明的且在图片之后绘制 → 配了背景图也只看得到渐变。
   改成 v-if/v-else 互斥(用无头 Chromium 真渲染对照验证:修复后取到图片像素,
   回退版本只剩渐变)。新增 background-layer.test.ts 锁住层次关系。

2) 规则/config 仍在安装目录,覆盖安装/卸载会连配置一起删:现在三样都落在
   %LOCALAPPDATA%\ScoreboardConsole\(与数据目录同一基座):
   - console\config.json(Console 读写都用这份)
   - config.yaml(Console 用绝对路径 -config 传给服务端与 passwd)
   - rules\(首次从安装目录复制过去,之后只认用户目录那份)
   首次运行复制迁移、已存在不覆盖、不可写则回落安装目录并记日志(不影响启动);
   开发态行为不变。备份脚本与文档同步。
   注意 resolveExecutable 必须显式传部署包根目录,否则会去用户目录找 scoreboard.exe。

自检:服务端 11 包全绿;console 语法 16 文件 + node:test 20/20;
前端 vue-tsc + build、node --test 69/69。
This commit is contained in:
AskaEth committed 2026-09-10 10:06:21 +08:00
1 parent 2d207fd654
commit 407abec3b0
15 files changed
+759 -81

No files matched your search

+42 -15
View File
@@ -16,12 +16,14 @@
``` ```
console/ console/
├── package.json # main 指向 main/index.js;electron-builder 配置 ├── package.json # main 指向 main/index.js;electron-builder 配置
├── config.json # 运行配置(端口 / exe 路径 / 选屏策略) ├── config.json # 运行配置模板(打包后首次启动复制到用户数据目录,改这里的只在开发态生效)
├── main/ ├── main/
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期 │ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
│ ├── config.js # 配置加载与合并、exe 路径解析 │ ├── config.js # 配置加载与合并、exe 路径解析
│ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移 │ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移
│ ├── datadir.test.js # 数据目录的单元测试(node --test main/datadir.test.js) │ ├── datadir.test.js # 数据目录的单元测试
│ ├── userpaths.js # 配置/规则目录解析 + 首次复制迁移(config.json / config.yaml / rules)
│ ├── userpaths.test.js # 配置/规则路径的单元测试
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启 │ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances │ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd │ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
@@ -60,16 +62,16 @@ pnpm start # 等价于 electron .
scoreboard-windows-amd64/ scoreboard-windows-amd64/
├── Scoreboard_Console.exe ├── Scoreboard_Console.exe
├── resources/app.asar # 内含 main/ renderer/ config.json ├── resources/app.asar # 内含 main/ renderer/ config.json
├── config.json # exe 旁边的可编辑配置 ├── config.json # exe 旁边的配置模板(首次启动复制到用户数据目录)
├── scoreboard.exe ├── scoreboard.exe
├── config.yaml ├── config.yaml # 服务端配置模板(同上)
├── rules/ ├── rules/ # 规则包模板(同上)
└── logs/console.log # 运行时生成 └── logs/console.log # 运行时生成(日志仍留在安装目录)
``` ```
数据不在安装目录里,默认落在 `%LOCALAPPDATA%\ScoreboardConsole\data`(见下文「数据目录」)。 数据、配置与规则都不在安装目录里,都在 `%LOCALAPPDATA%\ScoreboardConsole\` 下(见下文两节)。
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → exe 同目录的 `config.json`。 打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → 用户数据目录 `<用户根>\console\config.json`(首次启动从 exe 旁边的模板复制)。开发态(`pnpm start`)三者都不接管,仍用仓库里的 `console/config.json`。
## 数据目录(重要) ## 数据目录(重要)
@@ -89,6 +91,28 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
改完 `datadir.js` 跑 `node --test main/datadir.test.js`(Node 内置测试运行器,无额外依赖)。 改完 `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`。
## 行为说明 ## 行为说明
| 场景 | 行为 | | 场景 | 行为 |
@@ -101,6 +125,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 | | 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 | | 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 | | 数据目录 | 默认 `%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/连接失败)再强杀 | | 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 | | 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | | 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
@@ -109,15 +134,16 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | | 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` | | 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 | | 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) | | 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录 |
| 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) |
## 配置项(`config.json`) ## 配置项(`config.json`;打包态生效的是用户数据目录里那份)
| 键 | 默认值 | 说明 | | 键 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 | | `server.exe` | `scoreboard.exe` | 相对路径会以部署包根目录(打包态 = exe 所在目录,开发态 = `console/`)、当前目录、上级目录、`dist\` 依次探测,也可写绝对路径 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 | | `server.args` | `["-config","config.yaml"]` | 传给服务端的参数;其中 `-config` 的值会被换成解析出的绝对路径(`<用户根>\config.yaml`),其余参数(如 `-project`)原样传 |
| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以安装目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 | | `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以部署包根目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 |
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) | | `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 | | `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 |
| `server.healthIntervalMs` | `5000` | 健康检查间隔 | | `server.healthIntervalMs` | `5000` | 健康检查间隔 |
@@ -137,7 +163,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 |
| `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) | | `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) |
| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | | `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 | | `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) |
配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。
@@ -154,7 +180,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
``` ```
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。 - 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。 - `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。
- `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。 - `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。
- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 - 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
- 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。 - 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。
@@ -171,4 +197,5 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。 - 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。 - `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
- 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。 - 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。
- 配置/规则首次复制实机路径:老版本升级到本版本后,安装目录里的 `config.json` / `config.yaml` / `rules\` 是否被复制到 `%LOCALAPPDATA%\ScoreboardConsole\` 下对应位置、改过配置后覆盖安装是否仍然生效、`%LOCALAPPDATA%` 不可写(或被杀毒软件拦截)时是否只记日志并回落到安装目录里那份。
- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。 - electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。
+2 -2
View File
@@ -35,8 +35,8 @@ function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS })
return new Promise((resolve) => { return new Promise((resolve) => {
let child let child
try { try {
// cwd 取 exe 所在目录:config.yaml 与 rules/ 都跟 exe 平级 // cwd 取 exe 所在目录(部署包根目录);config.yaml 与数据目录都由参数给绝对路径,
// (数据目录由 -data 显式指定,不依赖 cwd) // 不依赖 cwd
child = spawn(exePath, args, { child = spawn(exePath, args, {
cwd: path.dirname(exePath), cwd: path.dirname(exePath),
windowsHide: true, windowsHide: true,
+7 -3
View File
@@ -8,6 +8,8 @@ const path = require('path')
const DEFAULTS = { const DEFAULTS = {
server: { server: {
exe: 'scoreboard.exe', exe: 'scoreboard.exe',
// -config 只声明「有个配置文件参数」,值在拉起服务端时被换成解析出的绝对路径:
// 打包态指向 <用户根>\config.yaml,开发态保持这里的相对值(见 userpaths.js)
args: ['-config', 'config.yaml'], args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态是 // 数据目录。空串 = 用 Console 选的默认位置(打包态是
// %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据); // %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据);
@@ -102,12 +104,14 @@ function updateConfig(configPath, mutate) {
} }
// 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同: // 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同:
// 开发时是 console/ 的上级,打包后是 exe 所在目录。逐个候选位置探测,避免写死。 // 开发时是仓库的 console/,打包后是 exe 所在目录。逐个候选位置探测,避免写死。
function resolveExecutable(exe, configPath) { // baseDir 是显式给出的部署包根目录;不传时退回「按 configPath 所在目录推断」——
// 打包态 config.json 已经搬到用户数据目录,那里推不出安装目录,必须显式传。
function resolveExecutable(exe, configPath, baseDir) {
if (!exe) return { path: null, candidates: [] } if (!exe) return { path: null, candidates: [] }
if (path.isAbsolute(exe)) return { path: exe, candidates: [exe] } if (path.isAbsolute(exe)) return { path: exe, candidates: [exe] }
const configDir = path.dirname(configPath) const configDir = baseDir ? path.resolve(baseDir) : path.dirname(configPath)
const candidates = [ const candidates = [
configDir, configDir,
process.cwd(), process.cwd(),
+2 -1
View File
@@ -6,6 +6,7 @@ const path = require('path')
// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`, // 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`,
// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。 // 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。
// 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。 // 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。
// 配置与规则目录(userpaths.js)用同一个基座,这里导出给它复用。
const LOCAL_BASE = 'ScoreboardConsole' const LOCAL_BASE = 'ScoreboardConsole'
const DATA_SUBDIR = 'data' const DATA_SUBDIR = 'data'
@@ -109,4 +110,4 @@ function migrateLegacyData({ targetDir, legacyDir, log }) {
return { migrated: true, reason: 'moved' } return { migrated: true, reason: 'moved' }
} }
module.exports = { resolveDataDir, migrateLegacyData, isInside } module.exports = { resolveDataDir, migrateLegacyData, isInside, LOCAL_BASE }
+41 -10
View File
@@ -14,6 +14,7 @@ const {
const { loadConfig, updateConfig, resolveExecutable } = require('./config') const { loadConfig, updateConfig, resolveExecutable } = require('./config')
const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir') const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir')
const { resolveUserRoot, planUserPaths, prepareUserPaths } = require('./userpaths')
const { LogBus } = require('./log-bus') const { LogBus } = require('./log-bus')
const { ServiceSupervisor, healthHost } = require('./service') const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays') const { DisplayManager } = require('./displays')
@@ -43,21 +44,29 @@ function main() {
let exePath = null let exePath = null
// 解析后的数据目录信息(见 datadir.js),拉起服务端和 passwd 都用它 // 解析后的数据目录信息(见 datadir.js),拉起服务端和 passwd 都用它
let dataDirInfo = null let dataDirInfo = null
// 解析后的用户配置/规则路径(见 userpaths.js):console\config.json、config.yaml、rules\
let userPaths = null
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表 // 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
let instancesTimer = null let instancesTimer = null
let instancesLastReason = null let instancesLastReason = null
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串 // passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
let accountBusy = false let accountBusy = false
// 部署包根目录:打包后 = exe 所在目录(安装目录),开发态 = 仓库的 console\。
// 注意别和下面数据目录用的 installDir 混用:那个取的是**服务端 exe** 所在目录,
// 开发态两者可能不是同一个地方。
function appDir() {
return app.isPackaged ? path.dirname(app.getPath('exe')) : path.join(__dirname, '..')
}
// Console 自己的 config.json:打包后在 <用户根>\console\config.json(配置/规则已经搬出安装
// 目录,覆盖安装不会丢),开发态仍是仓库里的 console\config.json。
// SCOREBOARD_CONSOLE_CONFIG 优先级最高,方便本机临时换一份配置跑。
function resolveConfigPath() { function resolveConfigPath() {
if (process.env.SCOREBOARD_CONSOLE_CONFIG) { if (process.env.SCOREBOARD_CONSOLE_CONFIG) {
return path.resolve(process.env.SCOREBOARD_CONSOLE_CONFIG) return path.resolve(process.env.SCOREBOARD_CONSOLE_CONFIG)
} }
// 打包后 config.json 放在 exe 旁边,方便现场直接改,不藏在 asar 里 return userPaths ? userPaths.consoleConfig : path.join(__dirname, '..', 'config.json')
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), 'config.json')
}
return path.join(__dirname, '..', 'config.json')
} }
// 开关显示以系统实际状态为准:打包后的 Windows 读登录项 enabled,其余回落配置值。 // 开关显示以系统实际状态为准:打包后的 Windows 读登录项 enabled,其余回落配置值。
@@ -424,8 +433,10 @@ function main() {
return { ok: true, openAtLogin: wanted } return { ok: true, openAtLogin: wanted }
} }
// 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号 // 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号;
// 打包态这份在用户数据目录里,Console 已经解析好了绝对路径(见 userpaths.js)
function passwdConfigArg() { function passwdConfigArg() {
if (userPaths && userPaths.serverConfig) return userPaths.serverConfig
const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : [] const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : []
const index = args.indexOf('-config') const index = args.indexOf('-config')
if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) { if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) {
@@ -456,7 +467,7 @@ function main() {
validateAccountField(password, '新密码', false) validateAccountField(password, '新密码', false)
if (invalid) return { ok: false, error: invalid } if (invalid) return { ok: false, error: invalid }
const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path).path const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path, appDir()).path
if (!exe || !fs.existsSync(exe)) { if (!exe || !fs.existsSync(exe)) {
return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` } return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` }
} }
@@ -561,17 +572,36 @@ function main() {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
const bundleDir = appDir()
// 配置与规则跟数据目录一样搬出安装目录(见 userpaths.js)。这一步在加载配置之前做:
// Console 自己的 config.json 也要先落到用户目录。失败只记日志,攒着等日志器建好再写。
const setupNotes = []
userPaths = prepareUserPaths({
plan: planUserPaths({
installDir: bundleDir,
userRoot: resolveUserRoot(process.env),
isPackaged: app.isPackaged
}),
log: (message) => setupNotes.push(message)
})
cfg = loadConfig(resolveConfigPath()) cfg = loadConfig(resolveConfigPath())
log = new LogBus({ log = new LogBus({
maxLines: cfg.values.log.maxLines, maxLines: cfg.values.log.maxLines,
file: path.resolve(path.dirname(cfg.path), cfg.values.log.file || 'logs/console.log'), // 日志固定在部署包根目录的 logs\ 下(log.file 相对它解析):配置文件搬走了,
// 但日志是易失的排查材料,位置保持原样,现场习惯和文档都不用改
file: path.resolve(bundleDir, cfg.values.log.file || 'logs/console.log'),
maxFileBytes: cfg.values.log.maxFileBytes maxFileBytes: cfg.values.log.maxFileBytes
}) })
log.push('console', `Scoreboard Console v${app.getVersion()} 启动(配置:${cfg.path})`) log.push('console', `Scoreboard Console v${app.getVersion()} 启动(配置:${cfg.path})`)
for (const note of setupNotes) log.push('console', note)
if (userPaths.mode === 'user') {
log.push('console', `用户数据目录:${userPaths.root}(Console 配置、服务端 config.yaml、rules 都在这里,覆盖安装不会丢)`)
}
if (cfg.warning) log.push('console', cfg.warning) if (cfg.warning) log.push('console', cfg.warning)
const resolved = resolveExecutable(cfg.values.server.exe, cfg.path) const resolved = resolveExecutable(cfg.values.server.exe, cfg.path, bundleDir)
exePath = resolved.path exePath = resolved.path
log.push('console', `服务端程序:${resolved.path}`) log.push('console', `服务端程序:${resolved.path}`)
@@ -604,7 +634,8 @@ function main() {
values: cfg.values, values: cfg.values,
exePath: resolved.path, exePath: resolved.path,
log, log,
dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null,
configPath: userPaths.serverConfig
}) })
displays = new DisplayManager({ displays = new DisplayManager({
+11 -3
View File
@@ -7,6 +7,8 @@ const path = require('path')
const { EventEmitter } = require('events') const { EventEmitter } = require('events')
const { spawn } = require('child_process') const { spawn } = require('child_process')
const { withConfigArg } = require('./userpaths')
const STATE_LABEL = { const STATE_LABEL = {
stopped: '已停止', stopped: '已停止',
starting: '启动中', starting: '启动中',
@@ -37,7 +39,7 @@ function healthHost(server) {
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。 // 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。 // 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter { class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log, dataDir }) { constructor({ values, exePath, log, dataDir, configPath }) {
super() super()
this.values = values this.values = values
this.server = values.server this.server = values.server
@@ -45,6 +47,8 @@ class ServiceSupervisor extends EventEmitter {
this.log = log this.log = log
// 绝对路径,非空时下发给服务端 -data;为空表示沿用服务端默认数据目录 // 绝对路径,非空时下发给服务端 -data;为空表示沿用服务端默认数据目录
this.dataDir = dataDir || null this.dataDir = dataDir || null
// 绝对路径,非空时覆盖 args 里的 -config;为空表示沿用 args 原值(开发态)
this.configPath = configPath || null
this.child = null this.child = null
this.state = 'stopped' this.state = 'stopped'
@@ -113,9 +117,13 @@ class ServiceSupervisor extends EventEmitter {
} }
const args = Array.isArray(this.server.args) ? this.server.args : [] const args = Array.isArray(this.server.args) ? this.server.args : []
// -config 换成 Console 解析出的那份:打包态在用户数据目录里,
// 安装目录里那份只是随包的模板(会被卸载器删掉)
const withConfig = withConfigArg(args, this.configPath)
// 显式下发数据目录:默认位置在安装目录之外,覆盖安装时才不会被卸载器连目录删掉 // 显式下发数据目录:默认位置在安装目录之外,覆盖安装时才不会被卸载器连目录删掉
const spawnArgs = this.dataDir ? [...args, '-data', this.dataDir] : args const spawnArgs = this.dataDir ? [...withConfig, '-data', this.dataDir] : withConfig
// 工作目录取 exe 所在目录:部署包里 config.yaml / rules/ 都跟 exe 平级 // 工作目录取 exe 所在目录(部署包根目录);config.yaml 与数据目录都走绝对路径,
// 不再依赖 cwd
const cwd = path.dirname(this.exePath) const cwd = path.dirname(this.exePath)
this.lastError = null this.lastError = null
+169
View File
@@ -0,0 +1,169 @@
'use strict'
const fs = require('fs')
const path = require('path')
const { LOCAL_BASE } = require('./datadir')
// 这三样以前随安装包落在安装目录里,但都属于「用户改出来的东西」,必须跟数据目录一样
// 挪到安装目录之外:卸载器结尾是 `RMDir /r $INSTDIR`,覆盖安装又会先静默跑一遍旧卸载器,
// 放在安装目录里等于用户改一次丢一次。
// console\config.json → <用户根>\console\config.json
// config.yaml → <用户根>\config.yaml
// rules\ → <用户根>\rules\
// 用户根与数据目录同一基座:%LOCALAPPDATA%\ScoreboardConsole\
const CONSOLE_SUBDIR = 'console'
const CONSOLE_CONFIG_FILE = 'config.json'
const SERVER_CONFIG_FILE = 'config.yaml'
const RULES_SUBDIR = 'rules'
const ITEM_NAMES = ['consoleConfig', 'serverConfig', 'rulesDir']
const ITEM_LABEL = {
consoleConfig: 'Console 配置文件',
serverConfig: '服务端配置文件',
rulesDir: '规则目录'
}
// 用户根目录;拿不到 %LOCALAPPDATA% 时返回 null,由调用方回落到安装目录
function resolveUserRoot(env) {
const localAppData = env ? env.LOCALAPPDATA : ''
const value = typeof localAppData === 'string' ? localAppData.trim() : ''
return value ? path.join(value, LOCAL_BASE) : null
}
// 解析落点,纯函数不碰磁盘。mode='user' 才接管到用户目录;
// 其余情况(开发态、拿不到 %LOCALAPPDATA%)三项一律用安装目录里随包的那份,
// 行为与以前完全一致 —— 开发态不能动仓库里的文件。
function planUserPaths({ installDir, userRoot, isPackaged }) {
const install = path.resolve(installDir || '.')
const templates = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
serverConfig: path.join(install, SERVER_CONFIG_FILE),
rulesDir: path.join(install, RULES_SUBDIR)
}
const items = {}
const targets = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
serverConfig: path.join(install, SERVER_CONFIG_FILE),
rulesDir: path.join(install, RULES_SUBDIR)
}
const reason = !isPackaged ? 'dev' : userRoot ? null : 'no-localappdata'
if (!reason) {
targets.consoleConfig = path.join(userRoot, CONSOLE_SUBDIR, CONSOLE_CONFIG_FILE)
targets.serverConfig = path.join(userRoot, SERVER_CONFIG_FILE)
targets.rulesDir = path.join(userRoot, RULES_SUBDIR)
}
for (const name of ITEM_NAMES) {
items[name] = { target: path.resolve(targets[name]), template: path.resolve(templates[name]) }
}
return {
mode: reason ? 'install' : 'user',
reason,
root: userRoot ? path.resolve(userRoot) : null,
installDir: install,
items
}
}
// 目标目录必须真的能写:mkdir 只保证目录建出来,权限不足或路径被占要在这一步就暴露,
// 好让调用方回落到安装目录,而不是等到写配置文件时才失败
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
}
function tryRemove(dir) {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// 清不掉就留着:只是本次拷贝留下的临时目录,下次会原地重建
}
}
// 规则目录先拷到临时目录再整体改名到位:中途失败不会留下半个 rules\,
// 否则用户目录里一旦存在同名目录,下次启动就按「已存在」跳过、再也不补拷
function copyDirOnce(template, target) {
const tmp = `${target}.tmp-${process.pid}`
try {
tryRemove(tmp)
fs.cpSync(template, tmp, { recursive: true })
fs.renameSync(tmp, target)
} catch (err) {
tryRemove(tmp)
throw err
}
}
// 按 plan 落盘,返回最终生效的路径。判定只有两条:
// 目标不存在 + 安装目录里有 → 复制过去(安装目录那份留作模板,反正卸载器会删);
// 目标已存在 → 一律用用户目录那份,绝不覆盖用户改过的东西。
// 任一目录建不出来 / 拷不动都只记日志,该项继续用安装目录里的那份,绝不删用户文件、
// 也绝不因此启动失败。
function prepareUserPaths({ plan, log }) {
const notify = typeof log === 'function' ? log : () => {}
const result = {
mode: plan.mode,
reason: plan.reason,
root: plan.mode === 'user' ? plan.root : null,
consoleConfig: plan.items.consoleConfig.template,
rulesDir: plan.items.rulesDir.template,
// null = 不接管:拉起服务端与 passwd 时沿用 config.json 里 -config 的原值
serverConfig: null
}
if (plan.mode !== 'user') {
if (plan.reason === 'no-localappdata') {
notify(
'警告:拿不到 %LOCALAPPDATA%,config.json / config.yaml / rules 继续用安装目录里的那份,' +
'覆盖安装会连同它们一起丢失'
)
}
return result
}
for (const name of ITEM_NAMES) {
const { target, template } = plan.items[name]
const label = ITEM_LABEL[name]
if (path.resolve(target) === path.resolve(template)) continue
try {
ensureDir(path.dirname(target))
if (!fs.existsSync(target)) {
if (!fs.existsSync(template)) {
notify(`警告:${label}在用户目录与安装目录里都不存在(${template}),本次跳过复制`)
} else {
if (name === 'rulesDir') copyDirOnce(template, target)
else fs.copyFileSync(template, target)
notify(`已把 ${label} 复制到 ${target}(安装目录里那份留作模板)`)
}
}
} catch (err) {
notify(
`警告:准备 ${label} 失败(${err.message}),继续用安装目录里的 ${template};` +
'覆盖安装会丢失对它的改动'
)
continue
}
if (name === 'serverConfig') result.serverConfig = target
else result[name] = target
}
return result
}
// 服务端读哪份 config.yaml 由 Console 定:config.json 里的 server.args 只声明「有个 -config 参数」,
// 它的值在拉起时换成上面解析出的绝对路径(不传 configPath 时原样返回,开发态就走这条路)
function withConfigArg(args, configPath) {
const list = Array.isArray(args) ? [...args] : []
if (typeof configPath !== 'string' || configPath === '') return list
const index = list.indexOf('-config')
if (index !== -1 && typeof list[index + 1] === 'string') list[index + 1] = configPath
else list.push('-config', configPath)
return list
}
module.exports = { resolveUserRoot, planUserPaths, prepareUserPaths, withConfigArg }
+199
View File
@@ -0,0 +1,199 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
resolveUserRoot,
planUserPaths,
prepareUserPaths,
withConfigArg
} = require('./userpaths')
// 用绝对路径字符串拼测试目录,避免依赖运行平台的盘符写法
const INSTALL = path.resolve('/scoreboard-install')
const LOCAL = path.resolve('/localappdata')
const USER_ROOT = path.join(LOCAL, 'ScoreboardConsole')
function makeRoot(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-userpaths-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
return root
}
function writeFile(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
function readFile(file) {
return fs.readFileSync(file, 'utf8')
}
// 打包态 + 有 %LOCALAPPDATA% 是「接管」的唯一组合
function planFor({ installDir = INSTALL, env = { LOCALAPPDATA: LOCAL }, isPackaged = true } = {}) {
return planUserPaths({ installDir, userRoot: resolveUserRoot(env), isPackaged })
}
test('打包态三项都落到用户根下,与数据目录同一基座', () => {
const plan = planFor()
assert.equal(plan.mode, 'user')
assert.equal(plan.root, USER_ROOT)
assert.equal(plan.items.consoleConfig.target, path.join(USER_ROOT, 'console', 'config.json'))
assert.equal(plan.items.serverConfig.target, path.join(USER_ROOT, 'config.yaml'))
assert.equal(plan.items.rulesDir.target, path.join(USER_ROOT, 'rules'))
})
test('开发态不接管,三项都指向安装目录里随包的那份', () => {
const plan = planFor({ isPackaged: false })
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'dev')
assert.equal(plan.items.consoleConfig.target, path.join(INSTALL, 'config.json'))
assert.equal(plan.items.serverConfig.target, path.join(INSTALL, 'config.yaml'))
assert.equal(plan.items.rulesDir.target, path.join(INSTALL, 'rules'))
})
test('拿不到 %LOCALAPPDATA% 时回落安装目录并记日志', () => {
const plan = planFor({ env: {} })
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'no-localappdata')
const lines = []
const paths = prepareUserPaths({ plan, log: (line) => lines.push(line) })
assert.equal(paths.consoleConfig, path.join(INSTALL, 'config.json'))
assert.equal(paths.rulesDir, path.join(INSTALL, 'rules'))
assert.equal(paths.serverConfig, null)
assert.match(lines.join('\n'), /拿不到 %LOCALAPPDATA%/)
})
test('首次运行把随包的三项复制过去,安装目录那份留作模板', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{"server":{"port":19541}}')
writeFile(path.join(installDir, 'config.yaml'), 'server:\n port: 19541\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'format: scoreboard-rules\n')
writeFile(path.join(installDir, 'rules', 'README.md'), '说明\n')
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
assert.equal(paths.consoleConfig, path.join(userRoot, 'console', 'config.json'))
assert.equal(paths.serverConfig, path.join(userRoot, 'config.yaml'))
assert.equal(paths.rulesDir, path.join(userRoot, 'rules'))
assert.equal(readFile(paths.consoleConfig), '{"server":{"port":19541}}')
assert.equal(readFile(paths.serverConfig), 'server:\n port: 19541\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'cup.yaml')), 'format: scoreboard-rules\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'README.md')), '说明\n')
// 复制不是移动:安装目录那三份必须原样留着当模板
assert.equal(readFile(path.join(installDir, 'config.json')), '{"server":{"port":19541}}')
assert.equal(readFile(path.join(installDir, 'config.yaml')), 'server:\n port: 19541\n')
assert.equal(fs.existsSync(path.join(installDir, 'rules', 'cup.yaml')), true)
assert.equal(lines.filter((line) => line.startsWith('已把 ')).length, 3)
})
test('用户目录已有那份就一律用它,不覆盖也不重拷', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{"server":{"port":1111}}')
writeFile(path.join(installDir, 'config.yaml'), 'server:\n port: 1111\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'template\n')
writeFile(path.join(userRoot, 'console', 'config.json'), '{"server":{"port":2222}}')
writeFile(path.join(userRoot, 'config.yaml'), 'server:\n port: 2222\n')
writeFile(path.join(userRoot, 'rules', 'mine.yaml'), 'mine\n')
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
assert.equal(readFile(paths.consoleConfig), '{"server":{"port":2222}}')
assert.equal(readFile(paths.serverConfig), 'server:\n port: 2222\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'mine.yaml')), 'mine\n')
// 用户目录里的 rules\ 已存在(哪怕内容和模板不同)→ 不补拷模板里的 cup.yaml
assert.equal(fs.existsSync(path.join(userRoot, 'rules', 'cup.yaml')), false)
assert.equal(lines.filter((line) => line.startsWith('已把 ')).length, 0)
})
test('用户目录写不进去时记日志并回落到安装目录,不抛异常', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
writeFile(path.join(installDir, 'config.json'), '{}')
writeFile(path.join(installDir, 'config.yaml'), 'server: {}\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'template\n')
// 用户根的位置被一个文件占着:mkdir/rename 必然失败(ENOTDIR)
const userRoot = path.join(root, 'blocker')
writeFile(userRoot, 'not a dir')
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
assert.equal(paths.consoleConfig, path.join(installDir, 'config.json'))
assert.equal(paths.serverConfig, null)
assert.equal(paths.rulesDir, path.join(installDir, 'rules'))
assert.equal(readFile(paths.consoleConfig), '{}')
assert.equal(readFile(path.join(installDir, 'rules', 'cup.yaml')), 'template\n')
assert.equal(lines.filter((line) => line.includes('继续用安装目录里的')).length, 3)
})
test('两边都没有的那一份跳过复制,路径仍指向用户目录', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
fs.mkdirSync(installDir, { recursive: true })
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
// 不接管就没人去读用户目录,服务端会按 config.Load 的默认值跑
assert.equal(paths.serverConfig, path.join(userRoot, 'config.yaml'))
assert.equal(fs.existsSync(paths.serverConfig), false)
assert.equal(lines.filter((line) => line.includes('本次跳过复制')).length, 3)
})
test('规则目录拷贝到位后不留临时目录,重复运行不重做', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{}\n')
writeFile(path.join(installDir, 'config.yaml'), 'server: {}\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'template\n')
const plan = planUserPaths({ installDir, userRoot, isPackaged: true })
const first = prepareUserPaths({ plan, log: () => {} })
assert.deepEqual(fs.readdirSync(userRoot).sort(), ['config.yaml', 'console', 'rules'])
assert.deepEqual(fs.readdirSync(first.rulesDir), ['cup.yaml'])
// 第二次启动:目标都在了,不再复制(用户改过的内容不会被模板顶掉)
writeFile(path.join(userRoot, 'config.yaml'), 'server:\n port: 3333\n')
const second = prepareUserPaths({ plan, log: () => {} })
assert.deepEqual(second, first)
assert.equal(readFile(path.join(userRoot, 'config.yaml')), 'server:\n port: 3333\n')
})
test('-config 的值换成解析出的绝对路径,其余参数原样保留', () => {
assert.deepEqual(withConfigArg(['-config', 'config.yaml'], '/u/config.yaml'), ['-config', '/u/config.yaml'])
assert.deepEqual(
withConfigArg(['-config', 'config.yaml', '-project', 'cup'], '/u/config.yaml'),
['-config', '/u/config.yaml', '-project', 'cup']
)
// 参数里没写 -config 就补一条,否则用户目录里那份永远不会被读到
assert.deepEqual(withConfigArg(['-project', 'cup'], '/u/config.yaml'), ['-project', 'cup', '-config', '/u/config.yaml'])
// 不接管(开发态)原样返回
assert.deepEqual(withConfigArg(['-config', 'config.yaml'], null), ['-config', 'config.yaml'])
assert.deepEqual(withConfigArg(null, '/u/config.yaml'), ['-config', '/u/config.yaml'])
})
+10 -4
View File
@@ -28,6 +28,11 @@ set "ROOT=%CD%"
REM Same data dir the Console hands to the server via -data. REM Same data dir the Console hands to the server via -data.
set "DATA=%ROOT%\data" set "DATA=%ROOT%\data"
if defined LOCALAPPDATA if exist "%LOCALAPPDATA%\ScoreboardConsole\data" set "DATA=%LOCALAPPDATA%\ScoreboardConsole\data" if defined LOCALAPPDATA if exist "%LOCALAPPDATA%\ScoreboardConsole\data" set "DATA=%LOCALAPPDATA%\ScoreboardConsole\data"
REM Same config.yaml the Console hands to the server via -config: it lives in the
REM per-user dir now, and the copy next to this script is only the shipped template
REM (the uninstaller deletes the install dir, so edits there are lost).
set "CONFIG=%ROOT%\config.yaml"
if defined LOCALAPPDATA if exist "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" set "CONFIG=%LOCALAPPDATA%\ScoreboardConsole\config.yaml"
set "REASON=manual" set "REASON=manual"
if not "%~1"=="" set "REASON=%~1" if not "%~1"=="" set "REASON=%~1"
@@ -57,13 +62,14 @@ if not defined EXE (
REM config.yaml is still needed for port / backup settings; -data below picks the REM config.yaml is still needed for port / backup settings; -data below picks the
REM data dir explicitly, so the current directory no longer decides it. REM data dir explicitly, so the current directory no longer decides it.
if not exist "%ROOT%\config.yaml" ( if not exist "%CONFIG%" (
echo [ERROR] %ROOT%\config.yaml not found. echo [ERROR] config.yaml not found: %CONFIG%
exit /b 1 exit /b 1
) )
echo === Scoreboard backup === echo === Scoreboard backup ===
echo Data dir : %DATA% echo Data dir : %DATA%
echo Config : %CONFIG%
echo Server : %EXE% echo Server : %EXE%
echo Reason : %REASON% echo Reason : %REASON%
echo. echo.
@@ -77,12 +83,12 @@ set "FOUND="
for /d %%D in ("%DATA%\event_projs\*") do ( for /d %%D in ("%DATA%\event_projs\*") do (
set "FOUND=1" set "FOUND=1"
echo backup event %%~nxD echo backup event %%~nxD
"%EXE%" backup -config config.yaml -project "%%~nxD" -data "%DATA%" -reason "%REASON%" "%EXE%" backup -config "%CONFIG%" -project "%%~nxD" -data "%DATA%" -reason "%REASON%"
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1) if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
) )
if not defined FOUND ( if not defined FOUND (
echo backup default event echo backup default event
"%EXE%" backup -config config.yaml -data "%DATA%" -reason "%REASON%" "%EXE%" backup -config "%CONFIG%" -data "%DATA%" -reason "%REASON%"
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1) if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
) )
+5 -4
View File
@@ -16,7 +16,7 @@
- [ ] Windows 10/11 x64,接市电(建议接 UPS) - [ ] Windows 10/11 x64,接市电(建议接 UPS)
- [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540` - [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540`
- [ ] 数据目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙 - [ ] 数据/配置目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\`(数据在 `data\`,配置与规则是 `console\config.json`、`config.yaml`、`rules\`;都在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙
- [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」) - [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」)
- [ ] **安装目录不要选 `C:\Program Files`**(那里普通用户没写权限,日志会写不进去);装到 D 盘等普通目录即可,向导里可以改路径 - [ ] **安装目录不要选 `C:\Program Files`**(那里普通用户没写权限,日志会写不进去);装到 D 盘等普通目录即可,向导里可以改路径
@@ -25,6 +25,7 @@
- [ ] 双击 `Scoreboard_Console.exe`,启动管理窗口出现 - [ ] 双击 `Scoreboard_Console.exe`,启动管理窗口出现
- [ ] 服务端状态变为「运行中 · 就绪」 - [ ] 服务端状态变为「运行中 · 就绪」
- [ ] 「运行日志」里 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(**不是**安装目录),且没有「都有数据 / 迁移失败」的警告 - [ ] 「运行日志」里 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(**不是**安装目录),且没有「都有数据 / 迁移失败」的警告
- [ ] 「运行日志」里 `用户数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole`,且没有「准备 … 失败」的警告(配置与规则也在那儿,见部署指南 3.5)
- [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录 - [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录
- [ ] **管理员初始密码**在 `logs\console.log` 里搜 `INITIAL ADMIN CREATED`,密码就在该行上方;也可以直接打开 Console 的「运行日志」面板往上翻。首次登录会要求改密 - [ ] **管理员初始密码**在 `logs\console.log` 里搜 `INITIAL ADMIN CREATED`,密码就在该行上方;也可以直接打开 Console 的「运行日志」面板往上翻。首次登录会要求改密
@@ -32,7 +33,7 @@
- [ ] 「系统设置 → 赛事信息」填名称、副标题、主办方、会标、主题色 - [ ] 「系统设置 → 赛事信息」填名称、副标题、主办方、会标、主题色
- [ ] 「大屏展示」选背景图、标题、轮播间隔 - [ ] 「大屏展示」选背景图、标题、轮播间隔
- [ ] **导入规则包**:`rules\2026-驭电利刃杯军事运动会.yaml`(安装目录下的 `rules\` 里) - [ ] **导入规则包**:`%LOCALAPPDATA%\ScoreboardConsole\rules\2026-驭电利刃杯军事运动会.yaml`(规则目录在用户数据目录里,见部署指南 3.5)
—— 服务端不会自动加载规则包,必须导入一次 —— 服务端不会自动加载规则包,必须导入一次
- [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分 - [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分
- [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分 - [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分
@@ -62,9 +63,9 @@
| 故障 | 动作 | | 故障 | 动作 |
| --- | --- | | --- | --- |
| 服务端无响应 | 服务管理窗口「重启服务」 | | 服务端无响应 | 服务管理窗口「重启服务」 |
| 主机死机 | 备机启动同一份部署包,把数据目录(`%LOCALAPPDATA%\ScoreboardConsole\data`)拷回去 | | 主机死机 | 备机启动同一份部署包,把用户数据目录(`%LOCALAPPDATA%\ScoreboardConsole\`,含数据、配置与规则)拷回去 |
| 数据库损坏 | 停机,用数据目录下 `backups\` 的最新备份替换 `event_projs\<项目名>\event.db`(先删 `-wal`/`-shm`),详见部署指南第 8.3 节 | | 数据库损坏 | 停机,用数据目录下 `backups\` 的最新备份替换 `event_projs\<项目名>\event.db`(先删 `-wal`/`-shm`),详见部署指南第 8.3 节 |
| 大屏黑屏 | 服务管理窗口「重新排布」;仍不行就用备用笔记本开同一地址 | | 大屏黑屏 | 服务管理窗口「重新排布」;仍不行就用备用笔记本开同一地址 |
| 覆盖安装后数据不见了 | 看「运行日志」里的 `数据目录:` 与迁移警告,按部署指南第 9.9 节处理 | | 覆盖安装后数据/配置不见了 | 看「运行日志」里的 `数据目录:`、`用户数据目录:` 与迁移警告,按部署指南第 9.9 节处理 |
> 完整恢复步骤见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。 > 完整恢复步骤见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
+3 -1
View File
@@ -378,9 +378,11 @@
### 11.3 规则包 ### 11.3 规则包
> 规则包文件放在 `%LOCALAPPDATA%\ScoreboardConsole\rules\`(安装包里附带的那份会在首次启动时复制过去;见部署指南 3.5)。
- **导出 rules.yaml**:导出积分模板、并列规则、破纪录加分、计分通道与项目覆盖; - **导出 rules.yaml**:导出积分模板、并列规则、破纪录加分、计分通道与项目覆盖;
- **导入**(仅管理员): - **导入**(仅管理员):
1. 选择 `.yaml` / `.yml` 文件; 1. 选择 `.yaml` / `.yml` 文件(就是上面那个 `rules\` 目录里的);
2. 点「预览差异」,查看格式是否兼容、影响多少个小项、逐条改动; 2. 点「预览差异」,查看格式是否兼容、影响多少个小项、逐条改动;
3. 确认无误后点「确认导入并全量重算」,服务端会按新规则重算全部已发布积分。 3. 确认无误后点「确认导入并全量重算」,服务端会按新规则重算全部已发布积分。
+5 -3
View File
@@ -10,7 +10,9 @@
首次启动时随机生成、只打印一次。用 Console 启动时,在 `logs\console.log` 里搜索「初始密码」即可找到,方法见 [部署指南 · 首次登录](./部署指南.md#5-首次登录)。 首次启动时随机生成、只打印一次。用 Console 启动时,在 `logs\console.log` 里搜索「初始密码」即可找到,方法见 [部署指南 · 首次登录](./部署指南.md#5-首次登录)。
**Q2:忘记密码怎么办?** **Q2:忘记密码怎么办?**
先停止服务,然后在部署目录执行 `scoreboard.exe passwd -config config.yaml`,会打印新的随机密码,并要求下次登录强制改密。 先停止服务,然后执行(配置在用户数据目录,不要用部署目录里那份模板):
`scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"`
会打印新的随机密码,并要求下次登录强制改密。
**Q3:访客和录入员有什么区别?** **Q3:访客和录入员有什么区别?**
访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。 访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。
@@ -88,7 +90,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
`data\backups\`,文件名形如 `system-<原因>-<时间戳>.db`、`<项目名>-<原因>-<时间戳>.db`(原因有 `auto` / `manual` / `publish`)。 `data\backups\`,文件名形如 `system-<原因>-<时间戳>.db`、`<项目名>-<原因>-<时间戳>.db`(原因有 `auto` / `manual` / `publish`)。
**Q24:多久备份一次?** **Q24:多久备份一次?**
服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config config.yaml`。另外每次发布成绩前会自动备一份。 服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"`(部署目录下的 `deploy\backup.bat` 已经带好这些路径)。另外每次发布成绩前会自动备一份。
**Q25:备份会自动删除旧的么?** **Q25:备份会自动删除旧的么?**
会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。 会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。
@@ -156,4 +158,4 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。
**Q40:数据目录在哪?** **Q40:数据目录在哪?**
由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级/卸载都不会删)。想换位置就改 `config.yaml` 的 `dataDir`(支持绝对路径),那份 `config.yaml` 本身也在 `%LOCALAPPDATA%\ScoreboardConsole\` 下。
+82 -34
View File
@@ -13,7 +13,7 @@
| 网络 | 完全离线即可运行。若需要手机/平板/电视访问,用独立交换机或路由器组成局域网 | | 网络 | 完全离线即可运行。若需要手机/平板/电视访问,用独立交换机或路由器组成局域网 |
| 磁盘 | 建议预留 ≥ 10GB | | 磁盘 | 建议预留 ≥ 10GB |
| 运行时 | 无需安装 Go / Node / 数据库等任何运行时,前端已内嵌进可执行文件 | | 运行时 | 无需安装 Go / Node / 数据库等任何运行时,前端已内嵌进可执行文件 |
| 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\data` 下建库、在部署目录的 `logs\` 写日志,请确保这两处可写 | | 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\` 下建数据目录并写入配置/规则(见 3.5),在部署目录的 `logs\` 写日志,请确保这两处可写 |
> 服务端只构建 Windows amd64 一个版本。Linux / 其他平台不在交付范围内。 > 服务端只构建 Windows amd64 一个版本。Linux / 其他平台不在交付范围内。
@@ -51,7 +51,7 @@ deploy\build.bat
3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。 3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。
4. 双击「赛事积分榜」启动。 4. 双击「赛事积分榜」启动。
> 赛事数据存放在 `%LOCALAPPDATA%\ScoreboardConsole\data`(不随安装目录走,见第 8.1 节),日志写在安装目录的 `logs\` 下;安装目录本身只要当前用户可写即可。 > 赛事数据、配置与规则都存放在 `%LOCALAPPDATA%\ScoreboardConsole\`(不随安装目录走,见 3.4、3.5),日志写在安装目录的 `logs\` 下;安装目录本身只要当前用户可写即可。
### 3.2 使用免安装包 ### 3.2 使用免安装包
@@ -73,17 +73,22 @@ deploy\build.bat
安装目录\ 安装目录\
├── Scoreboard_Console.exe # 桌面宿主,双击启动 ├── Scoreboard_Console.exe # 桌面宿主,双击启动
├── resources\app.asar # Console 自身资源 ├── resources\app.asar # Console 自身资源
├── config.json # Console 配置(可编辑)
├── scoreboard.exe # Go 服务端 ├── scoreboard.exe # Go 服务端
├── config.yaml # 服务端配置
├── scoreboard.png # 应用图标 ├── scoreboard.png # 应用图标
├── config.json # Console 配置模板(首次启动复制到用户配置目录)
├── config.yaml # 服务端配置模板(同上)
├── rules\ # 规则包模板(同上)
└── logs\console.log # 首次运行生成:运行日志 └── logs\console.log # 首次运行生成:运行日志
``` ```
数据目录不在安装目录里,是独立的一处(详见 3.4): 配置、规则与数据**都不在安装目录里**,各是独立的一处(详见 3.4、3.5):
``` ```
%LOCALAPPDATA%\ScoreboardConsole\data\ # 数据库、备份、上传的图片 %LOCALAPPDATA%\ScoreboardConsole\ # 用户数据目录(与安装目录无关)
├── data\ # 数据库、备份、上传的图片
├── console\config.json # Console 自己的配置(实际生效的那份)
├── config.yaml # 服务端配置(实际生效的那份)
└── rules\ # 规则包(实际生效的那份)
``` ```
### 3.4 数据放在哪里(升级/卸载都不会删) ### 3.4 数据放在哪里(升级/卸载都不会删)
@@ -92,8 +97,8 @@ deploy\build.bat
| 场景 | 数据目录 | | 场景 | 数据目录 |
| --- | --- | | --- | --- |
| 默认(`console\config.json` 的 `server.dataDir` 留空) | `%LOCALAPPDATA%\ScoreboardConsole\data`,即 `C:\Users\<用户名>\AppData\Local\ScoreboardConsole\data` | | 默认(`console\config.json` 的 `server.dataDir` 留空,该文件见 3.5) | `%LOCALAPPDATA%\ScoreboardConsole\data`,即 `C:\Users\<用户名>\AppData\Local\ScoreboardConsole\data` |
| 想放到别处,例如 D 盘 | 在 `console\config.json` 里把 `server.dataDir` 改成 `"D:\\scoreboard-data"`(绝对路径),或相对安装目录的路径 | | 想放到别处,例如 D 盘 | 在 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` 里把 `server.dataDir` 改成 `"D:\\scoreboard-data"`(绝对路径),或相对安装目录的路径 |
| 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** | | 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** |
- Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行; - Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行;
@@ -104,6 +109,41 @@ deploy\build.bat
> 升级到本版本后第一次启动,请打开 Console 的「运行日志」确认是否有迁移记录,并核对管理端里的赛事/账号数据是否完整。 > 升级到本版本后第一次启动,请打开 Console 的「运行日志」确认是否有迁移记录,并核对管理端里的赛事/账号数据是否完整。
### 3.5 配置与规则放在哪里(升级/卸载也不会丢)
`config.json`、`config.yaml`、`rules\` 以前跟着安装包落在安装目录里,而**安装目录会被卸载器整个删掉**(覆盖安装会先静默跑一遍旧卸载器),所以用户改过的配置和规则包会一起没。本版本起它们也搬到**用户数据目录**(与数据目录同一处,安装目录里那三份只当模板):
| 内容 | 实际生效的位置 | 管什么 |
| --- | --- | --- |
| Console 配置 | `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` | 端口、选屏策略、开机自启等(见 6.2) |
| 服务端配置 | `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` | 监听地址/端口、备份间隔、日志级别(见 6.1) |
| 规则目录 | `%LOCALAPPDATA%\ScoreboardConsole\rules\` | 规则包 YAML;管理面板「导入规则包」时从这里选 |
**怎么改**:直接编辑上表里的文件。`config.json` 与 `config.yaml` 都是 Console 启动时读一次,改完要**退出并重启 Console**(托盘 → 退出);服务管理窗口的「配置文件」一行显示的就是 `console\config.json` 的实际路径,可用来确认。`rules\` 只是规则包存放目录,往里放文件不需要重启,但规则要在管理面板「系统设置 → 规则包 → 导入」里选文件导入后才生效(服务端不会自动加载)。安装目录里那三份是模板,改了不生效。
升级/覆盖安装时:
- **首次运行会复制**:用户目录里还没有、安装目录里有 → 原样复制一份过去,安装目录那份留着当模板;
- **已存在就绝不覆盖**:用户目录里已经有一份时一律用它,Console 既不覆盖也不删除,之后升级只换程序不换配置;
- Console 拉起服务端时传**绝对路径** `-config <用户目录>\config.yaml`,`passwd` 子命令用同一份(见 5.5),不会两边各读各的;
- 规则目录只在首次复制一次,之后你往里面放的规则包不会被模板顶掉;
- 某一处建不出来或不可写(权限、杀毒软件拦截等)时**只记日志并回落到安装目录里那份**,绝不因此启动失败;代价是那份改动仍会在覆盖安装时丢失,日志里会明确写出来;
- 开发运行(`pnpm start`)不接管这三项,仍用仓库里的 `console\config.json`、`config.yaml`、`rules\`;
- 不用 Console、直接双击 `scoreboard.exe` 时,服务端仍按工作目录下的 `config.yaml` 读配置,**不受这套保护**。
> 从老版本覆盖升级上来的第一次启动:安装目录里那三份在覆盖安装时**就已经被旧卸载器删过一遍**(新装进去的是新版模板),Console 只能把模板复制到用户目录,升级前改在安装目录里的配置需要重做一遍。**从本版本起**(也就是改动落在上表位置之后),配置与规则才会跨升级保留。
> 日志是例外:它留在安装目录的 `logs\console.log`(每次运行都会重写的排查材料,不值得搬),所以卸载后日志会消失,配置与规则不会。
首次启动后看 Console「运行日志」里这几行核对:
| 日志 | 含义 |
| --- | --- |
| `已把 Console 配置文件 复制到 …` | 三项各自的首选复制记录,各一条,属正常 |
| `用户数据目录:<路径>` | 本次实际使用的用户数据目录 |
| `警告:准备 … 失败…` | 该项回落到安装目录里那份,按日志里的路径手工复制过去再重启 Console |
| `警告:拿不到 %LOCALAPPDATA%…` | 系统环境异常,三项都还在安装目录里,覆盖安装会丢改动 |
--- ---
## 4. 服务管理窗口(Scoreboard Console) ## 4. 服务管理窗口(Scoreboard Console)
@@ -149,7 +189,7 @@ deploy\build.bat
| 大屏 | `http://<主机IP>:19540/display` | | 大屏 | `http://<主机IP>:19540/display` |
| 导航页 | `http://<主机IP>:19540/` | | 导航页 | `http://<主机IP>:19540/` |
> 端口默认 **19540**,以 `config.yaml` 为准。 > 端口默认 **19540**,以 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 为准(该文件在哪见 3.5)。
### 5.2 初始账号 ### 5.2 初始账号
@@ -185,13 +225,13 @@ findstr /C:"INITIAL ADMIN CREATED" logs\console.log
### 5.5 忘记密码 / 没记下初始密码 ### 5.5 忘记密码 / 没记下初始密码
在 Console 里先**停止服务**,然后在部署目录(`config.yaml` 所在目录)执行: 在 Console 里先**停止服务**,然后在安装目录(`scoreboard.exe` 所在目录)执行:
```bat ```bat
scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsole\data" scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"
``` ```
> **`-data` 不能省**:不加它服务端会去用工作目录下的 `data\`,改的是另一个库里的账号(那个库是空的,看起来像「密码重置成功但登录不上」)。数据目录改过配置的,把这里换成 `console\config.json` 里 `server.dataDir` 实际生效的那个路径;实在不确定,可以先看 `logs\console.log` 里 `数据目录:` 那一行。 > **`-config` 与 `-data` 都不能省**:`-config` 指到 Console 实际使用的用户配置(安装目录里那份只是模板,见 3.5);`-data` 不加的话服务端会去用工作目录下的 `data\`,改的是另一个库里的账号(那个库是空的,看起来像「密码重置成功但登录不上」)。数据目录改过配置的,把这里换成用户配置里 `server.dataDir` 实际生效的那个路径;实在不确定,可以先看 `logs\console.log` 里 `数据目录:` 那一行。
该命令会为用户 `admin` 生成一个新的随机密码并打印出来,同时要求下次登录强制改密。可选参数: 该命令会为用户 `admin` 生成一个新的随机密码并打印出来,同时要求下次登录强制改密。可选参数:
@@ -206,7 +246,9 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol
## 6. 端口与配置修改 ## 6. 端口与配置修改
### 6.1 服务端 `config.yaml` ### 6.1 服务端配置 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml`
> 路径说明见 3.5;安装目录里的 `config.yaml` 只是模板,改它不生效。
| 项 | 当前默认值 | 说明 | | 项 | 当前默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
@@ -218,12 +260,14 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol
| `backup.keep` | `20` | 每个库滚动保留的备份份数 | | `backup.keep` | `20` | 每个库滚动保留的备份份数 |
| `server.allowedOrigins` | `[]` | 额外放行的 WebSocket Origin 白名单;默认只放行同源与不带 Origin 的客户端 | | `server.allowedOrigins` | `[]` | 额外放行的 WebSocket Origin 白名单;默认只放行同源与不带 Origin 的客户端 |
### 6.2 Console `console\config.json` ### 6.2 Console 配置 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json`
> 路径说明见 3.5;安装目录里的 `config.json` 只是模板,改它不生效。服务管理窗口的「配置文件」一行显示的就是这份实际路径。
| 项 | 当前默认值 | 说明 | | 项 | 当前默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 服务端可执行文件;相对路径会在配置目录、当前目录、上级目录、`dist\` 依次查找 | | `server.exe` | `scoreboard.exe` | 服务端可执行文件;相对路径会在部署目录(安装目录)、当前目录、上级目录、`dist\` 依次查找 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的启动参数 | | `server.args` | `["-config","config.yaml"]` | 传给服务端的启动参数;其中 `-config` 的**值**会被换成 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的绝对路径,其余参数(如 `-project`)按原样传(见 3.5) |
| `server.dataDir` | `""` | 数据目录;留空 = 用默认位置 `%LOCALAPPDATA%\ScoreboardConsole\data`。填了就按它来:绝对路径直接用,相对路径以安装目录为基准。Console 会把它作为 `-data <绝对路径>` 追加到启动参数后(见第 3.4 节) | | `server.dataDir` | `""` | 数据目录;留空 = 用默认位置 `%LOCALAPPDATA%\ScoreboardConsole\data`。填了就按它来:绝对路径直接用,相对路径以安装目录为基准。Console 会把它作为 `-data <绝对路径>` 追加到启动参数后(见第 3.4 节) |
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查与打开管理面板用的地址;配成 `0.0.0.0` 时本机探测自动改走回环地址 | | `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查与打开管理面板用的地址;配成 `0.0.0.0` 时本机探测自动改走回环地址 |
| `server.autoStart` | `true` | Console 启动后是否自动拉起服务端 | | `server.autoStart` | `true` | Console 启动后是否自动拉起服务端 |
@@ -244,7 +288,7 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol
| `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制 | | `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制 |
| `admin.width` / `admin.height` | `1280` / `860` | 启动管理窗口初始尺寸(服务管理窗口与服务端管理面板共用) | | `admin.width` / `admin.height` | `1280` / `860` | 启动管理窗口初始尺寸(服务管理窗口与服务端管理面板共用) |
| `log.maxLines` | `2000` | 内存中保留的日志行数 | | `log.maxLines` | `2000` | 内存中保留的日志行数 |
| `log.file` | `logs/console.log` | 日志文件,相对 `config.json` 所在目录 | | `log.file` | `logs/console.log` | 日志文件,相对部署目录(安装目录)解析 —— 日志不随配置搬到用户目录 |
| `log.maxFileBytes` | `4194304` | 单个日志文件上限,超出后轮转为 `.1` | | `log.maxFileBytes` | `4194304` | 单个日志文件上限,超出后轮转为 `.1` |
> 配置缺项会自动使用内置默认值;JSON 语法错误时回落到全默认值,并在窗口顶部提示。 > 配置缺项会自动使用内置默认值;JSON 语法错误时回落到全默认值,并在窗口顶部提示。
@@ -254,8 +298,8 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol
端口必须**两处一致**: 端口必须**两处一致**:
1. 关闭 Console(托盘 → 退出); 1. 关闭 Console(托盘 → 退出);
2. 修改 `config.yaml` 的 `server.port`; 2. 修改 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的 `server.port`;
3. 修改 `console\config.json` 的 `server.port`(保持一致,否则 Console 健康检查和管理面板地址会失效); 3. 修改 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` 的 `server.port`(保持一致,否则 Console 健康检查和管理面板地址会失效);
4. 重新启动 Console; 4. 重新启动 Console;
5. 之后管理面板地址变为 `http://<主机IP>:<新端口>/admin`。 5. 之后管理面板地址变为 `http://<主机IP>:<新端口>/admin`。
@@ -334,14 +378,14 @@ http://<主机IP>:19540/display
| --- | --- | --- | | --- | --- | --- |
| 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.db` | | 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.db` |
| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` | | 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsole\data"` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` | | 命令行 | 在安装目录执行 `scoreboard.exe backup -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`) | | 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`) |
| 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` | | 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` |
- 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`; - 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`;
- 自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算,超出的旧备份自动删除; - 自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算,超出的旧备份自动删除;
- 同一时刻只允许一个备份在进行,自动备份与手动、发布前备份不会互相踩; - 同一时刻只允许一个备份在进行,自动备份与手动、发布前备份不会互相踩;
- `deploy\backup.bat` 会自动用和 Console 相同的数据目录(优先 `%LOCALAPPDATA%\ScoreboardConsole\data`,该目录还不存在时回退到脚本上级目录的 `data`),并把该目录作为 `-data` 传给服务端,所以从哪里运行都不会备份到别的库; - `deploy\backup.bat` 会自动用和 Console 相同的配置与数据目录(配置优先 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml`、数据优先 `%LOCALAPPDATA%\ScoreboardConsole\data`,即用户数据目录里那两份;都不存在时回退到脚本上级目录 `..\config.yaml` 与 `..\data`),并分别作为 `-config` / `-data` 传给服务端,所以从哪里运行都不会备份到别的库;
- 现场建议:开幕前、每次发布高峰前、每天结束时各手动备份一次(自动备份只是兜底)。 - 现场建议:开幕前、每次发布高峰前、每天结束时各手动备份一次(自动备份只是兜底)。
### 8.3 如何恢复 ### 8.3 如何恢复
@@ -360,11 +404,11 @@ http://<主机IP>:19540/display
### 8.4 整机迁移 ### 8.4 整机迁移
把 **安装目录**(`Scoreboard_Console.exe`、`config.json`、`config.yaml`、`rules\` 等)与**数据目录** `%LOCALAPPDATA%\ScoreboardConsole\data` 一起拷到另一台同版本 Windows 机器: 用户数据目录(`%LOCALAPPDATA%\ScoreboardConsole\`,里面是数据 + 配置 + 规则)与**安装目录**(`Scoreboard_Console.exe`、`scoreboard.exe` 等)一起拷到另一台同版本 Windows 机器:
- 安装目录:按同样路径放好,或直接用新机器上的安装包装一遍; - 用户数据目录:整个 `%LOCALAPPDATA%\ScoreboardConsole\` 拷过去即可(含 `data\`、`console\config.json`、`config.yaml`、`rules\`;同一用户名下路径一致,最省事)——这样配置、规则和赛事数据原样接管,无需重新配置;
- 数据目录:拷到新机器的 `%LOCALAPPDATA%\ScoreboardConsole\data`(同一用户名下路径一致,最省事); - 安装目录:按同样路径放好,或直接用新机器上的安装包装一遍(安装目录里那三份只是模板,拷不拷都行);
- 启动 Console 即可,无需重新配置;若数据目录放到了别处,记得同步改 `console\config.json` 的 `server.dataDir`。 - 启动 Console 即可;若数据目录放到了别处,记得同步改 `console\config.json` 的 `server.dataDir`(在用户数据目录里那份)。
### 8.5 赛事项目包导出 / 导入 ### 8.5 赛事项目包导出 / 导入
@@ -374,7 +418,7 @@ http://<主机IP>:19540/display
| 导入 | 「导出 → 赛事项目包 → 选择 .zip → 导入」 | **仅管理员**可操作;可填「重命名」,勾选「覆盖同名项目」会先二次确认 | | 导入 | 「导出 → 赛事项目包 → 选择 .zip → 导入」 | **仅管理员**可操作;可填「重命名」,勾选「覆盖同名项目」会先二次确认 |
- 导入后项目落在 `<数据目录>\event_projs\<项目名>\`; - 导入后项目落在 `<数据目录>\event_projs\<项目名>\`;
- 当前运行的服务端默认使用 `default` 项目。要让新导入的赛事生效,可在 Console 的 `config.json` 里把 `server.args` 改为 `["-config","config.yaml","-project","<项目名>"]`,然后重启 Console;也可以直接用 `scoreboard.exe -config config.yaml -project <项目名> -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 启动; - 当前运行的服务端默认使用 `default` 项目。要让新导入的赛事生效,可在 Console 的用户配置 `config.json` 里给 `server.args` 加 `"-project","<项目名>"`,然后重启 Console;也可以直接用 `scoreboard.exe -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -project <项目名> -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 启动;
- 导入期间请勿停止服务端进程。 - 导入期间请勿停止服务端进程。
### 8.6 手工备份 / 迁移整个数据目录 ### 8.6 手工备份 / 迁移整个数据目录
@@ -385,7 +429,7 @@ http://<主机IP>:19540/display
2. 把整个数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 复制到 U 盘或其他盘(里面含 `system.db`、`event_projs\`(数据库 + `uploads\` 图片)、`backups\`); 2. 把整个数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 复制到 U 盘或其他盘(里面含 `system.db`、`event_projs\`(数据库 + `uploads\` 图片)、`backups\`);
3. 恢复时把整个目录拷回去,然后: 3. 恢复时把整个目录拷回去,然后:
- 拷回**原路径**:直接启动 Console 即可; - 拷回**原路径**:直接启动 Console 即可;
- 拷到**别的路径**:先建好目标目录并放入数据,再在 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console; - 拷到**别的路径**:先建好目标目录并放入数据,再在用户配置 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console;
4. 恢复完成后在管理端核对赛事、账号、上传图片是否齐全。 4. 恢复完成后在管理端核对赛事、账号、上传图片是否齐全。
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标在 `event_projs\<项目名>\uploads\` 里,不拷这个目录会丢图片。 > 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标在 `event_projs\<项目名>\uploads\` 里,不拷这个目录会丢图片。
@@ -428,7 +472,7 @@ http://<主机IP>:19540/display
1. 确认 Console 里服务端状态为「运行中」; 1. 确认 Console 里服务端状态为「运行中」;
2. 本机访问 `http://127.0.0.1:19540/admin`,其他设备访问 `http://<主机IP>:19540/admin`; 2. 本机访问 `http://127.0.0.1:19540/admin`,其他设备访问 `http://<主机IP>:19540/admin`;
3. 确认 `config.yaml` 与 `console\config.json` 的端口一致; 3. 确认用户配置 `config.yaml` 与 `console\config.json` 的端口一致(都在 `%LOCALAPPDATA%\ScoreboardConsole\` 下,见 3.5);
4. 检查 Windows 防火墙是否拦截了该端口; 4. 检查 Windows 防火墙是否拦截了该端口;
5. 用托盘菜单的「打开服务端管理面板」直接打开。 5. 用托盘菜单的「打开服务端管理面板」直接打开。
@@ -436,7 +480,7 @@ http://<主机IP>:19540/display
- 打开 `logs\console.log`,搜索 `[server!]`,这是服务端错误输出; - 打开 `logs\console.log`,搜索 `[server!]`,这是服务端错误输出;
- 检查数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 是否可写、磁盘是否已满; - 检查数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 是否可写、磁盘是否已满;
- 检查 `config.yaml` 语法与端口是否合法。 - 检查用户配置 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的语法与端口是否合法。
### 9.5 页面显示「示例数据」 ### 9.5 页面显示「示例数据」
@@ -454,26 +498,29 @@ http://<主机IP>:19540/display
1. 确认电视与主机在同一局域网,主机 IP 为固定 IP; 1. 确认电视与主机在同一局域网,主机 IP 为固定 IP;
2. 电视浏览器地址填 `http://<主机IP>:19540/display`; 2. 电视浏览器地址填 `http://<主机IP>:19540/display`;
3. 确认 `config.yaml` 的 `server.host` 为 `0.0.0.0`; 3. 确认用户配置 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的 `server.host` 为 `0.0.0.0`;
4. 部分电视浏览器对 CSS 动画、字体支持有限,显示效果以实际设备为准。 4. 部分电视浏览器对 CSS 动画、字体支持有限,显示效果以实际设备为准。
### 9.8 忘记管理员密码 ### 9.8 忘记管理员密码
见第 5.5 节,使用 `scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 重置。 见第 5.5 节,使用 `scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 重置。
### 9.9 升级后数据不见了 / 日志里有迁移警告 ### 9.9 升级后数据/配置不见了 / 日志里有迁移警告
覆盖安装时安装程序会先跑旧卸载器删掉安装目录,所以本版本起数据默认放在 `%LOCALAPPDATA%\ScoreboardConsole\data`。首次启动后先看 Console「运行日志」或 `logs\console.log`: 覆盖安装时安装程序会先跑旧卸载器删掉安装目录,所以本版本起数据放在 `%LOCALAPPDATA%\ScoreboardConsole\data`、配置与规则放在 `%LOCALAPPDATA%\ScoreboardConsole\` 下的另外三处(见 3.4、3.5)。首次启动后先看 Console「运行日志」或 `logs\console.log`:
| 日志 | 含义 | | 日志 | 含义 |
| --- | --- | | --- | --- |
| `数据目录:<路径>` | 本次实际使用的数据目录,先确认它是不是你期望那个 | | `数据目录:<路径>` | 本次实际使用的数据目录,先确认它是不是你期望那个 |
| `用户数据目录:<路径>` | 本次实际使用的用户数据目录(配置与规则都在它下面) |
| `已把 <X> 复制到 <Y>` | 首次运行把安装目录里的配置/规则复制到用户目录,属正常 |
| `已把旧数据从 X 迁移到 Y` | 旧数据已整体搬过去,属正常 | | `已把旧数据从 X 迁移到 Y` | 旧数据已整体搬过去,属正常 |
| `警告:...都有数据...` | 两个目录都有数据,Console 没有动任何一边;请人工把要保留的那份拷到 `数据目录:` 指向的位置 | | `警告:...都有数据...` | 两个目录都有数据,Console 没有动任何一边;请人工把要保留的那份拷到 `数据目录:` 指向的位置 |
| `警告:迁移旧数据失败...` | 跨盘或权限问题;旧数据仍在日志里写的 `X`,手工拷过去再重启即可 | | `警告:迁移旧数据失败...` | 跨盘或权限问题;旧数据仍在日志里写的 `X`,手工拷过去再重启即可 |
| `警告:准备 <X> 失败...` | 配置/规则那一项回落到安装目录里那份,改动仍会在覆盖安装时丢;按日志里的路径手工复制到用户目录再重启 |
| `警告:数据目录仍在安装目录内` | `server.dataDir` 配置成了安装目录里的路径,覆盖安装会丢数据,请改到安装目录之外 | | `警告:数据目录仍在安装目录内` | `server.dataDir` 配置成了安装目录里的路径,覆盖安装会丢数据,请改到安装目录之外 |
若安装目录里的 `data\` 已被删掉,优先用 `backups\` 里最新的 `.db` 快照按第 8.3 节恢复。 若安装目录里的 `data\` 已被删掉,优先用 `backups\` 里最新的 `.db` 快照按第 8.3 节恢复;配置与规则从老版本升上来时只能由 Console 复制新版模板过去(原因见 3.5 的说明),升级后确认一遍端口等设置即可。
--- ---
@@ -481,6 +528,7 @@ http://<主机IP>:19540/display
- [ ] 服务端开机自启、崩溃自动拉起已验证 - [ ] 服务端开机自启、崩溃自动拉起已验证
- [ ] 「运行日志」里的 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(不是安装目录),且没有迁移警告 - [ ] 「运行日志」里的 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(不是安装目录),且没有迁移警告
- [ ] 「运行日志」里的 `用户数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole`,且没有「准备 … 失败」警告(配置与规则也在用户目录里,见 3.5)
- [ ] 数据目录的 `backups\` 可写,已生成至少一份备份 - [ ] 数据目录的 `backups\` 可写,已生成至少一份备份
- [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片) - [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片)
- [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常 - [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常
+2 -1
View File
@@ -99,8 +99,9 @@ onUnmounted(() => {
<div class="screen"> <div class="screen">
<div class="stage" :style="[stageStyle, themeVars]"> <div class="stage" :style="[stageStyle, themeVars]">
<!-- 兜底渐变与背景图互斥:两者都是 absolute inset:0 同层,同时存在时后画的渐变会整块盖住图片 -->
<img v-if="background" class="bg" :src="background" alt="" /> <img v-if="background" class="bg" :src="background" alt="" />
<div class="bg-fallback" /> <div v-else class="bg-fallback" />
<div class="content"> <div class="content">
<TopBar /> <TopBar />
@@ -0,0 +1,179 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { compileTemplate, parse } from 'vue/compiler-sfc'
// 大屏层次:背景图 / 兜底渐变在最底,.content 压在其上,DetailOverlay 再压住 .content。
// 项目没有 jsdom 与组件测试工具,这里直接编译 App.vue 的模板取 AST,
// 用渲染条件(v-if/v-else 分支)而不是文本匹配来断言,改坏层次会直接失败。
const APP_SOURCE = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const OVERLAY_SOURCE = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8')
// @vue/compiler-core 的 NodeTypes 没有从 compiler-sfc 再导出,按枚举值取用。
const NODE_ELEMENT = 1
const NODE_IF = 9
interface AstAttr {
type: number
name?: string
value?: { content?: string }
}
interface AstNode {
type: number
tag?: string
props?: AstAttr[]
children?: AstNode[]
branches?: { children: AstNode[] }[]
}
interface FoundElement {
tag: string
classes: string[]
/** 是否位于 v-if/v-else 分支内:false 表示无条件渲染。 */
conditional: boolean
}
function classTokens(node: AstNode): string[] {
const attr = node.props?.find((prop) => prop.type === 6 && prop.name === 'class')
return attr?.value?.content?.split(/\s+/).filter(Boolean) ?? []
}
/** 收集整棵子树里的元素;v-if 分支内的元素标记为 conditional。 */
function collectElements(node: AstNode, conditional: boolean, out: FoundElement[]): void {
if (node.type === NODE_IF) {
for (const branch of node.branches ?? []) {
for (const child of branch.children) collectElements(child, true, out)
}
return
}
if (node.type === NODE_ELEMENT) {
out.push({ tag: node.tag ?? '', classes: classTokens(node), conditional })
}
for (const child of node.children ?? []) collectElements(child, conditional, out)
}
function collect(node: AstNode): FoundElement[] {
const out: FoundElement[] = []
collectElements(node, false, out)
return out
}
/** 只展开一层 v-if 分支,得到同一父级下的元素(用于核对兄弟顺序)。 */
function topLevelElements(node: AstNode): FoundElement[] {
const out: FoundElement[] = []
const push = (child: AstNode): void => {
if (child.type === NODE_IF) {
for (const branch of child.branches ?? []) for (const inner of branch.children) push(inner)
return
}
if (child.type === NODE_ELEMENT) out.push({ tag: child.tag ?? '', classes: classTokens(child), conditional: false })
}
for (const child of node.children ?? []) push(child)
return out
}
/** 条件渲染节点藏在普通元素里,也可能嵌在别的分支里,两种都要走到。 */
function findIfNode(node: AstNode, match: (candidate: AstNode) => boolean): AstNode | null {
if (node.type === NODE_IF && match(node)) return node
for (const child of node.children ?? []) {
const hit = findIfNode(child, match)
if (hit !== null) return hit
}
for (const branch of node.branches ?? []) {
for (const child of branch.children) {
const hit = findIfNode(child, match)
if (hit !== null) return hit
}
}
return null
}
/** AST 的根是 RootNode,模板里的最外层元素是它的第一个子节点。 */
function rootElement(ast: AstNode): AstNode {
const root = ast.children?.[0]
if (root == null) throw new Error('模板缺少根元素')
return root
}
function templateAst(source: string): AstNode {
const { descriptor, errors } = parse(source, { filename: 'App.vue' })
assert.equal(errors.length, 0, 'App.vue 应能被解析')
const template = descriptor.template
if (template == null) throw new Error('App.vue 缺少 <template>')
const built = compileTemplate({
source: template.content,
filename: 'App.vue',
id: 'display-app',
})
assert.deepEqual(built.errors, [], 'App.vue 模板应能编译')
const ast = built.ast
if (ast === undefined) throw new Error('模板未产出 AST')
return ast as unknown as AstNode
}
function styleText(source: string): string {
const { descriptor } = parse(source, { filename: 'style.vue' })
const block = descriptor.styles[0]
if (block == null) throw new Error('缺少 <style>')
return block.content
}
function cssRule(style: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style)
assert.ok(match !== null, `样式里应存在 ${selector} 规则`)
return match[1] ?? ''
}
function cssZIndex(rule: string): number | null {
const match = /z-index:\s*(-?\d+)/.exec(rule)
return match === null ? null : Number(match[1])
}
const APP_AST = templateAst(APP_SOURCE)
test('兜底渐变与背景图互斥,不会盖住图片', () => {
const elements = collect(APP_AST)
const bg = elements.find((el) => el.classes.includes('bg'))
const fallback = elements.find((el) => el.classes.includes('bg-fallback'))
assert.ok(bg !== undefined, '模板里应有 .bg 背景图')
assert.equal(bg.conditional, true, '.bg 应只在配置了背景图时渲染')
assert.ok(fallback !== undefined, '模板里应保留 .bg-fallback')
assert.equal(fallback.conditional, true, '.bg-fallback 必须挂在 else 分支上')
// 同一个 v-if/v-else:有图走 img 分支,没图才走渐变分支。
const ifNode = findIfNode(APP_AST, (candidate) =>
(candidate.branches ?? []).some((branch) => classTokens(branch.children[0] ?? { type: 0 }).includes('bg')),
)
assert.ok(ifNode !== null, '背景图与兜底渐变应同属一个条件渲染节点')
assert.equal(ifNode.branches?.length, 2, '应是一对 v-if / v-else 分支')
const [yes, no] = ifNode.branches ?? []
assert.deepEqual(classTokens(yes?.children[0] ?? { type: 0 }), ['bg'])
assert.deepEqual(classTokens(no?.children[0] ?? { type: 0 }), ['bg-fallback'])
})
test('背景层压在内容与弹层之下', () => {
const appStyle = styleText(APP_SOURCE)
const contentZ = cssZIndex(cssRule(appStyle, '.content'))
assert.ok(contentZ !== null && contentZ >= 1, '.content 应显式抬到背景层之上')
for (const selector of ['.bg', '.bg-fallback']) {
const z = cssZIndex(cssRule(appStyle, selector))
assert.ok(z === null || z < contentZ, `${selector} 不得声明高于 .content 的 z-index`)
}
const overlayZ = cssZIndex(cssRule(styleText(OVERLAY_SOURCE), '.overlay'))
assert.ok(overlayZ !== null && overlayZ > contentZ, 'DetailOverlay 应压在 .content 之上')
})
test('自绘标题栏在舞台之外,缩放后的舞台盖不住它', () => {
const top = topLevelElements(rootElement(APP_AST))
assert.deepEqual(
top.map((el) => el.tag),
['WindowedTitleBar', 'div'],
'WindowedTitleBar 应是 .root 的首个子级,且与 .screen 平级',
)
assert.deepEqual(top[1]?.classes, ['screen'])
assert.match(cssRule(styleText(APP_SOURCE), '.screen'), /overflow:\s*hidden/, '.screen 应裁剪溢出的舞台')
})