Files
Scoreboard/console/renderer/app.js
T
AskaEth 2c9ff65fc4 feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对)
  与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的
  重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端
- export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep),
  自动、手动、发布前三类备份共用同一把互斥锁与滚动清理
- realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行,
  跨站需命中 server.allowedOrigins)
- console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀),
  服务管理器新增「已连接的大屏实例」卡片
- deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空,
  导致最近这次构建的 exe 里根本没注入署名
- docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
2026-09-09 21:41:08 +08:00

285 lines
10 KiB
JavaScript

'use strict'
const el = {
version: document.getElementById('app-version'),
stateDot: document.getElementById('state-dot'),
stateLabel: document.getElementById('state-label'),
stateDetail: document.getElementById('state-detail'),
url: document.getElementById('k-url'),
pid: document.getElementById('k-pid'),
restarts: document.getElementById('k-restarts'),
power: document.getElementById('k-power'),
exe: document.getElementById('k-exe'),
config: document.getElementById('k-config'),
configWarning: document.getElementById('config-warning'),
displayList: document.getElementById('display-list'),
instanceList: document.getElementById('instance-list'),
instancesSummary: document.getElementById('instances-summary'),
log: document.getElementById('log'),
autoscroll: document.getElementById('autoscroll'),
btnStart: document.getElementById('btn-start'),
btnStop: document.getElementById('btn-stop'),
btnRestart: document.getElementById('btn-restart'),
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'),
winClose: document.getElementById('win-close')
}
const DOT_CLASS = {
running: 'ok',
starting: 'warn',
stopping: 'warn',
crashed: 'err',
stopped: ''
}
function renderStatus(status) {
el.version.textContent = status.appVersion ? `v${status.appVersion}` : ''
el.stateDot.className = `dot ${status.healthy ? 'ok' : DOT_CLASS[status.state] || ''}`
el.stateLabel.textContent = status.stateLabel || status.state
el.stateDetail.textContent = status.healthy
? '健康检查通过'
: status.lastError || (status.state === 'stopped' ? '服务未运行' : '等待健康检查')
el.url.textContent = status.url || '—'
el.pid.textContent = status.pid || '—'
el.restarts.textContent = status.restarts ? `${status.restarts} 次` : '0 次'
el.power.textContent = status.powerSaveBlocked ? '已启用(阻止显示器休眠)' : '未启用'
el.exe.textContent = status.exePath || '—'
el.config.textContent = status.configPath || '—'
if (status.configWarning) {
el.configWarning.hidden = false
el.configWarning.textContent = status.configWarning
} else {
el.configWarning.hidden = true
}
const busy = ['starting', 'running', 'stopping'].includes(status.state)
el.btnStart.disabled = busy
el.btnStop.disabled = !busy
el.btnRestart.disabled = !busy
renderDisplays(status.displays || [])
}
function renderDisplays(displays) {
el.displayList.textContent = ''
if (!displays.length) {
const li = document.createElement('li')
li.className = 'muted'
li.textContent = '尚未打开大屏窗口'
el.displayList.append(li)
return
}
for (const item of displays) {
const li = document.createElement('li')
const page = document.createElement('span')
page.className = 'page'
page.textContent = `屏 ${item.displayId} · ${item.page}`
const url = document.createElement('span')
url.className = 'url'
url.textContent = item.url || '(加载中)'
li.append(page, url)
el.displayList.append(li)
}
}
function renderInstances(payload) {
el.instanceList.textContent = ''
if (!payload || !payload.ok) {
el.instancesSummary.textContent = ''
const li = document.createElement('li')
li.className = 'muted'
li.textContent = (payload && payload.message) || '暂时无法读取大屏实例'
el.instanceList.append(li)
return
}
const list = payload.instances || []
el.instancesSummary.textContent = `共 ${list.length} 个 · ${list.filter((item) => item.online).length} 在线`
if (!list.length) {
const li = document.createElement('li')
li.className = 'muted'
li.textContent = '当前没有大屏实例连接'
el.instanceList.append(li)
return
}
for (const item of list) {
const li = document.createElement('li')
const page = document.createElement('span')
page.className = 'page'
page.textContent = item.page || '未知页面'
const id = document.createElement('span')
id.className = 'url'
id.textContent = item.id || '—'
const state = document.createElement('span')
state.className = `state ${item.online ? 'online' : 'offline'}`
state.textContent = item.online ? '在线' : '离线'
li.append(page, id, state)
el.instanceList.append(li)
}
}
function appendLog(line) {
const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8
el.log.textContent += `${line}\n`
if (el.autoscroll.checked && atBottom) {
el.log.scrollTop = el.log.scrollHeight
}
}
function setLog(lines) {
el.log.textContent = lines.length ? `${lines.join('\n')}\n` : ''
el.log.scrollTop = el.log.scrollHeight
}
async function refresh() {
renderStatus(await window.consoleApi.getStatus())
}
function renderMaximized(maximized) {
el.winMax.classList.toggle('is-maximized', maximized)
const label = maximized ? '还原' : '最大化'
el.winMax.title = label
el.winMax.setAttribute('aria-label', label)
}
function bindWindowControls() {
el.winMin.addEventListener('click', () => window.consoleApi.minimize())
el.winMax.addEventListener('click', () => window.consoleApi.maximize())
el.winClose.addEventListener('click', () => window.consoleApi.close())
// 双击标题栏切换最大化;点在按钮上的双击不算,否则连点会误触
el.titlebar.addEventListener('dblclick', (event) => {
if (event.target instanceof Element && event.target.closest('.win-btn')) return
window.consoleApi.maximize()
})
window.consoleApi.onMaximizeChange(renderMaximized)
window.consoleApi.isMaximized().then(renderMaximized)
}
function bindActions() {
el.btnStart.addEventListener('click', async () => renderStatus(await window.consoleApi.startService()))
el.btnStop.addEventListener('click', async () => renderStatus(await window.consoleApi.stopService()))
el.btnRestart.addEventListener('click', async () => renderStatus(await window.consoleApi.restartService()))
el.btnRelayout.addEventListener('click', async () => renderStatus(await window.consoleApi.relayoutDisplays()))
el.btnClearLog.addEventListener('click', async () => {
await window.consoleApi.clearLog()
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() {
bindActions()
bindWindowControls()
window.consoleApi.onStatus(renderStatus)
window.consoleApi.onLog(appendLog)
window.consoleApi.onAccountOutput(handleAccountOutput)
window.consoleApi.onDisplayInstances(renderInstances)
setLog(await window.consoleApi.getLog())
await refresh()
// 主进程只在窗口可见时轮询;这里补一次,保证窗口刚打开就有数据
renderInstances(await window.consoleApi.getDisplayInstances())
}
boot()