Files
Scoreboard/console/main/displays.js
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

308 lines
11 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 { BrowserWindow, screen } = require('electron')
const { healthHost } = require('./service')
const SELECTION = {
ALL_SECONDARY: 'all-secondary',
ALL: 'all',
SPECIFIED: 'specified'
}
// 大屏窗口管理:按显示器枚举结果开窗,绝不写死坐标。
// 坐标一律用 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
this.boundSingle = null
this.windows = new Map() // displayId -> { win, page, 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(single)
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()) {
this.applyGeometry(existing.win, display, single)
if (existing.page !== page) {
existing.page = page
this.loadPage(existing, display, page, single)
}
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) {
const bounds = display.bounds
const win = new BrowserWindow({
...this.geometryFor(display, single),
show: false,
// 窗口化模式由页面自绘标题栏(与应用其它窗口同风格),保留原生边框会多出一条标题栏;
// 多屏 kiosk 本来就不要边框
frame: false,
// 与 Console 主窗口同一套兜底:个别平台会忽略 frame:false
titleBarStyle: 'hidden',
kiosk: !single && this.display.kiosk !== false,
autoHideMenuBar: true,
backgroundColor: this.display.backgroundColor || '#000000',
title: `大屏 · ${page}`,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// 大屏常年在后台焦点之外,默认节流会冻结动画和定时器
backgroundThrottling: false
}
})
// 大屏页零交互控件;窗口层再兜一道,防止现场误触鼠标
if (!single && this.display.ignoreMouse !== false) {
win.setIgnoreMouseEvents(true)
}
// F11 只在单屏模式下有意义(现场调试用),多屏 kiosk 不允许切走
if (single) {
win.webContents.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && input.key === 'F11') {
event.preventDefault()
win.setFullScreen(!win.isFullScreen())
}
})
}
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()
})
const entry = { win, page, 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
}
}
applyGeometry(win, display, single) {
if (!win || win.isDestroyed()) return
if (single) {
if (win.isKiosk()) win.setKiosk(false)
if (win.isFullScreen()) win.setFullScreen(false)
} else if (this.display.kiosk !== false) {
win.setKiosk(true)
}
win.setBounds(this.geometryFor(display, single))
}
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()}`
}
// 单屏模式要「行为冻结」:不跟随屏幕变化重排,所以连监听都不挂
bindScreenListeners(single) {
if (this.screenListeners.length && this.boundSingle === single) return
this.unbindScreenListeners()
this.boundSingle = single
if (single) return
const schedule = () => {
if (this.layoutTimer) clearTimeout(this.layoutTimer)
// 分辨率/缩放变化会连续触发多次,防抖后再重排
this.layoutTimer = setTimeout(() => {
this.layoutTimer = null
this.layout()
}, 600)
}
screen.on('display-metrics-changed', schedule)
screen.on('display-added', schedule)
screen.on('display-removed', schedule)
this.screenListeners = [
['display-metrics-changed', schedule],
['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()
}
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 }