fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力 - realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份 - httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表 - web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换 - console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复 - docs: 用户文档三份 + README - 主代理: 破纪录加分接线(levels 未加载导致恒 0)
This commit is contained in:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# deploy/build.bat 在打包前把 docs/使用说明.txt 暂存到这里(extraFiles 指向该目录)
|
||||
extra/*
|
||||
!extra/.gitkeep
|
||||
+24
-10
@@ -1,11 +1,12 @@
|
||||
# Scoreboard Console
|
||||
|
||||
赛事积分榜系统的桌面宿主(Electron)。它只做四件事:
|
||||
赛事积分榜系统的桌面宿主(Electron)。它只做五件事:
|
||||
|
||||
1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件;
|
||||
2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏;
|
||||
3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏;
|
||||
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单。
|
||||
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单;
|
||||
5. 打包后随系统启动,现场断电重启后无需人工干预。
|
||||
|
||||
规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。
|
||||
|
||||
@@ -19,9 +20,13 @@ console/
|
||||
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
|
||||
│ ├── config.js # 配置加载与合并、exe 路径解析
|
||||
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
|
||||
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
|
||||
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
|
||||
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
|
||||
│ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件)
|
||||
├── scripts/
|
||||
│ └── check-syntax.js # npm run check:跨平台递归 node --check
|
||||
├── extra/ # 打包前由 deploy/build.bat 暂存 使用说明.txt(extraFiles 指向它)
|
||||
└── renderer/
|
||||
├── index.html # 管理窗口(含自绘标题栏)
|
||||
├── styles.css
|
||||
@@ -66,16 +71,19 @@ scoreboard-windows-amd64/
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏 |
|
||||
| 多块屏 | 除主屏外每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 |
|
||||
| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认)/ `all` / `specified` |
|
||||
| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:8080/display?page=standings&lock=1&screen=<id>&instance=<host>-<id>`,页序按 `display.pages` 轮转 |
|
||||
| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 |
|
||||
| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` |
|
||||
| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=<id>&instance=<host>-<id>`,页序按 `display.pages` 轮转 |
|
||||
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
|
||||
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
|
||||
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);恢复健康后自动重载大屏 |
|
||||
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
|
||||
| 停止服务 | 先 `POST server.shutdownPath` 请求服务端优雅关闭,`shutdownTimeoutMs` 内未退出再强杀;接口不可用时回退进程信号 |
|
||||
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
|
||||
| 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 |
|
||||
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
|
||||
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
|
||||
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`) |
|
||||
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
|
||||
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) |
|
||||
|
||||
## 配置项(`config.json`)
|
||||
|
||||
@@ -83,20 +91,24 @@ scoreboard-windows-amd64/
|
||||
| --- | --- | --- |
|
||||
| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 |
|
||||
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 |
|
||||
| `server.host` / `server.port` | `127.0.0.1` / `8080` | 健康检查与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
|
||||
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
|
||||
| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 |
|
||||
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
|
||||
| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 |
|
||||
| `server.maxRestarts` | `0` | 0 = 无限重启 |
|
||||
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现),不可用时回退进程信号 |
|
||||
| `server.shutdownTimeoutMs` | `12000` | 等待优雅关闭的上限,超过则强杀 |
|
||||
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
||||
| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` |
|
||||
| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 |
|
||||
| `display.pages` | `["standings","medals","individual","results","records"]` | 按屏序轮转锁定 |
|
||||
| `display.pages` | `["standings","medals","individual","results"]` | 按屏序轮转锁定;只填大屏支持的页名 |
|
||||
| `display.pageParam` | `page` | 页面 URL 参数名 |
|
||||
| `display.kiosk` | `true` | 多屏时是否 kiosk(单屏恒窗口化) |
|
||||
| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 |
|
||||
| `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 |
|
||||
| `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 |
|
||||
| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 |
|
||||
| `startup.openAtLogin` | `true` | 打包后随系统启动(开发态不注册) |
|
||||
| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
|
||||
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 |
|
||||
|
||||
@@ -109,12 +121,14 @@ scoreboard-windows-amd64/
|
||||
```bash
|
||||
cd console
|
||||
npm install
|
||||
npm run check # 跨平台语法检查(scripts/check-syntax.js)
|
||||
npm run dist # electron-builder --win --x64 → ../dist/console/
|
||||
npm run pack # 只出目录,不生成安装包,便于现场调试
|
||||
```
|
||||
|
||||
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。
|
||||
- `extraFiles` 会把 `../dist/scoreboard.exe` 与 `../config.yaml` 拷到安装目录,最终形成第 4 节里的部署包形态。
|
||||
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。
|
||||
- `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。
|
||||
- 图标资源目录 `build/` 尚未提供,暂用 Electron 默认图标;补上 `build/icon.ico` 后自动生效。
|
||||
- **未在 Windows 构建机上实际执行过 `npm run dist`**(本机没有安装 Electron/electron-builder,也按要求未执行 npm install)。
|
||||
|
||||
|
||||
+8
-2
@@ -7,12 +7,15 @@
|
||||
"autoStart": true,
|
||||
"healthIntervalMs": 5000,
|
||||
"restartDelayMs": 3000,
|
||||
"maxRestarts": 0
|
||||
"maxRestarts": 0,
|
||||
"shutdownPath": "/api/shutdown",
|
||||
"shutdownTimeoutMs": 12000,
|
||||
"healthyResetMs": 60000
|
||||
},
|
||||
"display": {
|
||||
"selection": "all-secondary",
|
||||
"displays": [],
|
||||
"pages": ["standings", "medals", "individual", "results", "records"],
|
||||
"pages": ["standings", "medals", "individual", "results"],
|
||||
"pageParam": "page",
|
||||
"kiosk": true,
|
||||
"ignoreMouse": true,
|
||||
@@ -20,6 +23,9 @@
|
||||
"waitForServerMs": 20000,
|
||||
"backgroundColor": "#0b0f1a"
|
||||
},
|
||||
"startup": {
|
||||
"openAtLogin": true
|
||||
},
|
||||
"admin": {
|
||||
"width": 1040,
|
||||
"height": 760
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,77 @@
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const { spawn } = require('child_process')
|
||||
|
||||
// 用户名 / 密码长度上限:passwd 自己也会限制,这里只是把明显不合法的挡在 UI 侧
|
||||
const FIELD_MAX = 64
|
||||
|
||||
// 返回错误文案,null 表示通过。required 为 false 时允许空串(= 不改 / 随机生成)
|
||||
function validateAccountField(value, label, required) {
|
||||
if (typeof value !== 'string') return `${label}必须是文本`
|
||||
if (value === '') return required ? `${label}不能为空` : null
|
||||
// spawn 用参数数组、不经 shell,不存在命令注入;但空格会把参数拆成两段、
|
||||
// 换行会截断输出,所以在入口直接拒掉,别让 passwd 报看不懂的错。
|
||||
if (/\s/.test(value)) return `${label}不能包含空格、制表符或换行`
|
||||
if (value.length > FIELD_MAX) return `${label}最多 ${FIELD_MAX} 个字符`
|
||||
return null
|
||||
}
|
||||
|
||||
// 空的新用户名 / 空密码直接传空串:passwd 的语义就是「留空则不改 / 随机生成」
|
||||
function buildPasswdArgs({ configArg, currentUsername, newUsername, password }) {
|
||||
return [
|
||||
'passwd',
|
||||
'-config', configArg,
|
||||
'-user', currentUsername,
|
||||
'-new-username', newUsername,
|
||||
'-password', password
|
||||
]
|
||||
}
|
||||
|
||||
// 跑一次 passwd,实时把 stdout/stderr 交给 onOutput,结束时返回完整输出与退出码
|
||||
function runPasswd({ exePath, args, onOutput }) {
|
||||
return new Promise((resolve) => {
|
||||
let child
|
||||
try {
|
||||
// cwd 取 exe 所在目录:config.yaml 与 data/ 都跟 exe 平级
|
||||
child = spawn(exePath, args, {
|
||||
cwd: path.dirname(exePath),
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: `启动 passwd 失败:${err.message}`, output: '' })
|
||||
return
|
||||
}
|
||||
|
||||
let output = ''
|
||||
let settled = false
|
||||
const finish = (result) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const onChunk = (chunk) => {
|
||||
const text = String(chunk)
|
||||
output += text
|
||||
if (typeof onOutput === 'function') onOutput(text)
|
||||
}
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', onChunk)
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', onChunk)
|
||||
}
|
||||
|
||||
child.on('error', (err) => finish({ ok: false, error: err.message, output }))
|
||||
// 用 close 而不是 exit:等两个 stdio 都排空,输出才是完整的
|
||||
child.on('close', (code, signal) => {
|
||||
finish({ ok: code === 0, exitCode: code, signal: signal || null, output })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { FIELD_MAX, validateAccountField, buildPasswdArgs, runPasswd }
|
||||
@@ -26,7 +26,8 @@ const DEFAULTS = {
|
||||
// all-secondary | all | specified
|
||||
selection: 'all-secondary',
|
||||
displays: [],
|
||||
pages: ['standings', 'medals', 'individual', 'results', 'records'],
|
||||
// 只能写大屏实际支持的页名(见 web/src/display/types.ts 的 PAGE_ALIASES)
|
||||
pages: ['standings', 'medals', 'individual', 'results'],
|
||||
pageParam: 'page',
|
||||
kiosk: true,
|
||||
ignoreMouse: true,
|
||||
@@ -35,6 +36,10 @@ const DEFAULTS = {
|
||||
waitForServerMs: 20000,
|
||||
backgroundColor: '#0b0f1a'
|
||||
},
|
||||
startup: {
|
||||
// 打包后随系统启动(doc 10 §4.3);开发态不注册
|
||||
openAtLogin: true
|
||||
},
|
||||
admin: {
|
||||
width: 1040,
|
||||
height: 760
|
||||
|
||||
+71
-2
@@ -1,5 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const {
|
||||
app,
|
||||
@@ -15,6 +16,7 @@ const { LogBus } = require('./log-bus')
|
||||
const { ServiceSupervisor } = require('./service')
|
||||
const { DisplayManager } = require('./displays')
|
||||
const { createTrayIcon } = require('./tray-icon')
|
||||
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
|
||||
|
||||
// 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
@@ -36,6 +38,9 @@ function main() {
|
||||
let displaysEnabled = false
|
||||
let quitting = false
|
||||
let shutdownDone = false
|
||||
let exePath = null
|
||||
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
|
||||
let accountBusy = false
|
||||
|
||||
function resolveConfigPath() {
|
||||
if (process.env.SCOREBOARD_CONSOLE_CONFIG) {
|
||||
@@ -87,7 +92,7 @@ function main() {
|
||||
height,
|
||||
minWidth: 860,
|
||||
minHeight: 600,
|
||||
title: 'Scoreboard Console · 启动管理',
|
||||
title: '赛事积分榜 · 服务管理',
|
||||
backgroundColor: '#0f1420',
|
||||
show: false,
|
||||
// 标题栏由页面自绘;titleBarStyle 只是兜底,防止某些平台忽略 frame:false
|
||||
@@ -143,7 +148,7 @@ function main() {
|
||||
height,
|
||||
minWidth: 900,
|
||||
minHeight: 620,
|
||||
title: 'Scoreboard · 服务端管理面板',
|
||||
title: '赛事积分榜 · 服务端管理面板',
|
||||
backgroundColor: '#161616',
|
||||
show: false,
|
||||
frame: false,
|
||||
@@ -253,8 +258,71 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号
|
||||
function passwdConfigArg() {
|
||||
const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : []
|
||||
const index = args.indexOf('-config')
|
||||
if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) {
|
||||
return args[index + 1]
|
||||
}
|
||||
return 'config.yaml'
|
||||
}
|
||||
|
||||
// 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP,
|
||||
// 所以不需要任何登录态,这就是面板能「免验证」的原因。
|
||||
async function resetAdminAccount(event, payload) {
|
||||
if (accountBusy) return { ok: false, error: '上一次重置还没结束,请稍候再试' }
|
||||
// IPC 入参一律当不可信处理
|
||||
if (payload === null || typeof payload !== 'object') payload = {}
|
||||
|
||||
const currentUsername = typeof payload.currentUsername === 'string' ? payload.currentUsername.trim() : ''
|
||||
const newUsername = typeof payload.newUsername === 'string' ? payload.newUsername.trim() : ''
|
||||
const password = typeof payload.password === 'string' ? payload.password : ''
|
||||
|
||||
const invalid =
|
||||
validateAccountField(currentUsername, '当前用户名', true) ||
|
||||
validateAccountField(newUsername, '新用户名', false) ||
|
||||
validateAccountField(password, '新密码', false)
|
||||
if (invalid) return { ok: false, error: invalid }
|
||||
|
||||
const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path).path
|
||||
if (!exe || !fs.existsSync(exe)) {
|
||||
return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` }
|
||||
}
|
||||
|
||||
const configArg = passwdConfigArg()
|
||||
const args = buildPasswdArgs({ configArg, currentUsername, newUsername, password })
|
||||
const sender = event.sender
|
||||
|
||||
accountBusy = true
|
||||
// 日志里不能带明文密码
|
||||
log.push(
|
||||
'console',
|
||||
`重置管理员账号:${exe} passwd -config ${configArg} -user ${currentUsername}` +
|
||||
`${newUsername ? ` -new-username ${newUsername}` : ''}(密码已隐藏)`
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await runPasswd({
|
||||
exePath: exe,
|
||||
args,
|
||||
onOutput: (text) => {
|
||||
if (!sender.isDestroyed()) sender.send('account:output', text)
|
||||
}
|
||||
})
|
||||
const reason = result.error
|
||||
? result.error
|
||||
: `退出码 ${result.exitCode}${result.signal ? `,信号 ${result.signal}` : ''}`
|
||||
log.push('console', `重置管理员账号${result.ok ? '完成' : '失败'}(${reason})`)
|
||||
return result
|
||||
} finally {
|
||||
accountBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
ipcMain.handle('service:get-status', () => statusSnapshot())
|
||||
ipcMain.handle('account:reset', (event, payload) => resetAdminAccount(event, payload))
|
||||
ipcMain.handle('service:start', () => {
|
||||
service.start()
|
||||
return statusSnapshot()
|
||||
@@ -323,6 +391,7 @@ function main() {
|
||||
if (cfg.warning) log.push('console', cfg.warning)
|
||||
|
||||
const resolved = resolveExecutable(cfg.values.server.exe, cfg.path)
|
||||
exePath = resolved.path
|
||||
log.push('console', `服务端程序:${resolved.path}`)
|
||||
|
||||
service = new ServiceSupervisor({
|
||||
|
||||
+14
-1
@@ -8,7 +8,7 @@
|
||||
"main": "main/index.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"check": "node -e \"const {execSync}=require('child_process');execSync('for f in $(find . -name \\\"*.js\\\" -not -path \\\"./node_modules/*\\\"); do node --check \\\"$f\\\"; done',{stdio:'inherit'})\"",
|
||||
"check": "node scripts/check-syntax.js",
|
||||
"dist": "electron-builder --win --x64",
|
||||
"pack": "electron-builder --dir"
|
||||
},
|
||||
@@ -39,9 +39,22 @@
|
||||
"from": "../config.yaml",
|
||||
"to": "config.yaml"
|
||||
},
|
||||
{
|
||||
"from": "config.json",
|
||||
"to": "config.json"
|
||||
},
|
||||
{
|
||||
"from": "scoreboard.png",
|
||||
"to": "scoreboard.png"
|
||||
},
|
||||
{
|
||||
"from": "../rules",
|
||||
"to": "rules"
|
||||
},
|
||||
{
|
||||
"from": "extra",
|
||||
"to": ".",
|
||||
"filter": ["**/*", "!.gitkeep"]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
|
||||
@@ -21,6 +21,12 @@ const el = {
|
||||
btnRelayout: document.getElementById('btn-relayout'),
|
||||
btnOpenAdmin: document.getElementById('btn-open-admin'),
|
||||
btnClearLog: document.getElementById('btn-clear-log'),
|
||||
btnResetAccount: document.getElementById('btn-reset-account'),
|
||||
acctUser: document.getElementById('acct-user'),
|
||||
acctNewUser: document.getElementById('acct-new-user'),
|
||||
acctPassword: document.getElementById('acct-password'),
|
||||
acctStatus: document.getElementById('acct-status'),
|
||||
acctOutput: document.getElementById('acct-output'),
|
||||
titlebar: document.getElementById('titlebar'),
|
||||
winMin: document.getElementById('win-min'),
|
||||
winMax: document.getElementById('win-max'),
|
||||
@@ -137,6 +143,88 @@ function bindActions() {
|
||||
setLog([])
|
||||
})
|
||||
el.btnOpenAdmin.addEventListener('click', () => window.consoleApi.openServerAdmin())
|
||||
el.btnResetAccount.addEventListener('click', resetAccount)
|
||||
}
|
||||
|
||||
let accountStreaming = false
|
||||
|
||||
function setAccountOutput(text) {
|
||||
el.acctOutput.hidden = !text
|
||||
el.acctOutput.textContent = text || ''
|
||||
if (text) el.acctOutput.scrollTop = el.acctOutput.scrollHeight
|
||||
}
|
||||
|
||||
function appendAccountOutput(text) {
|
||||
el.acctOutput.hidden = false
|
||||
el.acctOutput.textContent += text
|
||||
el.acctOutput.scrollTop = el.acctOutput.scrollHeight
|
||||
}
|
||||
|
||||
function handleAccountOutput(text) {
|
||||
// 只在提交中接收流式输出:重置结束后的最终结果由返回值统一渲染,避免重复
|
||||
if (accountStreaming) appendAccountOutput(text)
|
||||
}
|
||||
|
||||
function setAccountStatus(text, kind) {
|
||||
el.acctStatus.hidden = !text
|
||||
el.acctStatus.textContent = text || ''
|
||||
el.acctStatus.className = kind ? `hint ${kind}` : 'hint'
|
||||
}
|
||||
|
||||
function setAccountBusy(busy) {
|
||||
el.btnResetAccount.disabled = busy
|
||||
el.btnResetAccount.textContent = busy ? '正在重置…' : '重置账号'
|
||||
for (const input of [el.acctUser, el.acctNewUser, el.acctPassword]) input.disabled = busy
|
||||
}
|
||||
|
||||
async function resetAccount() {
|
||||
const currentUsername = el.acctUser.value.trim()
|
||||
const newUsername = el.acctNewUser.value.trim()
|
||||
const password = el.acctPassword.value
|
||||
|
||||
if (!currentUsername) {
|
||||
setAccountStatus('请先填写当前用户名', 'err')
|
||||
return
|
||||
}
|
||||
|
||||
const target = newUsername ? `用户名改为「${newUsername}」` : '只重置密码'
|
||||
// 重置会踢掉该用户的所有登录态,属于不可撤销操作,必须先二次确认
|
||||
const confirmed = window.confirm(
|
||||
'确认重置管理员账号?\n\n' +
|
||||
`当前用户名:${currentUsername}\n` +
|
||||
`本次操作:${target}\n` +
|
||||
`新密码:${password ? '使用输入框中的密码' : '由程序随机生成'}\n\n` +
|
||||
'重置会作废该用户的全部登录会话,正在使用管理面板的人会被强制退出。'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
accountStreaming = true
|
||||
setAccountBusy(true)
|
||||
setAccountOutput('')
|
||||
setAccountStatus('正在调用 scoreboard.exe passwd …')
|
||||
|
||||
try {
|
||||
const result = await window.consoleApi.resetAdminAccount({ currentUsername, newUsername, password })
|
||||
accountStreaming = false
|
||||
setAccountOutput(result.output || result.error || '')
|
||||
|
||||
if (result.ok) {
|
||||
setAccountStatus(
|
||||
password ? '重置成功,原会话已全部作废。' : '重置成功,密码为随机生成,请从下方输出抄录后立即修改。',
|
||||
'ok'
|
||||
)
|
||||
el.acctUser.value = newUsername || currentUsername
|
||||
el.acctNewUser.value = ''
|
||||
el.acctPassword.value = ''
|
||||
} else {
|
||||
setAccountStatus(`重置失败:${result.error || `passwd 退出码 ${result.exitCode}`}`, 'err')
|
||||
}
|
||||
} catch (err) {
|
||||
setAccountStatus(`重置失败:${err.message}`, 'err')
|
||||
} finally {
|
||||
accountStreaming = false
|
||||
setAccountBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
@@ -144,6 +232,7 @@ async function boot() {
|
||||
bindWindowControls()
|
||||
window.consoleApi.onStatus(renderStatus)
|
||||
window.consoleApi.onLog(appendLog)
|
||||
window.consoleApi.onAccountOutput(handleAccountOutput)
|
||||
setLog(await window.consoleApi.getLog())
|
||||
await refresh()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'"
|
||||
/>
|
||||
<title>Scoreboard Console · 启动管理</title>
|
||||
<title>赛事积分榜 · 服务管理</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,8 +15,8 @@
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="scoreboard.png" alt="" width="30" height="30" />
|
||||
<div>
|
||||
<h1>Scoreboard Console</h1>
|
||||
<p class="brand-sub">赛事积分榜 · 桌面宿主</p>
|
||||
<h1>赛事积分榜 · 服务管理</h1>
|
||||
<p class="brand-sub">服务端守护 · 大屏投放</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
@@ -108,6 +108,35 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>管理员账号</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-reset-account" class="btn" type="button">重置账号</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
本机直接调用同目录的 <span class="mono">scoreboard.exe passwd</span>,无需登录验证。
|
||||
重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>当前用户名</span>
|
||||
<input id="acct-user" class="input" type="text" value="admin" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新用户名(留空 = 不改)</span>
|
||||
<input id="acct-new-user" class="input" type="text" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新密码(留空 = 随机生成)</span>
|
||||
<input id="acct-password" class="input" type="password" autocomplete="new-password" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
<p id="acct-status" class="hint" hidden></p>
|
||||
<pre id="acct-output" class="log account-output" hidden></pre>
|
||||
</section>
|
||||
|
||||
<section class="card log-card">
|
||||
<div class="card-head">
|
||||
<h2>运行日志</h2>
|
||||
|
||||
@@ -18,11 +18,13 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
||||
clearLog: () => ipcRenderer.invoke('log:clear'),
|
||||
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
||||
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
|
||||
resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload),
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
|
||||
onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback),
|
||||
onAccountOutput: (callback) => subscribe('account:output', callback),
|
||||
onLog: (callback) => subscribe('log:line', callback),
|
||||
onStatus: (callback) => subscribe('service:status', callback)
|
||||
})
|
||||
@@ -152,7 +152,8 @@ main {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto minmax(160px, 1fr);
|
||||
/* 四张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */
|
||||
grid-template-rows: auto auto auto minmax(160px, 1fr);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -334,6 +335,48 @@ h2 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0a0e17;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hint.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.hint.err {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -349,3 +392,9 @@ h2 {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 账号重置的输出框沿用 .log 的终端样式,但要限高、别把卡片撑开 */
|
||||
.account-output {
|
||||
max-height: 180px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict'
|
||||
|
||||
const { spawnSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 跨平台的语法检查:原来 package.json 里的 `for f in $(find ...)` 只在 POSIX shell 下能用,
|
||||
// Windows 构建机上直接报错,这里改用 Node 递归遍历 + node --check。
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const SKIP_DIRS = new Set(['node_modules', '.git'])
|
||||
|
||||
function collect(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
collect(path.join(dir, entry.name), files)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
||||
files.push(path.join(dir, entry.name))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const files = collect(ROOT).sort()
|
||||
const failed = []
|
||||
|
||||
for (const file of files) {
|
||||
const result = spawnSync(process.execPath, ['--check', file], { stdio: 'inherit' })
|
||||
if (result.status !== 0) failed.push(file)
|
||||
}
|
||||
|
||||
if (failed.length) {
|
||||
console.error(`\n语法检查失败 ${failed.length}/${files.length} 个文件:`)
|
||||
for (const file of failed) console.error(` ${path.relative(ROOT, file)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`语法检查通过:${files.length} 个文件`)
|
||||
Reference in new issue
Block a user