Files
Scoreboard/console/main/displays.js
T
AskaEth 6483acf9ad feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
2026-09-10 09:05:06 +08:00

371 lines
14 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()
}
// 窗口的当前全屏状态:以窗口为准,配置改了 kiosk 但窗口没重建时也能报对
isKioskWindow(win) {
if (!win || win.isDestroyed()) return false
return Boolean(win.isKiosk() || win.isFullScreen())
}
// 管理面板手动为某块屏开窗:已经开着就只聚焦,重建会打断正在展示的大屏
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.isKioskWindow(existing.win) ? 'kiosk' : 'windowed' }
}
// 已销毁的窗口占着 map 会让后续 layout 静默跳过,先摘掉旧 entry 再建
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
// 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走
this.enabled = true
this.windows.set(display.id, this.createWindow(display, 0, !kiosk, wantedPage))
return { created: true, mode: kiosk ? 'kiosk' : 'windowed' }
}
// 关掉某块屏上的大屏窗口:kiosk 窗口刻意忽略鼠标,误开后只能从这里收
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.isKioskWindow(entry.win) ? 'kiosk' : 'windowed') : 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 }