From 9ef68c25896ae6b9786facd0acfaee01c1da3259 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 12 Sep 2026 16:25:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(console,admin):=20kiosk=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=82=B9=E5=87=BB=E7=A9=BF=E9=80=8F=20+=20=E5=8E=8B?= =?UTF-8?q?=E4=BD=8F=E4=BB=BB=E5=8A=A1=E6=A0=8F=EF=BC=9B=E5=A4=A7=E5=B1=8F?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=A1=B5=E6=96=B0=E5=A2=9E=E3=80=8C=E5=B1=80?= =?UTF-8?q?=E5=9F=9F=E7=BD=91=E5=A4=A7=E5=B1=8F=E3=80=8D=EF=BC=88=E9=93=BE?= =?UTF-8?q?=E6=8E=A5/=E5=A4=8D=E5=88=B6/=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=EF=BC=89dev=5F0.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① 现场问题: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 例。 --- VERSION | 2 +- console/README.md | 10 +- console/config.json | 2 +- console/main/config.js | 58 +- console/main/config.test.js | 87 +++ console/main/displays.js | 63 +- console/main/displays.test.js | 220 ++++++- console/main/index.js | 4 + console/package.json | 2 +- docs/使用手册.md | 13 +- docs/升级说明.md | 58 +- docs/常见问题.md | 2 +- docs/部署指南.md | 9 +- server/internal/httpapi/network.go | 187 ++++++ .../internal/httpapi/network_internal_test.go | 145 +++++ server/internal/httpapi/network_test.go | 109 ++++ server/internal/httpapi/server.go | 29 +- server/main.go | 3 + web/src/admin/api/client.ts | 14 + web/src/admin/api/mock.ts | 17 + web/src/admin/components/icons.ts | 1 + web/src/admin/pages/DisplayPage.vue | 313 ++++++++-- web/src/admin/pages/lanDisplay.ts | 102 ++++ web/src/admin/tests/lan-display-card.test.ts | 123 ++++ .../admin/tests/lan-display-refresh.test.ts | 38 ++ web/src/admin/tests/lan-display.test.ts | 133 +++++ web/src/shared/api.ts | 51 +- web/src/shared/qrcode.ts | 549 ++++++++++++++++++ web/src/shared/tests/network-api.test.ts | 94 +++ web/src/shared/tests/qrcode-frozen.ts | 386 ++++++++++++ web/src/shared/tests/qrcode.test.ts | 164 ++++++ web/src/shared/tests/request-id.test.ts | 6 +- web/src/shared/types.ts | 24 + 33 files changed, 2923 insertions(+), 95 deletions(-) create mode 100644 console/main/config.test.js create mode 100644 server/internal/httpapi/network.go create mode 100644 server/internal/httpapi/network_internal_test.go create mode 100644 server/internal/httpapi/network_test.go create mode 100644 web/src/admin/pages/lanDisplay.ts create mode 100644 web/src/admin/tests/lan-display-card.test.ts create mode 100644 web/src/admin/tests/lan-display-refresh.test.ts create mode 100644 web/src/admin/tests/lan-display.test.ts create mode 100644 web/src/shared/qrcode.ts create mode 100644 web/src/shared/tests/network-api.test.ts create mode 100644 web/src/shared/tests/qrcode-frozen.ts create mode 100644 web/src/shared/tests/qrcode.test.ts diff --git a/VERSION b/VERSION index 91c7625..eeebe8a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.3.0 +dev_0.4.0 diff --git a/console/README.md b/console/README.md index 3aec183..2c6d25b 100644 --- a/console/README.md +++ b/console/README.md @@ -121,9 +121,9 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | 场景 | 行为 | | --- | --- | -| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不跟随** `display-metrics-changed`(分辨率/缩放变化不重排,行为冻结);`display-added` / `display-removed` 一直听着,电视插回来会自动排布回 kiosk。监听常驻、只有 `closeAll()`(服务停止 / 应用退出)才摘(回归测试见 `main/displays.test.js`);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏 | +| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不跟随** `display-metrics-changed`(分辨率/缩放变化不重排,行为冻结);`display-added` / `display-removed` 一直听着,电视插回来会自动排布回 kiosk。监听常驻、只有 `closeAll()`(服务停止 / 应用退出)才摘(回归测试见 `main/displays.test.js`);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏。窗口化窗口**不置顶、不穿透**:F11 全屏完全由用户控制,置顶会把它变成一块收不掉、盖住一切的覆盖层 | | 模式与 URL | **必须永远一致**:模式(窗口化 / kiosk)记在窗口的 entry 上,谁改模式谁按新模式重载 URL。`layout()` 只对排布自己开的窗口套用全局 `single`,管理面板手动开的窗口保持自己的模式——否则会出现「窗口化窗口被改成 kiosk 全屏、URL 里还留着 `windowed=1`」这种页面认知与窗口真实模式脱钩、标题栏画反的问题(回归测试见 `main/displays.test.js`)| -| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 | +| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,光标由页面 CSS 隐藏。窗口**默认吞掉鼠标、不穿透**:`setIgnoreMouseEvents(true)` 的语义是「本窗口忽略鼠标」,点击会落到它后面的窗口或桌面上(现场想防的误触反而变成误点别的东西),被忽略鼠标的窗口也点不活、抢不回前台;只有确实要把大屏当纯展示层、同一块屏上还要操作别的窗口时才显式配 `display.allowClickThrough: true`。这个开关是从 `display.ignoreMouse` **改名**来的:旧版默认穿透,而且旧版模板已经把 `ignoreMouse: true` 写进了现场机器的用户配置(`userpaths.js` 不覆盖用户文件),只改默认值救不了老机器——换个新键名,旧键自然失效;启动时若发现配置里还留着旧键,会在 `logs/console.log` 里记一条提示(只记一次),说明它已废弃、现在默认不穿透、想穿透该写哪个键。窗口**置顶在 `screen-saver` 层级**(Windows 上 `setAlwaysOnTop(true)` 的默认层级 `floating` 仍画在任务栏下面,只有 pop-up-menu 及以上才压得住任务栏),所以别的窗口拖到这块屏上盖不住它、任务栏也盖不住它,被盖住后点一下就能回到最前。调用顺序**必须**是「先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后置顶」:反过来会把窗口从真正的全屏态拽回「恰好铺满该屏的无边框窗口」,既不是 topmost、也压不住任务栏(回归测试见 `main/displays.test.js`)| | 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` | | 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=&instance=-`,页序按 `display.pages` 轮转 | | 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 | @@ -163,7 +163,8 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | `display.pages` | `["standings","medals","individual","results"]` | 按屏序轮转锁定;只填大屏支持的页名 | | `display.pageParam` | `page` | 页面 URL 参数名 | | `display.kiosk` | `true` | 多屏时是否 kiosk(单屏恒窗口化) | -| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 | +| `display.allowClickThrough` | `false` | 多屏 kiosk 窗口是否**允许点击穿透**(`setIgnoreMouseEvents(true)`)。写 `true` 后该窗口忽略所有鼠标事件:点击落到它后面的窗口/桌面(现场误触会点到别的东西),大屏也点不活、被盖住后抢不回前台。只把大屏当纯展示层、且要在同一块屏上操作别的窗口时才开 | +| `display.ignoreMouse` | — | **已废弃 → 见 `display.allowClickThrough`**。旧版默认 `true` 且被旧模板写进现场机器的用户配置,只改默认值救不了老机器,所以换了键名;本键现在**完全不读**,配置文件里留着它只会让启动日志多一条提示,不影响行为 | | `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 | | `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 | | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | @@ -171,7 +172,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | `admin.width` / `admin.height` | `1280` / `860` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | | `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) | -配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 +配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。配置里如果还留着已改名的旧键(目前是 `display.ignoreMouse`),启动时会在 `logs/console.log` 里记一条中文提示(只在启动记一次,不随大屏重排重复刷),说明该键已废弃、现在默认不穿透、要穿透该写哪个键;旧键的值一律忽略,**不会**被自动改写进用户的配置文件。 ## 打包(electron-builder) @@ -198,6 +199,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试 以下内容只做了静态语法检查,需要在 Windows + 真实多显示器环境跑一遍: - 多屏 kiosk 定位与 `display-metrics-changed` 重排(改分辨率 / 缩放 / 插拔 HDMI)。 +- kiosk 窗口的置顶(`setAlwaysOnTop(true, 'screen-saver')`)与任务栏覆盖:能否稳定压住任务栏、别的窗口拖到该屏上会不会盖住它、点击后能否回到最前——`screen-saver` 层级在 Windows 上的实际效果只能在真机上验;`setKiosk` / `setBounds` 的先后顺序同理(Linux 开发机没有 Electron 运行时)。 - 管理面板显示器菜单:真正的 kiosk 全屏 / 窗口化结果、`screen.getAllDisplays()` 的 `label` 与主屏判定、`display:list` 返回的 scaleFactor 是否与现场一致。 - `powerSaveBlocker` 在长时间后台下的实际效果。 - 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。 diff --git a/console/config.json b/console/config.json index 7aa2ba0..de29ff4 100644 --- a/console/config.json +++ b/console/config.json @@ -19,7 +19,7 @@ "pages": ["standings", "medals", "individual", "results"], "pageParam": "page", "kiosk": true, - "ignoreMouse": true, + "allowClickThrough": false, "reloadEveryHours": 24, "waitForServerMs": 20000, "backgroundColor": "#0b0f1a" diff --git a/console/main/config.js b/console/main/config.js index 39965ef..f40cebb 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -38,7 +38,16 @@ const DEFAULTS = { pages: ['standings', 'medals', 'individual', 'results'], pageParam: 'page', kiosk: true, - ignoreMouse: 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, @@ -83,12 +92,51 @@ function readJson(file) { } } +// 改名过的配置键:旧键 → 新键。只提示、**不自动迁移**——改现场机器上的配置文件不是 +// 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: merge(DEFAULTS, values), - warning + values: stripRenamedKeys(merge(DEFAULTS, values)), + warning, + // 提示按**文件里真实写了什么**算,所以传的是 readJson 的原始值,不是上面那份合并结果 + notices: deprecatedKeyNotices(values) } } @@ -123,4 +171,6 @@ function resolveExecutable(exe, configPath, baseDir) { return { path: found || candidates[0], candidates: [...new Set(candidates)] } } -module.exports = { loadConfig, updateConfig, resolveExecutable } +// DEFAULTS 一并导出:displays.test.js 用它断言「随包发布的默认值」本身 +// (例如 display.allowClickThrough 必须是不穿透),测试里自己写死一份就验不到默认值被改回去 +module.exports = { DEFAULTS, loadConfig, updateConfig, resolveExecutable } diff --git a/console/main/config.test.js b/console/main/config.test.js new file mode 100644 index 0000000..8f0f6ae --- /dev/null +++ b/console/main/config.test.js @@ -0,0 +1,87 @@ +'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 { DEFAULTS, loadConfig } = require('./config') + +function makeDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-config-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + return dir +} + +// 现场机器上那份用户配置:内容原样写进文件,读的也必须是文件里真实写了什么 +function loadWith(t, raw) { + const dir = makeDir(t) + const file = path.join(dir, 'config.json') + fs.writeFileSync(file, typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2), 'utf8') + return loadConfig(file) +} + +test('缺省的 allowClickThrough 不穿透(默认值就是 false)', (t) => { + assert.equal(DEFAULTS.display.allowClickThrough, false) + const cfg = loadWith(t, { display: {} }) + assert.equal(cfg.values.display.allowClickThrough, false) + assert.deepEqual(cfg.notices, []) +}) + +test('显式写 allowClickThrough: true 才穿透,且不产生废弃提示', (t) => { + const cfg = loadWith(t, { display: { allowClickThrough: true } }) + assert.equal(cfg.values.display.allowClickThrough, true) + assert.deepEqual(cfg.notices, []) +}) + +// 本次修复的核心回归钉子:老机器升级后,用户配置文件里那个旧版模板写下的 +// ignoreMouse: true 必须彻底失效(userpaths.js 不会覆盖用户文件,只能靠不读旧键) +test('老配置里的旧键 ignoreMouse 已失效:既不生效、也不再是穿透开关', (t) => { + const cfg = loadWith(t, { display: { ignoreMouse: true } }) + + assert.equal('ignoreMouse' in DEFAULTS.display, false, 'DEFAULTS 里不能再有旧键') + assert.equal(cfg.values.display.allowClickThrough, false, '旧键 true 不能把穿透带回来') + assert.equal(cfg.values.display.ignoreMouse, undefined, '旧键不该被 merge 进最终配置') + + // 但必须留下一条一次性提示,让现场知道该改哪个键 + assert.equal(cfg.notices.length, 1) + const [notice] = cfg.notices + assert.match(notice, /display\.ignoreMouse/) + assert.match(notice, /display\.allowClickThrough/) + assert.match(notice, /废弃/, '要明说这个键已经不用了') + assert.match(notice, /不穿透/, '要明说现在的默认是不穿透') + assert.match(notice, /true/, '要给出「确实想穿透」的写法') +}) + +test('旧键提示只跟着「文件里写了旧键」出现,缺省或新键都不提示', (t) => { + // 旧版模板整份配置(现场最常见的形态):只提示一次,不会按重排/每帧刷 + const legacyTemplate = loadWith(t, { + display: { selection: 'all-secondary', kiosk: true, ignoreMouse: true } + }) + assert.equal(legacyTemplate.notices.length, 1) + + // 新模板(已去掉旧键、写上 new key)不再提示 + const currentTemplate = loadWith(t, { + display: { selection: 'all-secondary', kiosk: true, allowClickThrough: false } + }) + assert.deepEqual(currentTemplate.notices, []) + + // 两个键都在:仍然按「有旧键」提示一次(提示说的是旧键不生效,与新键无关) + const both = loadWith(t, { display: { ignoreMouse: true, allowClickThrough: true } }) + assert.equal(both.notices.length, 1) + assert.equal(both.values.display.allowClickThrough, true, '新键显式 true 仍然算数') +}) + +test('配置文件读不出来/内容不是对象时不报废弃键、也不炸', (t) => { + const missing = loadConfig(path.join(makeDir(t), 'nope.json')) + assert.deepEqual(missing.notices, []) + assert.equal(missing.warning !== null, true) + assert.equal(missing.values.display.allowClickThrough, false) + + const broken = loadWith(t, '{ 这不是 JSON') + assert.deepEqual(broken.notices, []) + + assert.deepEqual(loadWith(t, 'null').notices, []) + assert.deepEqual(loadWith(t, '[1,2,3]').notices, []) +}) diff --git a/console/main/displays.js b/console/main/displays.js index c735c8f..5711586 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -11,6 +11,11 @@ const SELECTION = { SPECIFIED: 'specified' } +// 只比窗口矩形本身:getBounds() 在真实 Electron 里会多带几个键,比整对象会永远判不等 +function sameBounds(a, b) { + return Boolean(a) && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + // 大屏窗口管理:按显示器枚举结果开窗,绝不写死坐标。 // 坐标一律用 DIP(display.bounds),页面内部再用 CSS 缩放适配,两边互不干扰。 class DisplayManager { @@ -137,7 +142,9 @@ class DisplayManager { frame: false, // 与 Console 主窗口同一套兜底:个别平台会忽略 frame:false titleBarStyle: 'hidden', - kiosk: !single && this.display.kiosk !== false, + // 这里刻意不给 kiosk:进 kiosk 必须发生在 setBounds 之后,而 setBounds 与 kiosk + // 的先后顺序是「压不压得住任务栏」的关键,只留 applyGeometry 一处去管, + // 免得构造函数和 applyGeometry 各设一遍、顺序互相打架 autoHideMenuBar: true, backgroundColor: this.display.backgroundColor || '#000000', title: `大屏 · ${page}`, @@ -152,8 +159,17 @@ class DisplayManager { } }) - // 大屏页零交互控件;窗口层再兜一道,防止现场误触鼠标 - if (!single && this.display.ignoreMouse !== false) { + // 大屏页没有可点的控件,但窗口**默认仍然吞掉鼠标**(不穿透)。 + // 这里原来是「默认 setIgnoreMouseEvents(true)」,方向恰好反了: + // setIgnoreMouseEvents(true) 的语义不是「本窗口不吃误触」,而是「本窗口完全忽略鼠标」, + // 点击会落到它后面的窗口或桌面上——现场想防的误触反而变成了误点别的东西; + // 而且被忽略鼠标的窗口点不活、被盖住后也抢不回前台。 + // 只有确实要穿透(把大屏当纯展示层、同一块屏上还要操作别的窗口)才显式配 + // display.allowClickThrough: true,代价见 README。 + // 旧键 display.ignoreMouse 在这里**完全不读**:老机器的配置文件里还被旧版模板写着 + // ignoreMouse: true,读它就等于老机器升级后继续穿透(详见 config.js 的 DEFAULTS 注释); + // 旧键的存在与否由 config.js 在启动时提示一次,这里不掺和。 + if (!single && this.display.allowClickThrough === true) { win.setIgnoreMouseEvents(true) } @@ -233,15 +249,46 @@ class DisplayManager { } } + // 把窗口摆到目标屏、并把窗口态调到目标模式。**这里的调用顺序是「大屏能不能压住任务栏」 + // 的全部理由**,动它之前先读下面 kiosk 分支的注释。 applyGeometry(win, display, single) { if (!win || win.isDestroyed()) return - if (single) { + const bounds = this.geometryFor(display, single) + const wantKiosk = !single && this.display.kiosk !== false + + if (!wantKiosk) { + // 窗口化(单屏调试态)与「多屏但 display.kiosk=false」:一律不置顶。 + // 单屏窗口化的 F11 全屏由用户自己控制,置顶会把它变成一块收不掉、盖住一切的覆盖层; + // 从 kiosk 退回来的窗口也必须在这里把置顶摘掉,否则会带着 WS_EX_TOPMOST 留在窗口化态 if (win.isKiosk()) win.setKiosk(false) if (win.isFullScreen()) win.setFullScreen(false) - } else if (this.display.kiosk !== false) { - win.setKiosk(true) + win.setBounds(bounds) + win.setAlwaysOnTop(false) + return } - win.setBounds(this.geometryFor(display, single)) + + // layout() 会因为插拔屏幕/改分辨率/改缩放在运行期反复调用;已经是对的状态就直接返回, + // 否则每次重排都要退出再进入一次 kiosk,现场大屏会白闪一下 + if (win.isKiosk() && win.isAlwaysOnTop() && sameBounds(win.getBounds(), bounds)) return + + // 顺序不能反,三条各自都是一次现场事故: + // ① 已经在 kiosk 里就先退出。Windows 上对全屏窗口调 setBounds 会把它从真正的全屏态 + // 拽回「恰好铺满该屏的无边框窗口」;此时再 setKiosk(true),Electron 侧会因为内部 + // 仍认为是 kiosk 而直接返回、什么都不做,窗口就永远停在那个假全屏态——它不是 + // topmost,任务栏(topmost)和任何拖到这块屏上的窗口都能画在它上面; + // ② 先 setBounds 定位,后 setKiosk(true) 进全屏。这样窗口进 kiosk 时已经在正确的屏上, + // 真正处于全屏态的窗口既压住任务栏,也不会被拖过来的窗口盖住; + // ③ 置顶放最后,之后不许再有任何改动窗口几何/窗口态的调用,否则 topmost 可能又丢。 + if (win.isKiosk()) win.setKiosk(false) + if (win.isFullScreen()) win.setFullScreen(false) + win.setBounds(bounds) + win.setKiosk(true) + // 层级必须给 'screen-saver':Windows 上只有 pop-up-menu 及以上才画在任务栏之上, + // setAlwaysOnTop(true) 的默认层级 'floating' 仍然在任务栏下面。置顶之后别的窗口 + // 拖到这块屏上也盖不住它,点一下就能回到最前——大屏没有键盘,这是唯一的抢回前台手段。 + // 这里不需要再监听 blur 或起定时器补偿:topmost 是 z 序属性、与焦点无关, + // 定时重设只会白白抢焦点,属于无因之果 + win.setAlwaysOnTop(true, 'screen-saver') } loadPage(entry, display, page, single) { @@ -357,7 +404,7 @@ class DisplayManager { return { created: true, mode: this.modeOf(entry) } } - // 关掉某块屏上的大屏窗口:kiosk 窗口刻意忽略鼠标,误开后只能从这里收 + // 关掉某块屏上的大屏窗口:窗口默认置顶且压住任务栏,误开/要收场时只能从这里收 closeDisplay(displayId) { const entry = this.windows.get(displayId) if (!entry) return false diff --git a/console/main/displays.test.js b/console/main/displays.test.js index b48dcb2..6fe4172 100644 --- a/console/main/displays.test.js +++ b/console/main/displays.test.js @@ -47,6 +47,13 @@ class FakeBrowserWindow extends EventEmitter { this.destroyed = false this.kiosk = options.kiosk === true this.fullScreen = false + // Electron 里 setIgnoreMouseEvents / setAlwaysOnTop 的默认态:不穿透、不置顶 + this.ignoreMouse = false + this.alwaysOnTop = false + this.alwaysOnTopLevel = null + // 调用流水。坐标与窗口态的**先后顺序**本身就是被测行为(见 assertKioskOrder): + // 只看最终状态会漏掉「先进 kiosk 再 setBounds」这种被改回去的回归 + this.calls = [] // 模拟真实 Electron 在切换瞬间回读到的旧值:设了它 isFullScreen() 就返回这个 this.staleFullScreen = undefined this.bounds = { x: options.x, y: options.y, width: options.width, height: options.height } @@ -74,14 +81,19 @@ class FakeBrowserWindow extends EventEmitter { setFullScreen(value) { this.fullScreen = value + this.calls.push(['setFullScreen', value]) } isKiosk() { return this.kiosk } + // 真实 Electron 里 kiosk 就是窗口级全屏,两者同进同退,桩也照这个来, + // 否则「kiosk 窗口其实是全屏」这层关系测不出来 setKiosk(value) { this.kiosk = value + this.fullScreen = value + this.calls.push(['setKiosk', value]) } getBounds() { @@ -90,10 +102,22 @@ class FakeBrowserWindow extends EventEmitter { setBounds(bounds) { this.bounds = bounds + this.calls.push(['setBounds', { ...bounds }]) + } + + isAlwaysOnTop() { + return this.alwaysOnTop + } + + setAlwaysOnTop(value, level) { + this.alwaysOnTop = value + this.alwaysOnTopLevel = value ? level || 'floating' : null + this.calls.push(['setAlwaysOnTop', value, level]) } setIgnoreMouseEvents(value) { this.ignoreMouse = value + this.calls.push(['setIgnoreMouseEvents', value]) } show() { @@ -137,6 +161,12 @@ Module._load = function (request, parent, isMain) { } const { DisplayManager } = require('./displays') Module._load = originalLoad +// 随包发布的内置默认值:用例要验「多屏 kiosk 默认不穿透」,就得拿**真的默认值**去跑, +// 在测试里自己写死一份 false 是验不到 DEFAULTS/config.json 被改回 true 的 +const { DEFAULTS } = require('./config') +// 随包的 config.json 模板(打包态首次启动复制到用户数据目录)是现场真正的默认值, +// 只改代码里的 DEFAULTS 而漏了它,现场升级后仍然是穿透 +const shippedConfig = require('../config.json') const WIDTH = 1920 const HEIGHT = 1080 @@ -145,7 +175,15 @@ function fakeDisplay(id, { internal = false } = {}) { return { id, label: `显示器 ${id}`, internal, bounds: { x: 0, y: 0, width: WIDTH, height: HEIGHT }, scaleFactor: 1 } } -function makeManager(t, { selection = 'all', kiosk = true } = {}) { +// allowClickThrough 默认直接取 config.js 里的内置默认值(false = 不穿透), +// 这样「默认值被改回 true」这件事本身会被用例抓住;要穿透的用例显式传 { allowClickThrough: true }。 +// legacy 用来注入旧机器配置文件里留下的老键(如 ignoreMouse: true),验「老键已失效」 +function makeManager(t, { + selection = 'all', + kiosk = true, + allowClickThrough = DEFAULTS.display.allowClickThrough, + legacy = {} +} = {}) { fakeScreen.displays = [] fakeScreen.primaryId = 1 // 上一个用例若忘了收摊,残留的监听会串到这一个用例里 @@ -160,9 +198,10 @@ function makeManager(t, { selection = 'all', kiosk = true } = {}) { pages: ['standings', 'medals'], pageParam: 'page', kiosk, - ignoreMouse: true, + allowClickThrough, reloadEveryHours: 0, - backgroundColor: '#000000' + backgroundColor: '#000000', + ...legacy } }, server: { host: '0.0.0.0', port: 19540 }, @@ -343,6 +382,181 @@ test('kiosk 窗口不挂 F11 处理', (t) => { assert.equal(manager.windows.get(2).win.webContents.listeners('before-input-event').length, 0) }) +// —— 窗口层行为:能不能点穿、压不压得住任务栏 —— +// 现场回归:① 大屏窗口默认 setIgnoreMouseEvents(true),点击穿透到后面的窗口/桌面, +// 「防误触」变成了「误点别的东西」,被穿透的窗口还点不活; +// ② kiosk 窗口先 setKiosk(true) 再 setBounds,被 Windows 从真正的全屏态拽回 +// 「恰好铺满该屏的无边框窗口」,不是 topmost,任务栏与拖过来的窗口都能盖住它。 +// ①的开关键从 ignoreMouse 改名成了 allowClickThrough:旧键在老机器的用户配置里被写死着, +// 只改默认值救不了它们,用例里的「老机器」就是带 ignoreMouse: true 进来的那份配置。 + +// 窗口调用流水里「最后一次」满足条件的调用下标;找不到返回 -1 +function lastCallIndex(win, name, match = () => true) { + return win.calls.reduce((found, [called, ...args], index) => ( + called === name && match(...args) ? index : found + ), -1) +} + +// kiosk 窗口必须:先 setBounds 定位 → 再 setKiosk(true) → 最后置顶到 screen-saver。 +// 顺序反了就是上面 ② 那个事故;这里断言的是顺序而不是最终状态,正为此。 +function assertKioskOrder(win) { + const boundsAt = lastCallIndex(win, 'setBounds') + const kioskAt = lastCallIndex(win, 'setKiosk', (value) => value === true) + assert.notEqual(kioskAt, -1, 'kiosk 窗口必须真的进过 kiosk') + assert.ok( + boundsAt !== -1 && boundsAt < kioskAt, + `setBounds(#${boundsAt}) 必须在 setKiosk(true)(#${kioskAt}) 之前,实际调用流水:${JSON.stringify(win.calls)}` + ) + + const topAt = lastCallIndex(win, 'setAlwaysOnTop', (value) => value === true) + assert.ok(topAt > kioskAt, `进 kiosk 之后必须置顶,实际调用流水:${JSON.stringify(win.calls)}`) + assert.equal(win.alwaysOnTopLevel, 'screen-saver', 'Windows 上只有 pop-up-menu 及以上才画在任务栏之上') +} + +test('新键缺省 = 不穿透:多屏 kiosk 不调 setIgnoreMouseEvents(true)', (t) => { + // 先把「默认值本身」钉住:这份默认值改成 true,就等于把点击穿透又放回现场 + assert.equal(DEFAULTS.display.allowClickThrough, false, '内置默认值必须是「不穿透」') + assert.equal(shippedConfig.display.allowClickThrough, false, '随包的 config.json 模板也必须是「不穿透」') + assert.equal('ignoreMouse' in DEFAULTS.display, false, '旧键必须从 DEFAULTS 里消失,否则等于还在读它') + + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + + manager.enable() + + for (const id of [1, 2]) { + const win = manager.windows.get(id).win + assert.equal(win.ignoreMouse, false, `display=${id} 默认必须吞掉鼠标:穿透会把点击送给后面的窗口/桌面`) + assert.equal(lastCallIndex(win, 'setIgnoreMouseEvents', (value) => value === true), -1) + } +}) + +test('只有显式配 display.allowClickThrough=true 才穿透', (t) => { + const manager = makeManager(t, { allowClickThrough: true }) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + + manager.enable() + + assert.deepEqual( + manager.windows.get(2).win.calls.filter(([name]) => name === 'setIgnoreMouseEvents'), + [['setIgnoreMouseEvents', true]] + ) +}) + +test('回归钉子:老机器配置里留着 ignoreMouse: true 也不穿透(旧键已失效)', (t) => { + // 这就是本次修复的核心:旧版模板把 ignoreMouse: true 写进了现场机器的用户配置, + // 而 userpaths.js 不会覆盖用户文件——只要还读旧键,那台机器升级后照样穿透 + const manager = makeManager(t, { legacy: { ignoreMouse: true } }) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + + manager.enable() + + for (const id of [1, 2]) { + const win = manager.windows.get(id).win + assert.equal(win.ignoreMouse, false, `display=${id}:旧键 ignoreMouse 已废弃,不能让它把穿透带回来`) + assert.equal(lastCallIndex(win, 'setIgnoreMouseEvents', (value) => value === true), -1) + } + + // 用例本身要确保旧键真的塞进了配置里,否则这条测试等于没测 + assert.equal('ignoreMouse' in manager.display, true) + assert.equal(manager.display.allowClickThrough, false, '新键没被显式写成 true') +}) + +test('单屏窗口化不受穿透开关影响:配了穿透也不穿透、也不置顶', (t) => { + const manager = makeManager(t, { allowClickThrough: true }) + fakeScreen.displays = [fakeDisplay(1, { internal: true })] + + manager.enable() + + const win = manager.windows.get(1).win + assert.equal(win.ignoreMouse, false, '穿透这一行从来只管多屏 kiosk') + assert.equal(lastCallIndex(win, 'setAlwaysOnTop', (value) => value === true), -1, '窗口化的 F11 全屏归用户管,不能置顶') + assert.equal(win.isKiosk(), false) +}) + +test('kiosk 窗口:先 setBounds 再 setKiosk(true),最后置顶 screen-saver', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + + manager.enable() + + for (const id of [1, 2]) { + const win = manager.windows.get(id).win + assertKioskOrder(win) + assert.equal(win.isKiosk(), true) + assert.equal(win.alwaysOnTop, true, '被别的窗口盖住后要能点回最前') + } +}) + +test('面板手动开的 kiosk 窗口同样置顶、不穿透', (t) => { + const manager = makeManager(t) + const primary = fakeDisplay(1, { internal: true }) + const external = fakeDisplay(2) + fakeScreen.displays = [primary, external] + + assert.deepEqual(manager.openOn(external, { page: 'medals', single: false }), { created: true, mode: 'kiosk' }) + + const win = manager.windows.get(2).win + assertKioskOrder(win) + assert.equal(win.ignoreMouse, false) +}) + +test('kiosk 已是目标态时不重复进出 kiosk(重排不该让大屏白闪)', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + manager.enable() + + const win = manager.windows.get(2).win + const callsBefore = JSON.stringify(win.calls) + + // 一次「屏幕没变」的重排:窗口态已经是目标态,就一个窗口调用都不该发生 + manager.layout() + + assert.equal(JSON.stringify(win.calls), callsBefore) + assertKioskOrder(win) +}) + +test('插拔屏幕就地换模式:置顶跟着 kiosk 来回,顺序不变', (t) => { + const manager = makeManager(t) + const primary = fakeDisplay(1, { internal: true }) + fakeScreen.displays = [primary] + manager.enable() + + const win = manager.windows.get(1).win + assert.equal(win.alwaysOnTop, false, '单屏窗口化不置顶') + + // 插上电视:同一块屏上的窗口就地转 kiosk,必须补上置顶 + fakeScreen.displays = [primary, fakeDisplay(2)] + manager.layout() + assertModeConsistent(manager, 1, false) + assertKioskOrder(win) + + // 电视拔了:转回窗口化,置顶必须摘掉,否则窗口化态会继续盖住一切 + fakeScreen.displays = [primary] + manager.layout() + assertModeConsistent(manager, 1, true) + assert.equal(win.alwaysOnTop, false) + assert.equal(win.alwaysOnTopLevel, null) +}) + +test('收窗路径不被置顶破坏:closeDisplay / closeAll 仍收得掉 kiosk 窗口', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + manager.enable() + + const external = manager.windows.get(2).win + const primary = manager.windows.get(1).win + assert.equal(manager.closeDisplay(2), true) + assert.equal(external.isDestroyed(), true) + assert.equal(manager.windows.has(2), false) + assert.equal(manager.closeDisplay(2), false, '已经收掉的窗口不能再报成功') + + manager.closeAll() + assert.equal(primary.isDestroyed(), true) + assert.equal(manager.windows.size, 0) + assert.equal(manager.enabled, false) +}) + // 屏幕监听的三条不变式,对应审查日志模块13 [1] P1: // ① 任何一次「屏幕数量变化」都不能把监听永久摘掉(只有 closeAll 摘) // ② 事件到达后 600ms 防抖重排,从 1 块恢复 2 块要自动回到 kiosk,从 2 块变 1 块要收回窗口化 diff --git a/console/main/index.js b/console/main/index.js index ec1b672..c9200fc 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -609,6 +609,10 @@ function main() { log.push('console', `用户数据目录:${userPaths.root}(Console 配置、服务端 config.yaml、rules 都在这里,覆盖安装不会丢)`) } if (cfg.warning) log.push('console', cfg.warning) + // 配置里还留着改名前的旧键(如 display.ignoreMouse):只在启动这里提示一次。 + // 判断与文案都在 config.js 的 loadConfig 里算好,这里只负责落日志—— + // 大屏重排路径(displays.js)绝不碰这件事,否则每次插拔屏幕都要刷一行 + for (const notice of cfg.notices) log.push('console', `警告:${notice}`) const resolved = resolveExecutable(cfg.values.server.exe, cfg.path, bundleDir) exePath = resolved.path diff --git a/console/package.json b/console/package.json index 6111bc0..161eb5b 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.3.0", + "version": "0.4.0", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/使用手册.md b/docs/使用手册.md index 456675a..8c48ac6 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -426,6 +426,15 @@ **逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。 +**局域网大屏**:这张卡片是给「用别的设备当大屏」准备的——把主机在局域网里可用的地址拼成完整链接,手机 / 平板 / 电视浏览器打开就是一块只读大屏(卡片上就写着「在别的设备浏览器打开下面的地址,就是一块大屏。」)。 + +- 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」; +- **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址; +- **空态**:显示「未检测到局域网地址(可能是没插网线 / 只有回环)。」——意思是主机当前没有别的设备能用上的地址(网线没插、网卡没起来,或只剩本机回环地址),先在 Windows 网络设置里确认主机已连上局域网;如果显示的是「服务端没有下发大屏端口,暂时拼不出能在别的设备打开的地址。」,那是服务端异常,重启 Console / 服务端后再看; +- 卡片底部注明「地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的」——**平板 / 电视那边不用登录**,只有看这张卡片(也就是本管理面板)才需要身份; +- **权限**:这张卡片需要**录入员(recorder)及以上**登录才看得到,只读访客(guest)看到的是「只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。」;账号权限不足时提示「当前账号没有查看权限,请先登录(该接口需要录入员及以上)。」; +- 复制失败(浏览器不允许自动复制)时会提示「复制失败 / 浏览器不允许自动复制,请手动选中地址复制」,手动选中地址复制即可。 + ### 9.1 显示器菜单(重建被关掉的大屏窗口) 扩展屏上的大屏窗口平时由服务管理程序(Console)按配置自动开出来,只有在**有人手动关掉了某块屏的大屏窗口**时才需要这一节。 @@ -439,7 +448,7 @@ - 该屏已经有窗口 → **聚焦窗口**:把窗口提到最前,不重建、不打断正在展示的比分; - 该屏还没有窗口,且允许多屏全屏 → **创建全屏窗口**; - 该屏还没有窗口,但不允许全屏 → **创建窗口(窗口化)**,行内会注明「单显示器 / 主显示器只能窗口化」; - - 已开窗口的行还有一个 **关闭窗口**:全屏大屏窗口是锁定鼠标的,万一开到了错误的屏上,从大屏本身退不出来,用这个按钮收掉; + - 已开窗口的行还有一个 **关闭窗口**:全屏大屏窗口画面上没有任何可操作的按钮(也不响应键盘),万一开到了错误的屏上,从大屏本身退不出来,用这个按钮收掉; 4. **重新排布(按设置重建全部窗口)**:按服务管理程序里的选屏配置重建所有大屏窗口。不在该配置范围内的窗口会被关掉,所以日常只想恢复某一块屏时,请优先用上面的「创建」按钮。 **为什么单显示器 / 主显示器只能创建窗口化窗口**:全屏窗口会盖住整块屏,而且没有标题栏和关闭按钮。单显示器上开全屏,等于把操作人员的桌面整个盖住;主显示器上开全屏,会把服务管理窗口、管理面板这些工作窗口全部遮住——现场又没有键盘可以退出来。所以这两种情况下按钮只提供窗口化:窗口约占屏幕 80%、居中显示、带标题栏,随时可关。只有「本机有多于一块显示器,且目标不是主显示器」时才放行全屏。 @@ -948,7 +957,7 @@ > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 -> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 +> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 ### 17.2 轮播与锁定 diff --git a/docs/升级说明.md b/docs/升级说明.md index cc77f46..a22f4a3 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,63 @@ --- -## dev_0.3.0(当前版本) +## dev_0.4.0(当前版本) + +### 一句话 + +**没有数据迁移,旧赛事项目包照常导入**:本版修了两个现场问题(kiosk 窗口误点穿、kiosk 窗口被任务栏盖住)并新增一个功能(大屏控制页的「局域网大屏」卡片)。⚠️ 唯一要留意的:**老机器配置文件里的 `display.ignoreMouse: true` 已废弃、不再生效**——升级后大屏窗口自动变成「不穿透」,想恢复穿透要改用新键名。 + +### ① kiosk 窗口不再点击穿透,旧键 `display.ignoreMouse` 已废弃 + +- **以前**:多屏 kiosk 窗口默认开启 `setIgnoreMouseEvents(true)`,本意是「不接收误触」,但它的真实语义是「**本窗口完全忽略鼠标**」——点击会落到大屏**后面**的窗口或桌面上,现场想防的误触反而变成误点别的东西;被忽略鼠标的窗口也点不活、被别的窗口盖住后抢不回前台。 +- **现在**:大屏窗口**默认吞掉鼠标点击、不穿透**,点击不会漏到后面的窗口或桌面,被盖住后点一下就能回到最前。 +- **开关改名**:`display.ignoreMouse` → **`display.allowClickThrough`**(默认 `false`)。旧键现在**完全不读**——老机器配置文件里那个 `ignoreMouse: true` 不会再让大屏穿透。 + - **为什么换键名而不是只改默认值**:旧版默认就是穿透,而且旧版模板已经把 `ignoreMouse: true` 写进了现场机器的配置文件(`%LOCALAPPDATA%\ScoreboardConsole\console\config.json`),而 Console 的规矩是**已存在的用户配置绝不覆盖**——光改默认值救不了老机器,配置文件里那个显式 `true` 会一直压着默认值;也没法区分「操作员刻意要穿透」和「旧模板留下的 true」。换个新键名,旧文件里的旧键自然失效,现场不动配置文件就回到「不穿透」。 +- **升级后第一次启动**,如果配置文件里还留着旧键,Console 会在 `logs\console.log` 里记一条**中文警告(只记一次,不随大屏重排重复刷)**,原文是: + + > 警告:配置项 display.ignoreMouse 已废弃、不再生效:本机配置文件里写的 ignoreMouse: true 会被忽略。大屏窗口现在默认「不穿透」,点击不会再漏到大屏后面的窗口或桌面;要让大屏恢复点击穿透,请改用 display.allowClickThrough: true + + (`ignoreMouse` 后面跟的值会原样照抄你配置文件里写的内容。)**旧键的值一律忽略,也不会被自动改写进你的配置文件。** +- **确实需要穿透**(把大屏当纯展示层、同一块屏上还要操作别的窗口)才显式写 `display.allowClickThrough: true`,代价是:点击会漏到大屏后面的窗口 / 桌面(误触会点到别的东西),大屏也点不活。 + +### ② kiosk 窗口现在压得住任务栏 + +- **以前**:先 `setKiosk(true)` 再 `setBounds(...)`,在 Windows 上会把窗口从真正的全屏态拽回「恰好铺满该屏的无边框窗口」——它不是置顶层,**任务栏与任何拖到这块屏上的窗口都能盖在它上面**;再叠加上一条的穿透,大屏被盖住后还点不活。 +- **现在**:多屏 kiosk 窗口固定置顶在 **`screen-saver` 层级**(Windows 上 `setAlwaysOnTop(true)` 的默认层级 `floating` 仍画在任务栏**下面**,只有 pop-up-menu 及以上才压得住任务栏)。效果:**任务栏盖不住它,别的窗口拖到这块屏上也盖不住它,被盖住后点一下就能回到最前**。 +- 调用顺序固定为「**先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true, 'screen-saver')`**」,并加了「已经是对的状态就直接返回」的守卫,避免重排时反复进出 kiosk 导致大屏白闪。窗口化(单屏调试态)会把置顶摘掉。 + +### ③ 大屏控制页新增「局域网大屏」卡片 + +- 卡片列出主机在局域网里**可用的地址**,拼成完整链接(形如 `http://192.168.1.5:19540/display`):链接可点开,旁边有**复制**按钮(复制成功短暂显示「已复制」),**第一个地址还带二维码**,手机扫码即可打开; +- 地址与端口由服务端 `GET /api/system/network` 下发(真实监听端口与路径,不是写死的),按现场最可能用到的顺序排(`192.168.*` → `10.*` → `172.16–31.*` → 其它); +- 没有可用地址时显示「未检测到局域网地址(可能是没插网线 / 只有回环)。」——这不是故障,表示主机当前没有别的设备能连上的地址; +- 这张卡片**需要录入员(recorder)及以上**登录才看得到,只读访客会被提前拦住。 + +### ④ 没有数据迁移 + +- 本版**没有任何数据库迁移**,也没有设置项迁移;旧赛事项目包照常导入、切换、导出,已有榜单数字与升级前一致。 +- 配置文件里那份旧键我们**不会**替你删改(现场那份原样留着,人自己决定),它只是不再生效。 + +### ⑤ 升级后 3 分钟核对 + +1. 启动 Console,打开 `logs\console.log`:确认没有意外的报错; +2. 看多屏大屏:窗口应当压住任务栏、点一下能回到最前,且**点击不会再点到桌面或后面的窗口**(即不穿透); +3. 若有老机器配置文件里写着 `ignoreMouse: true`,日志里应能看到上面那条「已废弃」警告——这是正常的,行为已是「不穿透」; +4. (可选)确实要穿透的机器:在 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` 里写 `"allowClickThrough": true`,重启 Console; +5. 用录入员及以上账号进「大屏控制」页,确认右侧有「局域网大屏」卡片,点「复制」能复制链接、手机能扫第一个地址的二维码打开大屏; +6. 用《上线检查清单》过一遍。 + +### ⑥ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 多屏 kiosk 的鼠标 / 置顶行为 | 《部署指南》6.2 配置表、7.4 | +| 「局域网大屏」卡片怎么用 | 《使用手册》第 9 节 | +| 接口与字段 | `devdocs/05` 大屏控制与运维 | + +--- + +## dev_0.3.0 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index e44fb7a..bf17b89 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -114,7 +114,7 @@ 检查 HDMI、扩展显示模式、Console「大屏窗口」状态,点「重新排布」;服务端未就绪时最多等 20 秒才开屏;单屏窗口化时按 `F11` 切全屏。详见 [部署指南 · 故障排查](./部署指南.md#9-故障排查)。 **Q22:单屏和多屏的显示方式有什么不同?** -单屏时大屏以窗口化方式打开,按 `F11` 切全屏;多屏时除内置屏外每块屏一个 kiosk 全屏窗口,各锁一页,不响应 F11,也不接收鼠标。 +单屏时大屏以窗口化方式打开,按 `F11` 切全屏;多屏时除内置屏外每块屏一个 kiosk 全屏窗口,各锁一页,不响应 F11,**默认吞掉鼠标点击、不穿透**(点击不会漏到后面的窗口或桌面,被别的窗口盖住后点一下就能回到最前);确实要穿透时在配置里显式写 `display.allowClickThrough: true`(旧键 `display.ignoreMouse` 已废弃、不再生效),详见 [部署指南 6.2](./部署指南.md)。 **Q23:大屏为什么不自动轮播?** Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点「解锁」后开始轮播,每 20 秒一页;公告页不参与轮播。 diff --git a/docs/部署指南.md b/docs/部署指南.md index ba34c31..2519f6d 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -317,7 +317,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da | `display.pages` | `["standings","medals","individual","results"]` | 多屏时按屏序轮转锁定的页面;可加入 `live`(实时赛况)等页面值,见 7.4 | | `display.pageParam` | `page` | 页面 URL 参数名 | | `display.kiosk` | `true` | 多屏时是否 kiosk 全屏(单屏恒为窗口化) | -| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 | +| `display.allowClickThrough` | `false` | 多屏 kiosk 窗口是否**允许点击穿透**。默认 `false` = 窗口吞掉鼠标点击:点击不会漏到大屏后面的窗口 / 桌面,被别的窗口盖住后点一下就能回到最前。显式写 `true` 才穿透,代价是**误触会点到后面别的东西**、大屏也点不活。本键是 `display.ignoreMouse` **改名**来的 | | `display.reloadEveryHours` | `24` | 定时重载兜底,`0` = 关闭 | | `display.waitForServerMs` | `20000` | 服务端未就绪时,最多等这么久也先把大屏开出来 | | `display.backgroundColor` | `#0b0f1a` | 大屏窗口底色,减少加载白闪 | @@ -328,6 +328,8 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da | `log.maxFileBytes` | `4194304` | 单个日志文件上限,超出后轮转为 `.1` | > 配置缺项会自动使用内置默认值;JSON 语法错误时回落到全默认值,并在窗口顶部提示。 +> +> 配置文件里若还留着**已废弃的** `display.ignoreMouse`(旧版模板会写 `ignoreMouse: true`),这个键现在**完全不读**,启动时会在 `logs\console.log` 里记一条中文警告(只在启动记一次,不随大屏重排重复刷):旧键已废弃、现在默认不穿透、想穿透改用 `display.allowClickThrough: true`。旧键的值一律忽略,也**不会**被自动改写进你的配置文件。 ### 6.3 修改端口 @@ -369,7 +371,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da - 除内置屏外的每块屏各开一个 kiosk 全屏窗口; - 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配; -- 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识; +- 多屏 kiosk 不响应 F11,也不显示任何调试标识;窗口**默认吞掉鼠标点击、不穿透**——点击不会漏到大屏后面的窗口或桌面,被别的窗口盖住后点一下就能回到最前,只有显式配 `display.allowClickThrough: true` 才会让点击穿透(代价是误触会点到后面的东西、大屏也点不活);kiosk 窗口固定置顶在 `screen-saver` 层级,**压得住任务栏**,别的窗口拖到这块屏上也盖不住它; - 可用的页面值:`standings`(总积分榜)、`medals`(单位奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`live`(实时赛况)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。把 `live` 加进 `display.pages`,就能把某块屏固定成「实时赛况」(`live` 也认 `live-results` / `实时赛况` / `实时` / `赛况` 这些别名,与其它页一样)。 - 这一页现在叫 **「单位奖牌榜」**(旧称「团体奖牌榜」容易和「团体项目的奖牌」混淆,已改名);**旧名 `团体奖牌榜` 仍作为别名被接受**,老配置里的 `display.pages`、大屏地址里 `page=团体奖牌榜` 这类老链接都照常生效,不需要改。 @@ -377,6 +379,8 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da 管理端「大屏控制」页有**显示器**卡片,可以给指定显示器单独开窗或收窗。这张卡片只在**从服务管理窗口点「打开服务端管理面板」打开的那个窗口**里可用;用浏览器直接打开管理面板时,卡片会提示此功能不可用。 +同一页右侧还有「**局域网大屏**」卡片:把本机可用的局域网地址拼成完整链接(可点开、可一键复制),第一个地址还带二维码,别的设备扫码即可当大屏用。卡片需**录入员及以上**登录才看得到,用法见《使用手册》第 9 节。 + - 列出本机每块显示器(名称、分辨率、缩放、是否主显示器、当前有没有大屏窗口、是全屏还是窗口化); - 先在「窗口页面」里选页面,再对某块屏点「创建全屏窗口」或「创建窗口(窗口化)」;已经开着窗口的屏显示「聚焦窗口」与「关闭窗口」; - **全屏只对「多显示器 + 非主显示器」开放**:只接一块屏、或选中的是主显示器时只能创建窗口化窗口(全屏会把桌面整个盖住又退不出来)。请求被降级时窗口照开,提示里会写明原因; @@ -415,6 +419,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da http://<主机IP>:19540/display ``` +- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到); - 需要主机设置固定 IP,且设备与主机在同一局域网; - 安卓电视一般常亮,无需额外防熄屏; - 浏览器方案**不受** Console 的 kiosk、防熄屏、选屏控制,字体与动画表现以实际设备为准; diff --git a/server/internal/httpapi/network.go b/server/internal/httpapi/network.go new file mode 100644 index 0000000..f9e69e2 --- /dev/null +++ b/server/internal/httpapi/network.go @@ -0,0 +1,187 @@ +package httpapi + +import ( + "bytes" + "net" + "net/http" + "sort" +) + +// 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从 +// 127.0.0.1 打开的,浏览器里的 location.host 对平板毫无用处,所以地址只能由服务端枚举网卡得到。 +// +// 路径也随响应一起下发:/ → index.html、/display → display.html、/admin → admin.html 的映射 +// 写在 server.go 的 staticHandler 里。前端各自硬编码一份,改前端产物名时就会有两处要跟着动。 +const ( + displayPagePath = "/display" + adminPagePath = "/admin" +) + +// lanAddress 是一个「局域网里其他设备能连上这台机器」的候选地址。 +// +// family 目前恒为 ipv4:IPv6 要写成 http://[fe80::1%eth0]:port/display,方括号与 %zone +// 粘到平板浏览器里出错率高,而现场本来就是 IPv4 内网。留着这个字段是为了以后真要补 IPv6 时 +// 前端不必改结构(见 06/07 文档里的前端约定)。 +type lanAddress struct { + IP string `json:"ip"` + Interface string `json:"interface"` + Family string `json:"family"` +} + +// networkInfo 是 GET /api/system/network 的响应体。 +type networkInfo struct { + Port int `json:"port"` + DisplayPath string `json:"displayPath"` + AdminPath string `json:"adminPath"` + Addresses []lanAddress `json:"addresses"` +} + +// ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。 +// +// 单独抽出来是为了让筛选与排序成为纯函数:net.Interface 的 Addrs() 直接问操作系统, +// 拿真网卡根本造不出「网线 + WiFi 双网卡」「两张网卡配同一个地址」这类现场, +// 而这正是最需要钉住的行为。测试构造快照即可,真网卡一行都不用碰。 +type ifaceSnapshot struct { + Name string + Flags net.Flags + Addrs []net.Addr +} + +// snapshotInterfaces 读一次本机网卡。读不到(极罕见)时返回空切片: +// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。 +func (s *server) snapshotInterfaces() []ifaceSnapshot { + list, err := net.Interfaces() + if err != nil { + s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) + return nil + } + out := make([]ifaceSnapshot, 0, len(list)) + for _, iface := range list { + addrs, err := iface.Addrs() + if err != nil { + // 单张网卡读不到地址不影响别的网卡:现场常见的是一张 down 掉的虚拟网卡报错。 + continue + } + out = append(out, ifaceSnapshot{Name: iface.Name, Flags: iface.Flags, Addrs: addrs}) + } + return out +} + +// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前。 +// 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。 +func lanAddresses(ifaces []ifaceSnapshot) []lanAddress { + // 候选先带上 4 字节形式:去重、排序用得到,且避免在比较器里反复做 To4。 + type candidate struct { + entry lanAddress + ip4 net.IP + } + seen := make(map[string]struct{}) + cands := make([]candidate, 0, len(ifaces)) + for _, iface := range ifaces { + if !usableInterface(iface.Flags) { + continue + } + for _, raw := range iface.Addrs { + ip4 := addrIP(raw).To4() + if ip4 == nil || !usableLanIPv4(ip4) { + continue + } + key := ip4.String() + // 同一地址配在多个接口上(桥接、双网卡同网段)时只留先出现的一条: + // 列表里两条一模一样的链接,现场会以为是两个不同的东西。 + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + cands = append(cands, candidate{ + entry: lanAddress{IP: key, Interface: iface.Name, Family: "ipv4"}, + ip4: ip4, + }) + } + } + + sort.Slice(cands, func(i, j int) bool { + a, b := cands[i], cands[j] + if ra, rb := lanRank(a.ip4), lanRank(b.ip4); ra != rb { + return ra < rb + } + // 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序, + // 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。 + if c := bytes.Compare(a.ip4, b.ip4); c != 0 { + return c < 0 + } + return a.entry.Interface < b.entry.Interface + }) + + out := make([]lanAddress, 0, len(cands)) + for _, c := range cands { + out = append(out, c.entry) + } + return out +} + +// usableInterface 判断一张网卡能不能贡献地址: +// - 必须 UP —— 网线没插、接口被 down 掉时,配置还在但包根本发不出去; +// - 排除回环接口 —— lo 上的 127.x 对别的设备毫无意义(面板自己就是从 127.0.0.1 打开的, +// 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。 +func usableInterface(flags net.Flags) bool { + return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0 +} + +// usableLanIPv4 判断一个 IPv4 地址能不能给局域网其他设备用。 +// 逐条排除的都是「拿得到、连不上」的地址: +// - 127.0.0.0/8 回环:只有本机能访问; +// - 169.254.0.0/16 link-local:DHCP 没拿到地址时系统自配的,同网段设备通常也到不了; +// - 0.0.0.0:未指定地址,拼不成 URL; +// - 224.0.0.0/4 组播:不是单播目的地址。 +func usableLanIPv4(ip4 net.IP) bool { + switch { + case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsLinkLocalUnicast(), ip4.IsMulticast(): + return false + } + return true +} + +// lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。 +// +// 分档依据是各私有段在实际现场的常见程度: +// - 0:192.168.0.0/16 —— 家用与小型办公路由器的出厂段,现场路由器 / 无线热点几乎都在这里; +// - 1:10.0.0.0/8 —— 稍大的内网、企业路由器与手机热点(部分机型)常用的段; +// - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它), +// 真要拿它给平板用也得排在直连网段之后; +// - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位: +// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个。 +func lanRank(ip4 net.IP) int { + switch { + case ip4[0] == 192 && ip4[1] == 168: + return 0 + case ip4[0] == 10: + return 1 + case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31: + return 2 + default: + return 3 + } +} + +// addrIP 从 net.Addr 里取出 IP。网卡地址实际都是 *net.IPNet,*net.IPAddr 只是兜底。 +func addrIP(addr net.Addr) net.IP { + switch a := addr.(type) { + case *net.IPNet: + return a.IP + case *net.IPAddr: + return a.IP + } + return nil +} + +// systemNetwork 是 GET /api/system/network:大屏控制页用它拼出平板能直接打开的链接。 +// 端口取服务端实际监听的那个(见 server.port),不在这里另写一个数字。 +func (s *server) systemNetwork(w http.ResponseWriter, r *http.Request) (any, error) { + return networkInfo{ + Port: s.port, + DisplayPath: displayPagePath, + AdminPath: adminPagePath, + Addresses: lanAddresses(s.snapshotInterfaces()), + }, nil +} diff --git a/server/internal/httpapi/network_internal_test.go b/server/internal/httpapi/network_internal_test.go new file mode 100644 index 0000000..1c28a72 --- /dev/null +++ b/server/internal/httpapi/network_internal_test.go @@ -0,0 +1,145 @@ +package httpapi + +import ( + "encoding/json" + "net" + "reflect" + "strings" + "testing" +) + +// lanIf 造一张网卡快照:测试里要的是「现场双网卡」这类布局,不能去动真网卡。 +func lanIf(name string, flags net.Flags, cidrs ...string) ifaceSnapshot { + addrs := make([]net.Addr, 0, len(cidrs)) + for _, cidr := range cidrs { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + panic("测试地址非法: " + cidr) + } + addrs = append(addrs, &net.IPNet{IP: ip, Mask: ipnet.Mask}) + } + return ifaceSnapshot{Name: name, Flags: flags, Addrs: addrs} +} + +func lanIPs(got []lanAddress) []string { + out := make([]string, 0, len(got)) + for _, a := range got { + out = append(out, a.IP) + } + return out +} + +// 只回「平板真的连得上」的 IPv4:回环、link-local、未指定、组播、IPv6、down 掉的接口全要滤掉。 +// 这条用例钉住的是现场最容易踩的坑——面板从 127.0.0.1 打开,顺手把回环地址显示出去, +// 现场照着抄到平板上就是打不开。 +func TestLanAddressesFiltersUnusable(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8", "::1/128"), + // 网线插着但 DHCP 没拿到地址:系统自配 169.254.x,同网段设备通常也到不了。 + lanIf("eth0", net.FlagUp, "169.254.10.5/16", "fe80::a00:27ff:fe4e:66a1/64"), + // 接口 down 掉:配置还在,包发不出去。 + lanIf("eth1", 0, "192.168.5.5/24"), + // 非回环接口上被误配了回环地址(别名地址写错段):按地址本身也得滤掉, + // 光看接口标志漏得掉这一条。 + lanIf("eth2", net.FlagUp, "127.0.0.5/8"), + lanIf("docker0", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4", "172.20.0.1/16"), + // 双栈内网里网卡还会带全局 IPv6:拼 URL 要写方括号且常带 %zone,平板粘过去容易出错, + // 所以一律不往下发(family 字段留着,将来真要用才补)。 + lanIf("wlan0", net.FlagUp, "2400:3200::1/64", "10.1.2.3/8"), + } + + want := []string{"10.1.2.3", "172.20.0.1"} + got := lanAddresses(ifaces) + if !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("可用地址应为 %v,得到 %v", want, lanIPs(got)) + } + // family 是给前端拼 URL 用的:目前只回 IPv4,字段也必须存在。 + for _, a := range got { + if a.Family != "ipv4" { + t.Fatalf("family 应为 ipv4,得到 %q(%s)", a.Family, a.IP) + } + } +} + +// 一个地址都挑不出来时必须回空切片而不是 nil:JSON 里要是 null,前端分不清 +// 「没有局域网地址」和「服务端没给这个字段」。 +func TestLanAddressesEmptyResultIsNonNil(t *testing.T) { + cases := map[string][]ifaceSnapshot{ + "没有网卡": nil, + "只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")}, + "只有 IPv6": {lanIf("eth0", net.FlagUp, "fe80::1/64")}, + "接口全 down": {lanIf("eth0", 0, "192.168.1.23/24")}, + } + for name, ifaces := range cases { + got := lanAddresses(ifaces) + if got == nil { + t.Fatalf("%s:应回空切片而不是 nil", name) + } + if len(got) != 0 { + t.Fatalf("%s:不该有地址,得到 %v", name, lanIPs(got)) + } + raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got}) + if err != nil { + t.Fatalf("%s:序列化失败 %v", name, err) + } + if !strings.Contains(string(raw), `"addresses":[]`) { + t.Fatalf("%s:addresses 应序列化为 [],得到 %s", name, raw) + } + } +} + +// 同一地址配在多张网卡上(桥接、双网卡同网段)时只留一条,且保留先出现的那张网卡名; +// 顺序按「像不像常见内网段」分档,同档再按地址数值升序——否则列表每次刷新都在跳, +// 现场刚记住的链接下次就不是第一条了。 +func TestLanAddressesDedupAndOrder(t *testing.T) { + ifaces := []ifaceSnapshot{ + // 常见的「网线 eth0 + WiFi wlan0」现场:WiFi 落在路由器出厂段 192.168.x。 + lanIf("eth0", net.FlagUp, "172.20.1.5/16", "192.168.1.23/24", "8.8.8.8/32"), + lanIf("wlan0", net.FlagUp, "192.168.1.9/24", "10.0.0.4/8", "192.168.1.100/24"), + lanIf("br0", net.FlagUp, "192.168.1.23/24"), + } + + want := []string{"192.168.1.9", "192.168.1.23", "192.168.1.100", "10.0.0.4", "172.20.1.5", "8.8.8.8"} + got := lanAddresses(ifaces) + if !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("地址顺序应为 %v,得到 %v", want, lanIPs(got)) + } + // 192.168.1.23 先出现在 eth0 上,br0 上的同一地址要被丢掉、也不能改写 interface。 + if got[1].Interface != "eth0" { + t.Fatalf("去重后应保留先出现的接口名 eth0,得到 %q", got[1].Interface) + } +} + +// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余,越靠前越是现场真在用的那条。 +func TestLanRankPrefersCommonLanSegments(t *testing.T) { + order := []string{ + "192.168.0.1", "192.168.255.254", // 路由器出厂段 + "10.0.0.1", "10.255.255.254", // 企业内网 / 热点 + "172.16.0.1", "172.31.255.254", // 最少见,多是虚拟网桥 + "172.15.0.1", "172.32.0.1", "203.0.113.9", // 172.16/12 之外 + } + for i := 1; i < len(order); i++ { + prev, curr := net.ParseIP(order[i-1]).To4(), net.ParseIP(order[i]).To4() + if lanRank(prev) > lanRank(curr) { + t.Fatalf("%s 不该排在 %s 之后(%d > %d)", order[i-1], order[i], lanRank(prev), lanRank(curr)) + } + } + if lanRank(net.ParseIP("172.15.0.1").To4()) != 3 || lanRank(net.ParseIP("172.32.0.1").To4()) != 3 { + t.Fatal("172.16/12 的两侧边界不该被当成常见内网段") + } +} + +// 网卡地址实际都是 *net.IPNet,但兜底分支也要能取到 IP,否则换平台(如某些 BSD/虚拟网卡) +// 会静默把地址全滤掉,现场表现为「明明有网却显示未检测到」。 +func TestAddrIPHandlesBothAddrTypes(t *testing.T) { + if got := addrIP(&net.IPAddr{IP: net.ParseIP("192.168.1.23")}); got.String() != "192.168.1.23" { + t.Fatalf("*net.IPAddr 应取到 IP,得到 %v", got) + } + if got := addrIP(&net.IPNet{IP: net.ParseIP("10.0.0.4"), Mask: net.CIDRMask(8, 32)}); got.String() != "10.0.0.4" { + t.Fatalf("*net.IPNet 应取到 IP,得到 %v", got) + } + // 认不出的地址类型不能让整条链路 panic。 + if got := addrIP(nil); got != nil { + t.Fatalf("未知地址类型应返回 nil,得到 %v", got) + } +} diff --git a/server/internal/httpapi/network_test.go b/server/internal/httpapi/network_test.go new file mode 100644 index 0000000..3551197 --- /dev/null +++ b/server/internal/httpapi/network_test.go @@ -0,0 +1,109 @@ +package httpapi_test + +import ( + "encoding/json" + "net" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/config" + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" +) + +// GET /api/system/network 是给大屏控制页拼「平板能直接打开的链接」用的: +// recorder 起可读,guest 与未登录一律 403(与 /api/system/about 同档——机器级信息不给匿名访客)。 +func TestSystemNetworkPermission(t *testing.T) { + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Port = 18080 }) + + guest, _ := env.client() + if status, env2 := env.do(guest, http.MethodGet, "/api/system/network", nil, false); status != http.StatusForbidden || + env2.Error == nil || env2.Error.Code != "FORBIDDEN" { + t.Fatalf("guest 读 network 应 403 FORBIDDEN,得到 %d %+v", status, env2.Error) + } + + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + status, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("recorder 读 network 应 200,得到 %d %+v", status, env2.Error) + } +} + +// 响应形状是对前端契约:字段一个不多一个不少,port 必须是实际监听端口(测试注入 18080), +// 路径字段则要与服务端真的托管着的页面一致——前端是照它拼链接的,写错了就是死链。 +func TestSystemNetworkShape(t *testing.T) { + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Port = 18080 }) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false) + if status != http.StatusOK { + t.Fatalf("读 network 应 200,得到 %d", status) + } + var body map[string]any + if err := json.Unmarshal(env2.Data, &body); err != nil { + t.Fatalf("解析 network: %v (%s)", err, env2.Data) + } + assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses"}) + + if got, ok := body["port"].(float64); !ok || int(got) != 18080 { + t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"]) + } + if body["displayPath"] != "/display" || body["adminPath"] != "/admin" { + t.Fatalf("路径字段不符: displayPath=%#v adminPath=%#v", body["displayPath"], body["adminPath"]) + } + + // 路径字段指向的页面必须真的存在:改了前端产物名就会在这里红灯,而不是等到现场打开是 404。 + for _, path := range []string{body["displayPath"].(string), body["adminPath"].(string)} { + code, raw := env.doRaw(client, http.MethodGet, path, nil, "", false) + if code != http.StatusOK || !strings.Contains(string(raw), " ({ source: 'auto', eventIds: [], events: [] }), ), + /** + * 本机局域网地址(`GET /api/system/network`,recorder 及以上)。 + * + * 这句是给「大屏控制」页的局域网卡片用的:连不上后端时退回示例地址, + * 让示例模式也能看到卡片长什么样;**403 不降级**——访客必须看到「没权限」而不是 + * 一条看着能用的假地址(那会让人拿着错的链接去别的设备上试)。 + */ + systemNetwork: (): Promise => + call( + () => client.system.network(), + () => mockDb.network(), + ), + login: (username: string, password: string): Promise => callWrite(() => client.auth.login(username, password)), diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index 40b5847..8079511 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -11,6 +11,7 @@ import type { EventCategory, EventSettings, HealthInfo, + NetworkInfo, Page, PointAdjustment, PublishPayload, @@ -834,6 +835,22 @@ class MockDb { health(): HealthInfo { return { version: 'mock' } } + + /** + * 示例模式下的本机网络信息:字段形状与 `GET /api/system/network` 完全一致, + * IP 用私有段里的一条假地址,绝不能让示例数据里出现真机或公网地址。 + */ + network(): NetworkInfo { + return { + port: 19540, + displayPath: '/display', + adminPath: '/admin', + addresses: [ + { ip: '192.168.1.23', interface: 'eth0', family: 'ipv4' }, + { ip: '10.0.8.7', interface: 'wlan0', family: 'ipv4' }, + ], + } + } } function round(value: number): number { diff --git a/web/src/admin/components/icons.ts b/web/src/admin/components/icons.ts index 8b3db2b..7129437 100644 --- a/web/src/admin/components/icons.ts +++ b/web/src/admin/components/icons.ts @@ -24,6 +24,7 @@ export const ICON_PATHS = { maximize: 'M5 5h14v14H5z', restore: 'M8 8h11v11H8zM5 16V5h11', check: 'M5 12.5 10 17l9-10', + copy: 'M9 9h11v11H9zM15 9V4H4v11h4', warning: 'M12 4l9 16H3l9-16Zm0 6v4m0 3v.5', search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.3-4.3', chevron: 'M8 10l4 4 4-4', diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue index e006ec1..6c6095a 100644 --- a/web/src/admin/pages/DisplayPage.vue +++ b/web/src/admin/pages/DisplayPage.vue @@ -1,15 +1,18 @@