Files
Scoreboard/console/main/displays.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

459 lines
21 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 os = require('os')
const path = require('path')
const { BrowserWindow, screen } = require('electron')
const { healthHost } = require('./service')
const SELECTION = {
ALL_SECONDARY: 'all-secondary',
ALL: 'all',
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 {
constructor({ values, server, log }) {
this.display = values.display
this.server = server
this.log = log
this.enabled = false
this.single = true
// entry.single 记这个窗口是以哪种模式建的(true=窗口化,false=kiosk),
// entry.manual 记它是管理面板手动开的(不跟随自动排布改模式)
this.windows = new Map() // displayId -> { win, page, single, manual, reloadTimer }
this.screenListeners = []
this.layoutTimer = null
}
enable() {
if (this.enabled) return
this.enabled = true
this.layout()
}
pageFor(index) {
const pages = Array.isArray(this.display.pages) && this.display.pages.length ? this.display.pages : ['standings']
// 单屏没有编排可言,固定锁第一页(默认总积分榜)
if (this.single) return pages[0]
return pages[index % pages.length]
}
pickDisplays() {
const all = screen.getAllDisplays()
const primary = screen.getPrimaryDisplay()
// 单屏机器上谈「扩展屏编排」没有意义,直接窗口化
if (all.length <= 1) return { single: true, displays: all.slice(0, 1) }
const selection = this.display.selection || SELECTION.ALL_SECONDARY
let picked
if (selection === SELECTION.ALL) {
picked = all.slice()
} else if (selection === SELECTION.SPECIFIED) {
picked = this.pickSpecified(all)
} else {
picked = this.pickSecondary(all, primary)
}
// 只有整机就一块屏时才是「单屏测试模式」;笔记本 + 一块扩展屏属于多屏场景,
// 扩展屏要正常 kiosk,不能退化成窗口化
if (!picked.length) return { single: true, displays: [primary] }
return { single: false, displays: picked }
}
// 默认策略是「除内置屏外的每块屏」(doc 06 §6.2)。用 display.internal 判断,
// 而不是拿主屏当替身:现场常把主屏设成外接 HDMI,用主屏排除会把 kiosk 开到笔记本屏。
// 全部屏都报 internal=false 时(纯台式机)退回「排除主屏」的旧口径。
pickSecondary(all, primary) {
const external = all.filter((item) => item.internal === false)
if (external.length && external.length < all.length) return external
return all.filter((item) => item.id !== primary.id)
}
// displays 配置里可以写 display.id,也可以写 0 起的序号
pickSpecified(all) {
const wanted = Array.isArray(this.display.displays) ? this.display.displays : []
const picked = []
for (const key of wanted) {
const match = typeof key === 'number' ? all.find((item) => item.id === key) || all[key] : all.find((item) => String(item.id) === String(key))
if (match && !picked.includes(match)) picked.push(match)
}
return picked
}
layout() {
if (!this.enabled) return
const { single, displays } = this.pickDisplays()
this.single = single
this.bindScreenListeners()
const wanted = new Set(displays.map((item) => item.id))
for (const [displayId, entry] of [...this.windows]) {
if (!wanted.has(displayId)) {
this.destroyEntry(displayId, entry)
}
}
displays.forEach((display, index) => {
const page = this.pageFor(index)
const existing = this.windows.get(display.id)
// 窗口可能被外部关闭/崩溃销毁而没走 destroyEntry,这里当作不存在重建,
// 否则 applyGeometry/loadPage 对 destroyed 窗口静默 return,现场只剩余黑屏
if (existing && existing.win && !existing.win.isDestroyed()) {
// 手动开的窗口保持建窗时定的模式,自动排布只负责摆位置;
// 排布自己开的窗口跟随本次排布(电视拔了就该变回窗口化)
const target = existing.manual ? existing.single : single
const modeChanged = target !== existing.single
existing.single = target
this.applyGeometry(existing.win, display, target)
// 模式变了必须连 URL 一起换:URL 里的 windowed 是页面对「要不要画标题栏」的全部认知,
// 只改几何不重载,页面就会带着旧参数画(全屏仍画标题栏、窗口化却空着一条)
if (modeChanged || existing.page !== page) {
existing.page = page
this.loadPage(existing, display, page, target)
}
return
}
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
this.windows.set(display.id, this.createWindow(display, index, single, page))
})
this.log.push('console', `大屏排布完成:${single ? '单屏窗口化' : `多屏 kiosk × ${displays.length}`},显示器 ${displays.map((d) => d.id).join(', ')}`)
}
createWindow(display, index, single, page, manual = false) {
const bounds = display.bounds
const win = new BrowserWindow({
...this.geometryFor(display, single),
show: false,
// 窗口化模式由页面自绘标题栏(与应用其它窗口同风格),保留原生边框会多出一条标题栏;
// 多屏 kiosk 本来就不要边框
frame: false,
// 与 Console 主窗口同一套兜底:个别平台会忽略 frame:false
titleBarStyle: 'hidden',
// 这里刻意不给 kiosk:进 kiosk 必须发生在 setBounds 之后,而 setBounds 与 kiosk
// 的先后顺序是「压不压得住任务栏」的关键,只留 applyGeometry 一处去管,
// 免得构造函数和 applyGeometry 各设一遍、顺序互相打架
autoHideMenuBar: true,
backgroundColor: this.display.backgroundColor || '#000000',
title: `大屏 · ${page}`,
webPreferences: {
// 页面看不到窗口级全屏,靠这个 preload 转达(见 display-preload.js)
preload: path.join(__dirname, '..', 'renderer', 'display-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// 大屏常年在后台焦点之外,默认节流会冻结动画和定时器
backgroundThrottling: 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)
}
// 窗口级全屏只在主进程可见:页面拿不到 setFullScreen 的结果,标题栏也就不会让位。
// 负载优先用调用方给的确定值——在回调里回读 win.isFullScreen() 会读到切换瞬间的旧值
// (Windows 上尤其),页面就会收到相反的状态、把标题栏画反。只有加载完成那次窗口
// 状态已经稳定,才回落到「读一次」。
const notifyFullscreen = (fullscreen) => {
if (win.isDestroyed()) return
win.webContents.send('window:fullscreen-changed', fullscreen === undefined ? win.isFullScreen() : fullscreen === true)
}
// F11 只在单屏模式下有意义(现场调试用),多屏 kiosk 不允许切走
if (single) {
win.webContents.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && input.key === 'F11') {
event.preventDefault()
const next = !win.isFullScreen()
win.setFullScreen(next)
notifyFullscreen(next)
}
})
}
// 状态变化下发一次之外,加载完成还要补发一次——重载/渲染进程崩溃恢复后窗口可能
// 已经是全屏,只挂事件会让页面一直画着标题栏。
win.on('enter-full-screen', () => notifyFullscreen(true))
win.on('leave-full-screen', () => notifyFullscreen(false))
win.webContents.on('did-finish-load', () => notifyFullscreen())
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.webContents.on('render-process-gone', (_event, details) => {
this.log.push('console', `大屏渲染进程异常(display=${display.id},${details.reason}),将重载`)
if (!win.isDestroyed()) win.webContents.reload()
})
// single 必须跟着 entry 走:页面 URL 里的 windowed 是按它生成的,
// 排布后来改了模式而这里没记,窗口的真实模式就和页面的认知脱钩了
const entry = { win, page, single, manual, reloadTimer: null }
// 窗口销毁(外部关闭/渲染进程崩溃)后必须把 entry 从 map 摘掉,
// 否则 layout/relayout 一直拿到已销毁窗口而静默跳过,现场只剩余黑屏
win.on('closed', () => {
if (entry.reloadTimer) clearInterval(entry.reloadTimer)
// 只有 map 里还是这个 entry 时才删,避免旧窗口的 closed 误删重建后的新窗口
if (this.windows.get(display.id) === entry) this.windows.delete(display.id)
})
// 无人值守跑 24h 以上,定时重载兜底内存与连接泄漏
const hours = Number(this.display.reloadEveryHours) || 0
if (hours > 0) {
entry.reloadTimer = setInterval(() => {
if (!win.isDestroyed()) win.webContents.reload()
}, hours * 3600 * 1000)
}
this.applyGeometry(win, display, single)
this.loadPage(entry, display, page, single)
this.log.push('console', `打开大屏窗口 display=${display.id} bounds=${bounds.width}x${bounds.height}@${bounds.x},${bounds.y} page=${page}`)
return entry
}
geometryFor(display, single) {
const bounds = display.bounds
if (!single) return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
// 单屏窗口化:不铺满,留出边距方便看管理窗口和调试
const width = Math.round(bounds.width * 0.8)
const height = Math.round(bounds.height * 0.8)
return {
x: bounds.x + Math.round((bounds.width - width) / 2),
y: bounds.y + Math.round((bounds.height - height) / 2),
width,
height
}
}
// 把窗口摆到目标屏、并把窗口态调到目标模式。**这里的调用顺序是「大屏能不能压住任务栏」
// 的全部理由**,动它之前先读下面 kiosk 分支的注释。
applyGeometry(win, display, single) {
if (!win || win.isDestroyed()) return
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)
win.setBounds(bounds)
win.setAlwaysOnTop(false)
return
}
// 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) {
if (!entry || !entry.win || entry.win.isDestroyed()) return
const url = this.urlFor(display, page, single)
this.log.push('console', `大屏加载 ${url}`)
entry.win.loadURL(url).catch((err) => {
// 服务端还没起来时加载失败是正常的,健康检查通过后会重排重载
this.log.push('console', `大屏加载失败(display=${display.id}):${err.message}`)
})
}
urlFor(display, page, single) {
const params = new URLSearchParams()
params.set(this.display.pageParam || 'page', page)
// lock=1 告知页面锁死单页,不参与轮播
params.set('lock', '1')
params.set('screen', String(display.id))
params.set('instance', `${os.hostname()}-${display.id}`)
// 只有单屏窗口化才画标题栏;多屏 kiosk 全屏无人值守,不能占高度
if (single) params.set('windowed', '1')
// 与健康检查/管理面板同一套 host 归一:0.0.0.0/:: 走回环,其余(如内网 IP)原样
return `http://${healthHost(this.server)}:${this.server.port}/display?${params.toString()}`
}
// 屏幕监听常驻,只有 closeAll()(服务停止 / 应用退出)才摘:
// 单屏态的「行为冻结」只冻结分辨率与缩放,display-added / display-removed 必须一直听着。
// 否则「只剩一块屏」那次排布会把监听整个摘掉,扩展屏插回来再没人重排——
// 大屏永远停在窗口化小窗,电视黑屏,只能重启 Console(审查日志 P1(18))。
bindScreenListeners() {
if (this.screenListeners.length) return
const schedule = () => {
if (this.layoutTimer) clearTimeout(this.layoutTimer)
// 分辨率/缩放变化会连续触发多次,防抖后再重排
this.layoutTimer = setTimeout(() => {
this.layoutTimer = null
this.layout()
}, 600)
}
// 单屏窗口化是现场调试态,分辨率/缩放变化不跟着重排(保留原有的冻结语义);
// 屏幕数量变化不能冻结,那是「电视被拔/被插」的唯一信号
const metricsChanged = () => {
if (this.single) return
schedule()
}
screen.on('display-metrics-changed', metricsChanged)
screen.on('display-added', schedule)
screen.on('display-removed', schedule)
this.screenListeners = [
['display-metrics-changed', metricsChanged],
['display-added', schedule],
['display-removed', schedule]
]
}
unbindScreenListeners() {
for (const [event, handler] of this.screenListeners) {
screen.removeListener(event, handler)
}
this.screenListeners = []
}
destroyEntry(displayId, entry) {
if (entry.reloadTimer) clearInterval(entry.reloadTimer)
if (entry.win && !entry.win.isDestroyed()) entry.win.destroy()
this.windows.delete(displayId)
this.log.push('console', `关闭大屏窗口 display=${displayId}`)
}
relayout() {
if (!this.enabled) return
for (const [, entry] of this.windows) {
if (entry.win && !entry.win.isDestroyed()) entry.win.webContents.reload()
}
this.layout()
}
// 窗口模式只看 entry 记的建窗模式,不用 win.isKiosk()/isFullScreen() 反推:
// 窗口化窗口被 F11 全屏后 isFullScreen() 也是 true,反推会把窗口化误报成 kiosk
modeOf(entry) {
return entry && entry.single === false ? 'kiosk' : 'windowed'
}
// 管理面板手动为某块屏开窗:已经开着就只聚焦,重建会打断正在展示的大屏
openOn(display, { page, single } = {}) {
const kiosk = !single && this.display.kiosk !== false
const wantedPage = page || this.pageFor(0)
const existing = this.windows.get(display.id)
if (existing && existing.win && !existing.win.isDestroyed()) {
if (existing.win.isMinimized()) existing.win.restore()
existing.win.show()
existing.win.focus()
// 已有窗口按它的真实模式报(可能与本次请求不同),面板才不会说反
return { created: false, mode: this.modeOf(existing) }
}
// 已销毁的窗口占着 map 会让后续 layout 静默跳过,先摘掉旧 entry 再建
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
// 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走。
// manual=true:自动排布不许改它的模式,否则面板开的小窗会被下一次排布顶成 kiosk
this.enabled = true
// 面板开窗同样是「管理已激活」,监听一并挂上:否则在这个状态下拔插电视没人听见
this.bindScreenListeners()
const entry = this.createWindow(display, 0, !kiosk, wantedPage, true)
this.windows.set(display.id, entry)
return { created: true, mode: this.modeOf(entry) }
}
// 关掉某块屏上的大屏窗口:窗口默认置顶且压住任务栏,误开/要收场时只能从这里收
closeDisplay(displayId) {
const entry = this.windows.get(displayId)
if (!entry) return false
this.destroyEntry(displayId, entry)
return true
}
// 只读枚举显示器与窗口状态,给管理面板的显示器菜单用,不改动任何窗口
listDisplays() {
const all = screen.getAllDisplays()
const primary = screen.getPrimaryDisplay()
const list = all.map((display, index) => {
const entry = this.windows.get(display.id)
const open = Boolean(entry && entry.win && !entry.win.isDestroyed())
return {
id: display.id,
label: display.label || `显示器 ${display.id}`,
primary: display.id === primary.id,
// 一律给 DIP,与窗口坐标同一口径;物理像素对不上时看 scaleFactor
width: display.bounds.width,
height: display.bounds.height,
scaleFactor: display.scaleFactor,
open,
page: open ? entry.page : this.pageFor(index),
mode: open ? this.modeOf(entry) : null
}
})
// 单屏机器上谈「扩展屏全屏」没有意义,与 pickDisplays 同一口径
return { single: all.length <= 1, displays: list }
}
getInfo() {
return [...this.windows.entries()].map(([displayId, entry]) => ({
displayId,
page: entry.page,
url: entry.win && !entry.win.isDestroyed() ? entry.win.webContents.getURL() : '',
bounds: entry.win && !entry.win.isDestroyed() ? entry.win.getBounds() : null
}))
}
closeAll() {
if (this.layoutTimer) clearTimeout(this.layoutTimer)
this.unbindScreenListeners()
for (const [displayId, entry] of [...this.windows]) {
this.destroyEntry(displayId, entry)
}
this.enabled = false
}
}
module.exports = { DisplayManager }