diff --git a/console/README.md b/console/README.md index 6f8a491..e49797e 100644 --- a/console/README.md +++ b/console/README.md @@ -16,12 +16,14 @@ ``` console/ ├── package.json # main 指向 main/index.js;electron-builder 配置 -├── config.json # 运行配置(端口 / exe 路径 / 选屏策略) +├── config.json # 运行配置模板(打包后首次启动复制到用户数据目录,改这里的只在开发态生效) ├── main/ │ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期 │ ├── config.js # 配置加载与合并、exe 路径解析 │ ├── 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 / 日志转发 / 健康检查 / 自动重启 │ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances │ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd @@ -60,16 +62,16 @@ pnpm start # 等价于 electron . scoreboard-windows-amd64/ ├── Scoreboard_Console.exe ├── resources/app.asar # 内含 main/ renderer/ config.json -├── config.json # exe 旁边的可编辑配置 +├── config.json # exe 旁边的配置模板(首次启动复制到用户数据目录) ├── scoreboard.exe -├── config.yaml -├── rules/ -└── logs/console.log # 运行时生成 +├── config.yaml # 服务端配置模板(同上) +├── rules/ # 规则包模板(同上) +└── 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 内置测试运行器,无额外依赖)。 +## 配置与规则目录(重要) + +同样因为卸载器会删掉整个安装目录,随包的 `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`(规则目录先拷到 `.tmp-` 再整体 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` 也先开,避免现场只见桌面 | | 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 | | 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 | +| 配置与规则 | 同样在 `%LOCALAPPDATA%\ScoreboardConsole\`(`console\config.json`、`config.yaml`、`rules\`),打包态首次从安装目录复制、之后只用用户目录那份;拉起服务端时 `-config` 传绝对路径(见「配置与规则目录」一节) | | 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 | | 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 | | 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | @@ -109,15 +134,16 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | | 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` | | 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 | -| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) | +| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录 | +| 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) | -## 配置项(`config.json`) +## 配置项(`config.json`;打包态生效的是用户数据目录里那份) | 键 | 默认值 | 说明 | | --- | --- | --- | -| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 | -| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 | -| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以安装目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 | +| `server.exe` | `scoreboard.exe` | 相对路径会以部署包根目录(打包态 = exe 所在目录,开发态 = `console/`)、当前目录、上级目录、`dist\` 依次探测,也可写绝对路径 | +| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数;其中 `-config` 的值会被换成解析出的绝对路径(`<用户根>\config.yaml`),其余参数(如 `-project`)原样传 | +| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以部署包根目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 | | `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) | | `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 | | `server.healthIntervalMs` | `5000` | 健康检查间隔 | @@ -137,7 +163,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | | `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) | | `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | -| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 | +| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) | 配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 @@ -154,7 +180,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试 ``` - 产物: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` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。 - 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 - 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。 @@ -171,4 +197,5 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试 - 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。 - `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。 - 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。 +- 配置/规则首次复制实机路径:老版本升级到本版本后,安装目录里的 `config.json` / `config.yaml` / `rules\` 是否被复制到 `%LOCALAPPDATA%\ScoreboardConsole\` 下对应位置、改过配置后覆盖安装是否仍然生效、`%LOCALAPPDATA%` 不可写(或被杀毒软件拦截)时是否只记日志并回落到安装目录里那份。 - electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。 diff --git a/console/main/account.js b/console/main/account.js index 2880892..377c48e 100644 --- a/console/main/account.js +++ b/console/main/account.js @@ -35,8 +35,8 @@ function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS }) return new Promise((resolve) => { let child try { - // cwd 取 exe 所在目录:config.yaml 与 rules/ 都跟 exe 平级 - // (数据目录由 -data 显式指定,不依赖 cwd) + // cwd 取 exe 所在目录(部署包根目录);config.yaml 与数据目录都由参数给绝对路径, + // 不依赖 cwd child = spawn(exePath, args, { cwd: path.dirname(exePath), windowsHide: true, diff --git a/console/main/config.js b/console/main/config.js index 2d37ded..39965ef 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -8,6 +8,8 @@ const path = require('path') const DEFAULTS = { server: { exe: 'scoreboard.exe', + // -config 只声明「有个配置文件参数」,值在拉起服务端时被换成解析出的绝对路径: + // 打包态指向 <用户根>\config.yaml,开发态保持这里的相对值(见 userpaths.js) args: ['-config', 'config.yaml'], // 数据目录。空串 = 用 Console 选的默认位置(打包态是 // %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据); @@ -102,12 +104,14 @@ function updateConfig(configPath, mutate) { } // 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同: -// 开发时是 console/ 的上级,打包后是 exe 所在目录。逐个候选位置探测,避免写死。 -function resolveExecutable(exe, configPath) { +// 开发时是仓库的 console/,打包后是 exe 所在目录。逐个候选位置探测,避免写死。 +// baseDir 是显式给出的部署包根目录;不传时退回「按 configPath 所在目录推断」—— +// 打包态 config.json 已经搬到用户数据目录,那里推不出安装目录,必须显式传。 +function resolveExecutable(exe, configPath, baseDir) { if (!exe) return { path: null, candidates: [] } 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 = [ configDir, process.cwd(), diff --git a/console/main/datadir.js b/console/main/datadir.js index 18d85d3..0441b1b 100644 --- a/console/main/datadir.js +++ b/console/main/datadir.js @@ -6,6 +6,7 @@ const path = require('path') // 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`, // 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。 // 也不能放 %APPDATA%\(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。 +// 配置与规则目录(userpaths.js)用同一个基座,这里导出给它复用。 const LOCAL_BASE = 'ScoreboardConsole' const DATA_SUBDIR = 'data' @@ -109,4 +110,4 @@ function migrateLegacyData({ targetDir, legacyDir, log }) { return { migrated: true, reason: 'moved' } } -module.exports = { resolveDataDir, migrateLegacyData, isInside } +module.exports = { resolveDataDir, migrateLegacyData, isInside, LOCAL_BASE } diff --git a/console/main/index.js b/console/main/index.js index 7546a93..c387307 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -14,6 +14,7 @@ const { const { loadConfig, updateConfig, resolveExecutable } = require('./config') const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir') +const { resolveUserRoot, planUserPaths, prepareUserPaths } = require('./userpaths') const { LogBus } = require('./log-bus') const { ServiceSupervisor, healthHost } = require('./service') const { DisplayManager } = require('./displays') @@ -43,21 +44,29 @@ function main() { let exePath = null // 解析后的数据目录信息(见 datadir.js),拉起服务端和 passwd 都用它 let dataDirInfo = null + // 解析后的用户配置/规则路径(见 userpaths.js):console\config.json、config.yaml、rules\ + let userPaths = null // 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表 let instancesTimer = null let instancesLastReason = null // passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串 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() { if (process.env.SCOREBOARD_CONSOLE_CONFIG) { return path.resolve(process.env.SCOREBOARD_CONSOLE_CONFIG) } - // 打包后 config.json 放在 exe 旁边,方便现场直接改,不藏在 asar 里 - if (app.isPackaged) { - return path.join(path.dirname(app.getPath('exe')), 'config.json') - } - return path.join(__dirname, '..', 'config.json') + return userPaths ? userPaths.consoleConfig : path.join(__dirname, '..', 'config.json') } // 开关显示以系统实际状态为准:打包后的 Windows 读登录项 enabled,其余回落配置值。 @@ -424,8 +433,10 @@ function main() { return { ok: true, openAtLogin: wanted } } - // 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号 + // 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号; + // 打包态这份在用户数据目录里,Console 已经解析好了绝对路径(见 userpaths.js) function passwdConfigArg() { + if (userPaths && userPaths.serverConfig) return userPaths.serverConfig const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : [] const index = args.indexOf('-config') if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) { @@ -456,7 +467,7 @@ function main() { validateAccountField(password, '新密码', false) 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)) { return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` } } @@ -561,17 +572,36 @@ function main() { } 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()) log = new LogBus({ 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 }) 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) - const resolved = resolveExecutable(cfg.values.server.exe, cfg.path) + const resolved = resolveExecutable(cfg.values.server.exe, cfg.path, bundleDir) exePath = resolved.path log.push('console', `服务端程序:${resolved.path}`) @@ -604,7 +634,8 @@ function main() { values: cfg.values, exePath: resolved.path, log, - dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null + dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null, + configPath: userPaths.serverConfig }) displays = new DisplayManager({ diff --git a/console/main/service.js b/console/main/service.js index 8472ed6..9413597 100644 --- a/console/main/service.js +++ b/console/main/service.js @@ -7,6 +7,8 @@ const path = require('path') const { EventEmitter } = require('events') const { spawn } = require('child_process') +const { withConfigArg } = require('./userpaths') + const STATE_LABEL = { stopped: '已停止', starting: '启动中', @@ -37,7 +39,7 @@ function healthHost(server) { // 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。 // 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。 class ServiceSupervisor extends EventEmitter { - constructor({ values, exePath, log, dataDir }) { + constructor({ values, exePath, log, dataDir, configPath }) { super() this.values = values this.server = values.server @@ -45,6 +47,8 @@ class ServiceSupervisor extends EventEmitter { this.log = log // 绝对路径,非空时下发给服务端 -data;为空表示沿用服务端默认数据目录 this.dataDir = dataDir || null + // 绝对路径,非空时覆盖 args 里的 -config;为空表示沿用 args 原值(开发态) + this.configPath = configPath || null this.child = null this.state = 'stopped' @@ -113,9 +117,13 @@ class ServiceSupervisor extends EventEmitter { } 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 - // 工作目录取 exe 所在目录:部署包里 config.yaml / rules/ 都跟 exe 平级 + const spawnArgs = this.dataDir ? [...withConfig, '-data', this.dataDir] : withConfig + // 工作目录取 exe 所在目录(部署包根目录);config.yaml 与数据目录都走绝对路径, + // 不再依赖 cwd const cwd = path.dirname(this.exePath) this.lastError = null diff --git a/console/main/userpaths.js b/console/main/userpaths.js new file mode 100644 index 0000000..f2cd9b9 --- /dev/null +++ b/console/main/userpaths.js @@ -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 } diff --git a/console/main/userpaths.test.js b/console/main/userpaths.test.js new file mode 100644 index 0000000..f3d535c --- /dev/null +++ b/console/main/userpaths.test.js @@ -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']) +}) diff --git a/deploy/backup.bat b/deploy/backup.bat index 560dc7f..013a5fc 100644 --- a/deploy/backup.bat +++ b/deploy/backup.bat @@ -28,6 +28,11 @@ set "ROOT=%CD%" REM Same data dir the Console hands to the server via -data. set "DATA=%ROOT%\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" 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 data dir explicitly, so the current directory no longer decides it. -if not exist "%ROOT%\config.yaml" ( - echo [ERROR] %ROOT%\config.yaml not found. +if not exist "%CONFIG%" ( + echo [ERROR] config.yaml not found: %CONFIG% exit /b 1 ) echo === Scoreboard backup === echo Data dir : %DATA% +echo Config : %CONFIG% echo Server : %EXE% echo Reason : %REASON% echo. @@ -77,12 +83,12 @@ set "FOUND=" for /d %%D in ("%DATA%\event_projs\*") do ( set "FOUND=1" 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 not defined FOUND ( 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) ) diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index b86c9f9..8306d08 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -16,7 +16,7 @@ - [ ] Windows 10/11 x64,接市电(建议接 UPS) - [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540` -- [ ] 数据目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙 +- [ ] 数据/配置目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\`(数据在 `data\`,配置与规则是 `console\config.json`、`config.yaml`、`rules\`;都在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙 - [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」) - [ ] **安装目录不要选 `C:\Program Files`**(那里普通用户没写权限,日志会写不进去);装到 D 盘等普通目录即可,向导里可以改路径 @@ -25,6 +25,7 @@ - [ ] 双击 `Scoreboard_Console.exe`,启动管理窗口出现 - [ ] 服务端状态变为「运行中 · 就绪」 - [ ] 「运行日志」里 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(**不是**安装目录),且没有「都有数据 / 迁移失败」的警告 +- [ ] 「运行日志」里 `用户数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole`,且没有「准备 … 失败」的警告(配置与规则也在那儿,见部署指南 3.5) - [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录 - [ ] **管理员初始密码**在 `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`、最后一名不得分 - [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分 @@ -62,9 +63,9 @@ | 故障 | 动作 | | --- | --- | | 服务端无响应 | 服务管理窗口「重启服务」 | -| 主机死机 | 备机启动同一份部署包,把数据目录(`%LOCALAPPDATA%\ScoreboardConsole\data`)拷回去 | +| 主机死机 | 备机启动同一份部署包,把用户数据目录(`%LOCALAPPDATA%\ScoreboardConsole\`,含数据、配置与规则)拷回去 | | 数据库损坏 | 停机,用数据目录下 `backups\` 的最新备份替换 `event_projs\<项目名>\event.db`(先删 `-wal`/`-shm`),详见部署指南第 8.3 节 | | 大屏黑屏 | 服务管理窗口「重新排布」;仍不行就用备用笔记本开同一地址 | -| 覆盖安装后数据不见了 | 看「运行日志」里的 `数据目录:` 与迁移警告,按部署指南第 9.9 节处理 | +| 覆盖安装后数据/配置不见了 | 看「运行日志」里的 `数据目录:`、`用户数据目录:` 与迁移警告,按部署指南第 9.9 节处理 | > 完整恢复步骤见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。 diff --git a/docs/使用手册.md b/docs/使用手册.md index 8211d3b..7a6ade4 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -378,9 +378,11 @@ ### 11.3 规则包 +> 规则包文件放在 `%LOCALAPPDATA%\ScoreboardConsole\rules\`(安装包里附带的那份会在首次启动时复制过去;见部署指南 3.5)。 + - **导出 rules.yaml**:导出积分模板、并列规则、破纪录加分、计分通道与项目覆盖; - **导入**(仅管理员): - 1. 选择 `.yaml` / `.yml` 文件; + 1. 选择 `.yaml` / `.yml` 文件(就是上面那个 `rules\` 目录里的); 2. 点「预览差异」,查看格式是否兼容、影响多少个小项、逐条改动; 3. 确认无误后点「确认导入并全量重算」,服务端会按新规则重算全部已发布积分。 diff --git a/docs/常见问题.md b/docs/常见问题.md index a36e35f..14899ba 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -10,7 +10,9 @@ 首次启动时随机生成、只打印一次。用 Console 启动时,在 `logs\console.log` 里搜索「初始密码」即可找到,方法见 [部署指南 · 首次登录](./部署指南.md#5-首次登录)。 **Q2:忘记密码怎么办?** -先停止服务,然后在部署目录执行 `scoreboard.exe passwd -config config.yaml`,会打印新的随机密码,并要求下次登录强制改密。 +先停止服务,然后执行(配置在用户数据目录,不要用部署目录里那份模板): +`scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"` +会打印新的随机密码,并要求下次登录强制改密。 **Q3:访客和录入员有什么区别?** 访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。 @@ -88,7 +90,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 `data\backups\`,文件名形如 `system-<原因>-<时间戳>.db`、`<项目名>-<原因>-<时间戳>.db`(原因有 `auto` / `manual` / `publish`)。 **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:备份会自动删除旧的么?** 会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。 @@ -156,4 +158,4 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 **Q40:数据目录在哪?** -由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。 +默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级/卸载都不会删)。想换位置就改 `config.yaml` 的 `dataDir`(支持绝对路径),那份 `config.yaml` 本身也在 `%LOCALAPPDATA%\ScoreboardConsole\` 下。 diff --git a/docs/部署指南.md b/docs/部署指南.md index bc77147..3761bcb 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -13,7 +13,7 @@ | 网络 | 完全离线即可运行。若需要手机/平板/电视访问,用独立交换机或路由器组成局域网 | | 磁盘 | 建议预留 ≥ 10GB | | 运行时 | 无需安装 Go / Node / 数据库等任何运行时,前端已内嵌进可执行文件 | -| 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\data` 下建库、在部署目录的 `logs\` 写日志,请确保这两处可写 | +| 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\` 下建数据目录并写入配置/规则(见 3.5),在部署目录的 `logs\` 写日志,请确保这两处可写 | > 服务端只构建 Windows amd64 一个版本。Linux / 其他平台不在交付范围内。 @@ -51,7 +51,7 @@ deploy\build.bat 3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。 4. 双击「赛事积分榜」启动。 -> 赛事数据存放在 `%LOCALAPPDATA%\ScoreboardConsole\data`(不随安装目录走,见第 8.1 节),日志写在安装目录的 `logs\` 下;安装目录本身只要当前用户可写即可。 +> 赛事数据、配置与规则都存放在 `%LOCALAPPDATA%\ScoreboardConsole\`(不随安装目录走,见 3.4、3.5),日志写在安装目录的 `logs\` 下;安装目录本身只要当前用户可写即可。 ### 3.2 使用免安装包 @@ -73,17 +73,22 @@ deploy\build.bat 安装目录\ ├── Scoreboard_Console.exe # 桌面宿主,双击启动 ├── resources\app.asar # Console 自身资源 -├── config.json # Console 配置(可编辑) ├── scoreboard.exe # Go 服务端 -├── config.yaml # 服务端配置 ├── scoreboard.png # 应用图标 +├── config.json # Console 配置模板(首次启动复制到用户配置目录) +├── config.yaml # 服务端配置模板(同上) +├── rules\ # 规则包模板(同上) └── logs\console.log # 首次运行生成:运行日志 ``` -数据目录不在安装目录里,是独立的一处(详见 3.4): +配置、规则与数据**都不在安装目录里**,各是独立的一处(详见 3.4、3.5): ``` -%LOCALAPPDATA%\ScoreboardConsole\data\ # 数据库、备份、上传的图片 +%LOCALAPPDATA%\ScoreboardConsole\ # 用户数据目录(与安装目录无关) +├── data\ # 数据库、备份、上传的图片 +├── console\config.json # Console 自己的配置(实际生效的那份) +├── config.yaml # 服务端配置(实际生效的那份) +└── rules\ # 规则包(实际生效的那份) ``` ### 3.4 数据放在哪里(升级/卸载都不会删) @@ -92,8 +97,8 @@ deploy\build.bat | 场景 | 数据目录 | | --- | --- | -| 默认(`console\config.json` 的 `server.dataDir` 留空) | `%LOCALAPPDATA%\ScoreboardConsole\data`,即 `C:\Users\<用户名>\AppData\Local\ScoreboardConsole\data` | -| 想放到别处,例如 D 盘 | 在 `console\config.json` 里把 `server.dataDir` 改成 `"D:\\scoreboard-data"`(绝对路径),或相对安装目录的路径 | +| 默认(`console\config.json` 的 `server.dataDir` 留空,该文件见 3.5) | `%LOCALAPPDATA%\ScoreboardConsole\data`,即 `C:\Users\<用户名>\AppData\Local\ScoreboardConsole\data` | +| 想放到别处,例如 D 盘 | 在 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` 里把 `server.dataDir` 改成 `"D:\\scoreboard-data"`(绝对路径),或相对安装目录的路径 | | 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** | - Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行; @@ -104,6 +109,41 @@ deploy\build.bat > 升级到本版本后第一次启动,请打开 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) @@ -149,7 +189,7 @@ deploy\build.bat | 大屏 | `http://<主机IP>:19540/display` | | 导航页 | `http://<主机IP>:19540/` | -> 端口默认 **19540**,以 `config.yaml` 为准。 +> 端口默认 **19540**,以 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 为准(该文件在哪见 3.5)。 ### 5.2 初始账号 @@ -185,13 +225,13 @@ findstr /C:"INITIAL ADMIN CREATED" logs\console.log ### 5.5 忘记密码 / 没记下初始密码 -在 Console 里先**停止服务**,然后在部署目录(`config.yaml` 所在目录)执行: +在 Console 里先**停止服务**,然后在安装目录(`scoreboard.exe` 所在目录)执行: ```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` 生成一个新的随机密码并打印出来,同时要求下次登录强制改密。可选参数: @@ -206,7 +246,9 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol ## 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` | 每个库滚动保留的备份份数 | | `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.args` | `["-config","config.yaml"]` | 传给服务端的启动参数 | +| `server.exe` | `scoreboard.exe` | 服务端可执行文件;相对路径会在部署目录(安装目录)、当前目录、上级目录、`dist\` 依次查找 | +| `server.args` | `["-config","config.yaml"]` | 传给服务端的启动参数;其中 `-config` 的**值**会被换成 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的绝对路径,其余参数(如 `-project`)按原样传(见 3.5) | | `server.dataDir` | `""` | 数据目录;留空 = 用默认位置 `%LOCALAPPDATA%\ScoreboardConsole\data`。填了就按它来:绝对路径直接用,相对路径以安装目录为基准。Console 会把它作为 `-data <绝对路径>` 追加到启动参数后(见第 3.4 节) | | `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查与打开管理面板用的地址;配成 `0.0.0.0` 时本机探测自动改走回环地址 | | `server.autoStart` | `true` | Console 启动后是否自动拉起服务端 | @@ -244,7 +288,7 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol | `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制 | | `admin.width` / `admin.height` | `1280` / `860` | 启动管理窗口初始尺寸(服务管理窗口与服务端管理面板共用) | | `log.maxLines` | `2000` | 内存中保留的日志行数 | -| `log.file` | `logs/console.log` | 日志文件,相对 `config.json` 所在目录 | +| `log.file` | `logs/console.log` | 日志文件,相对部署目录(安装目录)解析 —— 日志不随配置搬到用户目录 | | `log.maxFileBytes` | `4194304` | 单个日志文件上限,超出后轮转为 `.1` | > 配置缺项会自动使用内置默认值;JSON 语法错误时回落到全默认值,并在窗口顶部提示。 @@ -254,8 +298,8 @@ scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsol 端口必须**两处一致**: 1. 关闭 Console(托盘 → 退出); -2. 修改 `config.yaml` 的 `server.port`; -3. 修改 `console\config.json` 的 `server.port`(保持一致,否则 Console 健康检查和管理面板地址会失效); +2. 修改 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的 `server.port`; +3. 修改 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` 的 `server.port`(保持一致,否则 Console 健康检查和管理面板地址会失效); 4. 重新启动 Console; 5. 之后管理面板地址变为 `http://<主机IP>:<新端口>/admin`。 @@ -334,14 +378,14 @@ http://<主机IP>:19540/display | --- | --- | --- | | 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.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`) | | 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` | - 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`; - 自动、手动、命令行三种方式都按 `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 如何恢复 @@ -360,11 +404,11 @@ http://<主机IP>:19540/display ### 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 即可,无需重新配置;若数据目录放到了别处,记得同步改 `console\config.json` 的 `server.dataDir`。 +- 用户数据目录:整个 `%LOCALAPPDATA%\ScoreboardConsole\` 拷过去即可(含 `data\`、`console\config.json`、`config.yaml`、`rules\`;同一用户名下路径一致,最省事)——这样配置、规则和赛事数据原样接管,无需重新配置; +- 安装目录:按同样路径放好,或直接用新机器上的安装包装一遍(安装目录里那三份只是模板,拷不拷都行); +- 启动 Console 即可;若数据目录放到了别处,记得同步改 `console\config.json` 的 `server.dataDir`(在用户数据目录里那份)。 ### 8.5 赛事项目包导出 / 导入 @@ -374,7 +418,7 @@ http://<主机IP>:19540/display | 导入 | 「导出 → 赛事项目包 → 选择 .zip → 导入」 | **仅管理员**可操作;可填「重命名」,勾选「覆盖同名项目」会先二次确认 | - 导入后项目落在 `<数据目录>\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 手工备份 / 迁移整个数据目录 @@ -385,7 +429,7 @@ http://<主机IP>:19540/display 2. 把整个数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 复制到 U 盘或其他盘(里面含 `system.db`、`event_projs\`(数据库 + `uploads\` 图片)、`backups\`); 3. 恢复时把整个目录拷回去,然后: - 拷回**原路径**:直接启动 Console 即可; - - 拷到**别的路径**:先建好目标目录并放入数据,再在 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console; + - 拷到**别的路径**:先建好目标目录并放入数据,再在用户配置 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console; 4. 恢复完成后在管理端核对赛事、账号、上传图片是否齐全。 > 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标在 `event_projs\<项目名>\uploads\` 里,不拷这个目录会丢图片。 @@ -428,7 +472,7 @@ http://<主机IP>:19540/display 1. 确认 Console 里服务端状态为「运行中」; 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 防火墙是否拦截了该端口; 5. 用托盘菜单的「打开服务端管理面板」直接打开。 @@ -436,7 +480,7 @@ http://<主机IP>:19540/display - 打开 `logs\console.log`,搜索 `[server!]`,这是服务端错误输出; - 检查数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 是否可写、磁盘是否已满; -- 检查 `config.yaml` 语法与端口是否合法。 +- 检查用户配置 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` 的语法与端口是否合法。 ### 9.5 页面显示「示例数据」 @@ -454,26 +498,29 @@ http://<主机IP>:19540/display 1. 确认电视与主机在同一局域网,主机 IP 为固定 IP; 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 动画、字体支持有限,显示效果以实际设备为准。 ### 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` | 旧数据已整体搬过去,属正常 | | `警告:...都有数据...` | 两个目录都有数据,Console 没有动任何一边;请人工把要保留的那份拷到 `数据目录:` 指向的位置 | | `警告:迁移旧数据失败...` | 跨盘或权限问题;旧数据仍在日志里写的 `X`,手工拷过去再重启即可 | +| `警告:准备 失败...` | 配置/规则那一项回落到安装目录里那份,改动仍会在覆盖安装时丢;按日志里的路径手工复制到用户目录再重启 | | `警告:数据目录仍在安装目录内` | `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`,且没有「准备 … 失败」警告(配置与规则也在用户目录里,见 3.5) - [ ] 数据目录的 `backups\` 可写,已生成至少一份备份 - [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片) - [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常 diff --git a/web/src/display/App.vue b/web/src/display/App.vue index 144911d..57da404 100644 --- a/web/src/display/App.vue +++ b/web/src/display/App.vue @@ -99,8 +99,9 @@ onUnmounted(() => {
+ -
+
diff --git a/web/src/display/tests/background-layer.test.ts b/web/src/display/tests/background-layer.test.ts new file mode 100644 index 0000000..c3fe972 --- /dev/null +++ b/web/src/display/tests/background-layer.test.ts @@ -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 缺少