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:
1 parent
2d207fd654
commit
407abec3b0
15 files changed
+759
-81
No files matched your search
+42
-15
@@ -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 安装包在真机上的安装 / 卸载流程尚未验证。
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 输出定位正确、分辨率正确、字体正常
|
||||||
|
|||||||
@@ -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 应裁剪溢出的舞台')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user