Files
Scoreboard/console/main/config.js
T
AskaEth 9ef68c2589 feat(console,admin): kiosk 不再点击穿透 + 压住任务栏;大屏控制页新增「局域网大屏」(链接/复制/二维码)dev_0.4.0
## ① 现场问题:kiosk 窗口点击穿透(原设计方向反了)
`displays.js` 原来对多屏 kiosk 调 `setIgnoreMouseEvents(true)`,注释写的是
「防止现场误触」——但该 API 的语义是「本窗口忽略鼠标」,点击会**落到它后面的
窗口或桌面**,现场想防的误触正好变成误点别的东西;被忽略鼠标的窗口也点不活。
现在窗口**默认吞掉鼠标、不穿透**。

配置项**改名**为 `display.allowClickThrough`(缺省 false;显式 true 才穿透)。
**旧键 `display.ignoreMouse` 完全不读**——老机器的配置文件里还写着旧版模板留下的
`ignoreMouse: true`,而配置策略是「已存在不覆盖」,只改默认值等于老机器升级后
照样穿透;改名才能让默认值真正生效。启动时若发现旧键会记一条中文提示(只记一次),
并在合并后的配置里把它剥掉,防止将来有人再读到那个陈年 true。
(不是自动改写用户配置文件:无法区分「操作员刻意写的 true」与「旧模板留下的 true」。)

## ② 现场问题:任务栏盖住 kiosk 窗口、被盖住后抢不回前台
原顺序是 `setKiosk(true)` 之后又 `setBounds(...)`,在 Windows 上会把窗口从真正的
全屏态拽回「恰好铺满该屏的无边框窗口」→ 不是 topmost,任务栏与拖过去的窗口都盖得住。
现在固定为 **先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true,
'screen-saver')`**(默认层级 `floating` 在 Windows 上仍画在任务栏下面),并加了
「已达标就不重复进出 kiosk」的守卫避免重排白闪;非 kiosk 分支会摘掉置顶。
Win 真机行为需现场复核,代码/桩级已验(含顺序与层级的断言)。

## ③ 新功能:大屏控制页「局域网大屏」卡片
- 服务端新增 `GET /api/system/network`(recorder 及以上):回本机可用局域网 IPv4
  (排除回环/link-local/IPv6,去重,按 192.168/10/172.16-31 优先排序)+ 真实监听端口
  + 路径;无地址回 `[]` 不报错。
- 卡片:每个地址一条可点击链接 + 复制按钮,第一个地址配二维码(148px)。
- **二维码是离线自实现的纯 TS 编码器**(版本 1–40 自动、ECC L/M/Q/H、8 种掩码择优),
  正确性用 **Python OpenCV 真解码**验收:29/29 条解出原文(含中文与边界),
  SVG 渲染后再解码 4/4,并把验证过的矩阵冻结成仓库参照用例。
- 复制按**非安全上下文**处理:面板常从 `http://192.168.x.x` 打开,那里
  `navigator.clipboard` 是 undefined,自动回退 `execCommand('copy')`。

## ④ 真机联调抓出并修掉一个时序缺陷(真浏览器 + 真服务端)
`DisplayPage` 的 `refreshLan()` 在挂载时跑,而 `auth` 是异步加载的——那一刻
`auth.readOnly` 必然为 true,卡片把「当前为只读访客」写进一个**普通 ref** 就返回了,
身份落定后没有任何东西再触发它:**管理员登录后卡片永远停在访客态,
`/api/system/network` 一次都没被请求**。
改为「gate 由 computed 直接渲染 + watch gate 变 null 时重跑加载」,并补回归钉子。
(同时修正了一条把该错误结构当成正确行为的源码断言用例。)
端到端证据:真服务 + 局域网 IP(真非安全上下文)→ 卡片列出两个地址、点击复制变
「已复制」(走 execCommand 回退)→ 浏览器渲染的二维码截图经 cv2 解出
`http://10.50.101.85:19861/display`。

## 文档
部署指南配置表与多屏行为、使用手册「大屏控制」与局域网设备段、devdocs 05/07/10/13、
升级说明新增 dev_0.4.0(含旧键废弃与启动提示)。**无数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 645 例 ×2 + build;Console 20 文件 + 68 例。
2026-09-12 16:25:04 +08:00

177 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
const fs = require('fs')
const path = require('path')
// 默认值写死在代码里:config.json 只覆盖要改的项。
// 这样升级新增字段时老配置依然能用,现场也不容易配出半成品。
const DEFAULTS = {
server: {
exe: 'scoreboard.exe',
// -config 只声明「有个配置文件参数」,值在拉起服务端时被换成解析出的绝对路径:
// 打包态指向 <用户根>\config.yaml,开发态保持这里的相对值(见 userpaths.js)
args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态是
// %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据);
// 填了就按它来,相对路径以安装目录为基准(详见 datadir.js)
dataDir: '',
host: '127.0.0.1',
port: 19540,
autoStart: true,
healthIntervalMs: 5000,
restartDelayMs: 3000,
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
maxRestarts: 0,
// 优雅关闭握手:服务端需实现该回环接口并校验 X-Shutdown-Token,
// 未实现(404/连接失败)时回退到进程信号
shutdownPath: '/api/shutdown',
// 服务端受理关闭请求后等待其自行退出的上限,超过则强杀
shutdownTimeoutMs: 5000,
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
healthyResetMs: 60000
},
display: {
// all-secondary | all | specified
selection: 'all-secondary',
displays: [],
// 只能写大屏实际支持的页名(见 web/src/display/types.ts 的 PAGE_ALIASES)
pages: ['standings', 'medals', 'individual', 'results'],
pageParam: 'page',
kiosk: true,
// 多屏 kiosk 窗口是否**允许点击穿透**(写 true 才穿透)。默认 false = 窗口吞掉点击,
// 可以点活、被盖住后能抢回前台。
// 这个键是 display.ignoreMouse 改名来的,**为什么换键名而不是只改默认值**:
// 旧版默认 true,而且旧版模板已经把 ignoreMouse: true 写进了现场机器的
// <用户根>\console\config.json,userpaths.js 的策略是「已存在就绝不覆盖」——
// 光把默认值改成 false 救不了老机器(文件里那个显式 true 会一直压着默认值),
// 我们也没法区分「操作员刻意要穿透」和「旧模板留下的 true」(去改用户文件就是猜)。
// 换个新键名,老文件里的旧键自然失效,现场不动配置文件就回到不穿透;
// 确实要穿透的再显式写 allowClickThrough: true(旧键会触发一条启动提示,见 loadConfig)
allowClickThrough: false,
reloadEveryHours: 24,
// 服务端迟迟没就绪时的兜底:超时后也把大屏开出来,避免现场一片黑
waitForServerMs: 20000,
backgroundColor: '#0b0f1a'
},
startup: {
// 默认注册登录项但不启用(任务管理器「启动」里可见);服务管理窗口的开关控制是否真的随系统启动
openAtLogin: false
},
admin: {
width: 1280,
height: 860
},
log: {
maxLines: 2000,
file: 'logs/console.log',
maxFileBytes: 4 * 1024 * 1024
}
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function merge(base, override) {
const out = isPlainObject(base) ? { ...base } : {}
if (!isPlainObject(override)) return out
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue
out[key] = isPlainObject(value) && isPlainObject(base[key]) ? merge(base[key], value) : value
}
return out
}
function readJson(file) {
try {
const raw = fs.readFileSync(file, 'utf8')
return { values: JSON.parse(raw), warning: null }
} catch (err) {
if (err.code === 'ENOENT') return { values: {}, warning: `配置文件不存在,使用内置默认值:${file}` }
return { values: {}, warning: `配置文件无法解析,使用内置默认值:${file}(${err.message})` }
}
}
// 改名过的配置键:旧键 → 新键。只提示、**不自动迁移**——改现场机器上的配置文件不是
// Console 该做的事,而且「操作员刻意写的」和「旧模板留下的」在文件里长得一模一样,猜错就改了人家的意图。
const RENAMED_KEYS = {
'display.ignoreMouse': 'display.allowClickThrough'
}
// 看看持久化的配置文件里还留着哪些废弃键。读的是**文件里真实写了什么**(readJson 的原始值),
// 不是 merge 之后的结果:merge 出来的值一律带 DEFAULTS,拿它判断会把每个键都当成「用户写过」。
// 只在这里算一次、由 index.js 在启动时记一次,绝不在 layout/重排路径里判断,否则日志会被刷爆。
function deprecatedKeyNotices(values) {
const notices = []
// 配置文件内容可能是 null / 数组 / 字符串(JSON 语法合法但不是对象),一律当作「没有旧键」
if (!isPlainObject(values)) return notices
for (const [oldKey, newKey] of Object.entries(RENAMED_KEYS)) {
const [section, key] = oldKey.split('.')
if (!isPlainObject(values[section]) || !(key in values[section])) continue
notices.push(
`配置项 ${oldKey} 已废弃、不再生效:本机配置文件里写的 ${key}: ${JSON.stringify(values[section][key])} 会被忽略。` +
`大屏窗口现在默认「不穿透」,点击不会再漏到大屏后面的窗口或桌面;` +
`要让大屏恢复点击穿透,请改用 ${newKey}: true`
)
}
return notices
}
// 把改名前的旧键从**合并后的配置**里删掉:要让「旧键失效」彻底一点,
// 不能只做到「没人读它」——merge 是通用深合并,旧键会原样留在 cfg.values.display 里,
// 以后谁再加一行读旧键的代码,就会读到老机器配置文件里那个陈年 true。
// 只动这份内存里的配置,绝不写回用户文件(现场那份要原样留着,人自己决定怎么改)。
function stripRenamedKeys(values) {
for (const oldKey of Object.keys(RENAMED_KEYS)) {
const [section, key] = oldKey.split('.')
if (isPlainObject(values[section])) delete values[section][key]
}
return values
}
function loadConfig(configPath) {
const { values, warning } = readJson(configPath)
return {
path: configPath,
values: stripRenamedKeys(merge(DEFAULTS, values)),
warning,
// 提示按**文件里真实写了什么**算,所以传的是 readJson 的原始值,不是上面那份合并结果
notices: deprecatedKeyNotices(values)
}
}
// 就地改配置并写回文件:只动 raw 里已有的键,不把 DEFAULTS 全量落盘,
// 否则升级新增的默认值会被老文件冻结成显式值。
function updateConfig(configPath, mutate) {
const { values } = readJson(configPath)
const raw = isPlainObject(values) ? values : {}
mutate(raw)
fs.mkdirSync(path.dirname(configPath), { recursive: true })
fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n', 'utf8')
return raw
}
// 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同:
// 开发时是仓库的 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 = baseDir ? path.resolve(baseDir) : path.dirname(configPath)
const candidates = [
configDir,
process.cwd(),
path.resolve(configDir, '..'),
path.resolve(configDir, '..', 'dist')
].map((dir) => path.resolve(dir, exe))
const found = candidates.find((candidate) => fs.existsSync(candidate))
return { path: found || candidates[0], candidates: [...new Set(candidates)] }
}
// DEFAULTS 一并导出:displays.test.js 用它断言「随包发布的默认值」本身
// (例如 display.allowClickThrough 必须是不穿透),测试里自己写死一份就验不到默认值被改回去
module.exports = { DEFAULTS, loadConfig, updateConfig, resolveExecutable }