## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。
## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。
主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试
## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。
## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败
## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
376 lines
14 KiB
JavaScript
376 lines
14 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'),
|
|
openAtLogin: document.getElementById('sw-open-at-login'),
|
|
openAtLoginLabel: document.getElementById('sw-open-at-login-label'),
|
|
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',
|
|
// 服务端按设计退出(切换赛事 / 一键恢复)后的等待期:黄灯而不是红灯
|
|
restarting: 'warn',
|
|
crashed: 'err',
|
|
stopped: ''
|
|
}
|
|
|
|
// 没有 lastError 时按状态给一句人话,别让「重启中」落到通用的「等待健康检查」上
|
|
const DETAIL_FALLBACK = {
|
|
stopped: '服务未运行',
|
|
restarting: '服务端按设计退出,正在自动拉起'
|
|
}
|
|
|
|
function renderStatus(status) {
|
|
el.version.textContent = status.appVersion ? `v${status.appVersion}` : ''
|
|
|
|
// 日志上限跟着主进程配置走(status.logMaxLines):先更新上限再裁一次,
|
|
// 否则开机先灌进来的那批日志会一直超着
|
|
if (Number.isFinite(status.logMaxLines) && status.logMaxLines > 0 && status.logMaxLines !== logMaxLines) {
|
|
logMaxLines = status.logMaxLines
|
|
trimLog()
|
|
}
|
|
|
|
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 || DETAIL_FALLBACK[status.state] || '等待健康检查'
|
|
|
|
el.url.textContent = status.url || '—'
|
|
el.pid.textContent = status.pid || '—'
|
|
el.restarts.textContent = status.restarts ? `${status.restarts} 次` : '0 次'
|
|
el.power.textContent = status.powerSaveBlocked ? '已启用(阻止显示器休眠)' : '未启用'
|
|
renderOpenAtLogin(status)
|
|
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', 'restarting', 'stopping'].includes(status.state)
|
|
el.btnStart.disabled = busy
|
|
el.btnStop.disabled = !busy
|
|
el.btnRestart.disabled = !busy
|
|
|
|
renderDisplays(status.displays || [])
|
|
}
|
|
|
|
// 切换后状态快照可能还停在旧值(轮询与写配置存在时间差),用它兜底:
|
|
// 等快照追上新值再交还轮询,避免刚点下的开关被旧快照冲回去。
|
|
let openAtLoginPending = null
|
|
let openAtLoginPendingTimer = null
|
|
|
|
function setOpenAtLoginPending(value) {
|
|
openAtLoginPending = value
|
|
if (openAtLoginPendingTimer) clearTimeout(openAtLoginPendingTimer)
|
|
openAtLoginPendingTimer = null
|
|
if (value === null) return
|
|
openAtLoginPendingTimer = setTimeout(() => {
|
|
openAtLoginPending = null
|
|
openAtLoginPendingTimer = null
|
|
}, 5000)
|
|
}
|
|
|
|
function renderOpenAtLogin(status) {
|
|
const supported = status.openAtLoginSupported === true
|
|
let enabled = status.openAtLogin === true
|
|
if (openAtLoginPending !== null) {
|
|
if (enabled === openAtLoginPending) setOpenAtLoginPending(null)
|
|
else enabled = openAtLoginPending
|
|
}
|
|
el.openAtLogin.checked = enabled
|
|
el.openAtLogin.disabled = !supported
|
|
el.openAtLogin.title = supported ? '' : '仅打包后的 Windows 版本生效'
|
|
el.openAtLoginLabel.textContent = enabled ? '已开启' : '已关闭'
|
|
}
|
|
|
|
async function toggleOpenAtLogin() {
|
|
const wanted = el.openAtLogin.checked
|
|
// 写配置期间锁住开关,避免连点触发多次写入
|
|
el.openAtLogin.disabled = true
|
|
setOpenAtLoginPending(wanted)
|
|
el.openAtLoginLabel.textContent = wanted ? '已开启' : '已关闭'
|
|
|
|
let result
|
|
try {
|
|
result = await window.consoleApi.setOpenAtLogin(wanted)
|
|
} catch (err) {
|
|
result = { ok: false, message: err.message }
|
|
}
|
|
|
|
if (result && result.ok) {
|
|
// 用返回值即时更新,不必等下一次状态快照
|
|
const applied = result.openAtLogin === true
|
|
setOpenAtLoginPending(applied)
|
|
el.openAtLogin.checked = applied
|
|
el.openAtLoginLabel.textContent = applied ? '已开启' : '已关闭'
|
|
} else {
|
|
// 失败:回滚勾选;原因由主进程写进运行日志,这里再给个 title 提示
|
|
setOpenAtLoginPending(null)
|
|
el.openAtLogin.checked = !wanted
|
|
el.openAtLoginLabel.textContent = !wanted ? '已开启' : '已关闭'
|
|
el.openAtLogin.title = (result && result.message) || '设置开机自启失败'
|
|
}
|
|
|
|
el.openAtLogin.disabled = false
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 日志上限(行):与主进程 LogBus 的 log.maxLines 一致,由状态快照下发;
|
|
// 主进程只留最后 N 行,但每一行都会实时 IPC 过来,页面若只增不减,
|
|
// 长赛事跑一天会堆出几十万个文本节点,窗口越用越卡。
|
|
let logMaxLines = 2000
|
|
|
|
// 超上限就从头部丢:一行一个文本节点,单次追加是 O(1),不做整块 textContent 重排
|
|
function trimLog() {
|
|
while (el.log.childNodes.length > logMaxLines) el.log.removeChild(el.log.firstChild)
|
|
}
|
|
|
|
function appendLog(line) {
|
|
const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8
|
|
el.log.append(document.createTextNode(`${line}\n`))
|
|
trimLog()
|
|
if (el.autoscroll.checked && atBottom) {
|
|
el.log.scrollTop = el.log.scrollHeight
|
|
}
|
|
}
|
|
|
|
function setLog(lines) {
|
|
el.log.textContent = ''
|
|
for (const line of lines.slice(-logMaxLines)) el.log.append(document.createTextNode(`${line}\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)
|
|
el.openAtLogin.addEventListener('change', toggleOpenAtLogin)
|
|
}
|
|
|
|
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()
|