fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)

## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

+8 -5
View File
@@ -25,6 +25,7 @@ console/
│ ├── userpaths.js # 配置/规则目录解析 + 首次复制迁移(config.json / config.yaml / rules)
│ ├── userpaths.test.js # 配置/规则路径的单元测试
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
│ ├── service.test.js # 守护逻辑的单元测试(注入假 spawn/时钟/HTTP)
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
@@ -87,6 +88,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
- Console 把最终目录通过服务端的 `-data <绝对路径>` 传下去,`passwd` 子命令带同一个参数,保证改的是同一个库里的账号;
- 迁移只在**打包态**做一次:**目标目录为空(或不存在)且 `<安装目录>\data` 有数据**时,用 `fs.renameSync` 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留;
- **「没有旧数据」与「读不到旧目录」分开处理**:旧目录不存在/存在但空才是 `no-legacy`(不写日志);旧目录读不出来(EACCES、ENOTDIR 等)返回 `legacy-unreadable` 并写一条「读不到旧数据目录」警告,绝不当成「没有旧数据」静默跳过(回归测试见 `main/datadir.test.js`);
- 两边都有数据时**不做任何动作**,只写一条醒目日志提示人工确认,两份都不会被自动删除;
- 迁移结果与最终数据目录都写进 `logs/console.log`(搜「数据目录」「已把旧数据从」);
- 数据目录若仍在安装目录内,启动时会额外打一条警告。
@@ -126,7 +128,8 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 页面锁定 | 每块屏各锁一页,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` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 服务端崩溃 | 按 `restartDelayMs` **线性**退避(第 N 次等 N × `restartDelayMs`,最多 5 倍)自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 服务端按设计退出 | 切换赛事 / 一键恢复时服务端**先回执 200 再自己优雅退出**(退出码 0)。这种退出**不当崩溃**:状态是「重启中」(黄灯,不是红灯)、崩溃计数不 +1、同样按 `restartDelayMs` 自动拉起。判定见 `service.js` 的 `isPlannedExit`:退出码 0 **且**落在预期退出窗口内(最后一次健康检查通过后最多 15 秒,HealthInterval 更大时按两个探测周期放宽)——刚起来就退 0 或卡死很久才退 0 都仍按崩溃处理 |
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 |
| 配置与规则 | 同样在 `%LOCALAPPDATA%\ScoreboardConsole\`(`console\config.json`、`config.yaml`、`rules\`),打包态首次从安装目录复制、之后只用用户目录那份;拉起服务端时 `-config` 传绝对路径(见「配置与规则目录」一节) |
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
@@ -137,7 +140,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录;管理窗口按同一个 `log.maxLines` 截断(超上限从头部丢,长跑不变卡) |
| 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) |
## 配置项(`config.json`;打包态生效的是用户数据目录里那份)
@@ -150,7 +153,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| `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.restartDelayMs` | `3000` | 重启基础退避:**第 N 次等 N × 本值**(×1..×5,线性,不是翻倍) |
| `server.maxRestarts` | `0` | 0 = 无限重启 |
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 |
| `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 |
@@ -165,7 +168,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 |
| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 |
| `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) |
| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
| `admin.width` / `admin.height` | `1280` / `860` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) |
配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。
@@ -182,7 +185,7 @@ pnpm dist # electron-builder --win --x64 → ../dist/console/
pnpm pack # 只出目录,不生成安装包,便于现场调试
```
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.1-x64.*`)。
- 产物:NSIS 安装包 + zip,文件名形如 `Scoreboard Console-<版本>-x64.*`。`<版本>` 不是手写的:`deploy/prepare-extra.mjs` 在打包前读仓库根 `VERSION`(当前 `dev_0.1.8`)去掉 `dev_` 前缀写进 `console/package.json` 的 `version`,`artifactName` 用 `${productName}-${version}-${arch}.${ext}` 展开 —— 也就是说**这里不要手改版本号**,改仓库根的 `VERSION`。
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。
- 部署包根目录的说明书是可选的:`deploy/prepare-extra.mjs` 在打包前按一张清单暂存到 `console/extra/`(`extraFiles` 会把该目录内容平铺到安装目录根):`docs/使用手册.md` → `使用说明.txt`,`docs/报名名单导入规范.md` → `报名名单导入规范.txt`(各自优先取 `docs/` 下的同名 `.txt`)。缺哪个就在构建日志里注明并跳过。
- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
+10
View File
@@ -80,6 +80,16 @@ function migrateLegacyData({ targetDir, legacyDir, log }) {
return { migrated: false, reason: 'target-unreadable' }
}
// 旧目录读不出来(EACCES / EPERM / ENOTDIR 等)与「没有旧数据」是两回事:
// 前者可能是一份没搬成功的真实数据,不能静默按「没数据」处理,必须留一条日志让人去人工确认。
if (legacy === 'error') {
notify(
`警告:读不到旧数据目录 ${legacyDir}(权限不足或路径不可用),本次不做迁移。` +
'请人工确认该目录里是否有需要保留的数据'
)
return { migrated: false, reason: 'legacy-unreadable' }
}
if (target === 'has-data') {
if (legacy === 'has-data') {
notify(
+35
View File
@@ -189,3 +189,38 @@ test('目标路径读不出来时按「可能有数据」处理,不做迁移',
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'db')
assert.match(lines.join('\n'), /跳过旧数据迁移/)
})
test('旧路径读不出来时不当成「没有旧数据」,要记一条日志', (t) => {
const root = makeRoot(t)
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
fs.mkdirSync(targetDir, { recursive: true })
// 旧目录的上级是个文件:readdir 报 ENOTDIR,等同于现场遇到 EACCES 这类「读不到」
const blocker = path.join(root, 'blocker')
writeFile(blocker, 'not a dir')
const lines = []
const result = migrateLegacyData({
targetDir,
legacyDir: path.join(blocker, 'data'),
log: (line) => lines.push(line)
})
assert.equal(result.migrated, false)
assert.equal(result.reason, 'legacy-unreadable')
assert.match(lines.join('\n'), /读不到旧数据目录/)
})
test('确实没有旧数据时只返回 no-legacy,不写警告日志', (t) => {
const root = makeRoot(t)
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
fs.mkdirSync(targetDir, { recursive: true })
const legacyDir = path.join(root, 'install', 'data')
fs.mkdirSync(legacyDir, { recursive: true })
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, false)
assert.equal(result.reason, 'no-legacy')
assert.deepEqual(lines, [])
})
+11 -6
View File
@@ -90,7 +90,10 @@ function main() {
powerSaveBlocked: powerBlockerId !== null ? powerSaveBlocker.isStarted(powerBlockerId) : false,
openAtLogin: openAtLoginEffective(),
openAtLoginSupported: app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin'),
appVersion: app.getVersion()
appVersion: app.getVersion(),
// 渲染层按同一个上限裁剪日志节点(见 renderer/app.js appendLog):
// 主进程只留最后 maxLines 行,页面若只增不减,长赛事跑一天会把窗口拖卡
logMaxLines: cfg ? cfg.values.log.maxLines : null
}
}
@@ -332,7 +335,13 @@ function main() {
function buildTrayMenu() {
const status = statusSnapshot()
const busy = status.state === 'starting' || status.state === 'running' || status.state === 'stopping'
// 「重启中」也算忙:此时子进程已经退出、自动拉起还排在定时器上,
// 允许点「启动服务」会与那次自动拉起抢同一个端口
const busy =
status.state === 'starting' ||
status.state === 'running' ||
status.state === 'restarting' ||
status.state === 'stopping'
return Menu.buildFromTemplate([
{
label: `服务端:${status.stateLabel}${status.healthy ? ' · 就绪' : ''}`,
@@ -534,10 +543,6 @@ function main() {
})
ipcMain.handle('display:open', (event, displayId, options) => openDisplayWindow(displayId, options))
ipcMain.handle('display:close', (event, displayId) => closeDisplayWindow(displayId))
ipcMain.handle('app:open-admin-window', () => {
showAdmin()
return true
})
ipcMain.handle('app:open-server-admin', () => {
showServerAdmin()
return true
+100 -29
View File
@@ -14,6 +14,9 @@ const STATE_LABEL = {
starting: '启动中',
running: '运行中',
stopping: '停止中',
// 服务端自己按设计退出(切换赛事 / 一键恢复)后的等待期:与「异常退出」必须分开显示,
// 现场看到红灯会以为服务挂了,实际是正常收尾等 Console 拉回来。
restarting: '重启中',
crashed: '异常退出'
}
@@ -29,6 +32,18 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000
const FALLBACK_FORCE_KILL_MS = 1500
// 连续健康这么久才认为崩溃计数可以清零
const HEALTHY_RESET_MS = 60000
// 「预期退出窗口」的下限:最后一次确认服务端还活着之后的这段时间内退出,才可能是按设计退出。
// 健康探测默认 5 秒一次,窗口至少覆盖两三个探测周期,避免刚好卡在探测空档时漏判。
const EXPECTED_EXIT_WINDOW_MS = 15000
// 副作用边界集中在这里注入:进程、时钟、HTTP。生产路径直接用 Node 的实现,
// 单元测试传假实现,才能在毫秒内跑完退避、超时、优雅关闭这些真实要等几秒的分支。
const defaultTimers = {
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (id) => clearTimeout(id),
setInterval: (fn, ms) => setInterval(fn, ms),
clearInterval: (id) => clearInterval(id)
}
// 服务端可能监听 0.0.0.0/::,本机访问(健康检查、关闭握手、大屏、管理面板)统一走回环地址
function healthHost(server) {
@@ -39,7 +54,7 @@ function healthHost(server) {
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log, dataDir, configPath }) {
constructor({ values, exePath, log, dataDir, configPath, spawn: spawnFn, http: httpClient, timers, now }) {
super()
this.values = values
this.server = values.server
@@ -50,6 +65,12 @@ class ServiceSupervisor extends EventEmitter {
// 绝对路径,非空时覆盖 args 里的 -config;为空表示沿用 args 原值(开发态)
this.configPath = configPath || null
// 可注入的副作用边界,默认就是 Node 的实现
this.spawn = spawnFn || spawn
this.http = httpClient || http
this.timers = { ...defaultTimers, ...(timers || {}) }
this.now = now || (() => Date.now())
this.child = null
this.state = 'stopped'
this.healthy = false
@@ -60,6 +81,9 @@ class ServiceSupervisor extends EventEmitter {
this.stopRequested = false
this.healthySince = null
this.disposed = false
// 最近一次确认服务端还在正常服务(健康检查通过)的时刻,用于「预期退出窗口」判定
this.lastAliveAt = null
this.healthTimer = null
this.restartTimer = null
this.forceKillTimer = null
@@ -128,9 +152,12 @@ class ServiceSupervisor extends EventEmitter {
this.lastError = null
this.healthySince = null
// 新进程重新起算「还活着」的时刻:否则上一个进程的健康记录会让
// 「刚起来就退出 0」被误判成按设计退出
this.lastAliveAt = null
this.gracefulAccepted = false
this.forcedKill = false
this.startedAt = new Date().toISOString()
this.startedAt = new Date(this.now()).toISOString()
this.setState('starting')
this.log.push(
'console',
@@ -143,7 +170,7 @@ class ServiceSupervisor extends EventEmitter {
let child
try {
child = spawn(this.exePath, spawnArgs, {
child = this.spawn(this.exePath, spawnArgs, {
cwd,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
@@ -183,7 +210,7 @@ class ServiceSupervisor extends EventEmitter {
// 必须在这里清掉 child/pid 并调度重启,否则 start() 因 child 非空直接返回、
// stop() 永远等不到 exit,守护器彻底卡死。
handleSpawnError(child, err) {
if (this.child !== child) return
if (this.disposed || this.child !== child) return
this.stopHealthCheck()
this.child = null
this.pid = null
@@ -194,10 +221,11 @@ class ServiceSupervisor extends EventEmitter {
}
handleExit(child, code, signal) {
// 迟到的 exit(error 分支已收尾或已换进程)不能动当前状态
if (this.child !== child) return
// 迟到的 exit(error 分支已收尾、已换进程、或已经 dispose)不能动当前状态;
// 尤其是 dispose 之后:应用正在退出,这里再排一次自动重启会留下一个没人管的循环
if (this.disposed || this.child !== child) return
if (this.forceKillTimer) {
clearTimeout(this.forceKillTimer)
this.timers.clearTimeout(this.forceKillTimer)
this.forceKillTimer = null
}
this.stopHealthCheck()
@@ -216,22 +244,60 @@ class ServiceSupervisor extends EventEmitter {
return
}
if (this.isPlannedExit(code)) {
// 切换赛事 / 一键恢复:服务端先回执再自行优雅退出,Console 的职责是把它拉回来。
// 这不能按崩溃算——否则现场正常切个赛事会显示红灯、崩溃计数 +1。
this.log.push('console', `服务端按设计退出(${reason},切换赛事 / 一键恢复),准备自动拉起`)
this.setState('restarting', { lastError: null })
this.scheduleRestart({ planned: true })
return
}
this.setState('crashed', { lastError: `服务端意外退出(${reason})` })
this.scheduleRestart()
}
scheduleRestart() {
// 「按设计退出」的判定窗口(毫秒):最后一次确认服务端还活着之后允许退出的时间。
// 健康间隔配得大时窗口跟着放大,至少覆盖两个探测周期。
expectedExitWindowMs() {
const interval = Number(this.server.healthIntervalMs) || 5000
return Math.max(EXPECTED_EXIT_WINDOW_MS, interval * 2)
}
// 服务端主动退出(退出码 0)与崩溃靠两点区分:
// 1) 退出码:Go 只有从 main 正常返回才是 0,panic 是 2、装配失败是 os.Exit(1)、被信号杀则没有退出码;
// 2) 是否在预期退出窗口内:切换赛事 / 一键恢复一定发生在服务端刚刚还在正常服务的时候
// (管理面板先拿到 200 回执,服务端随后才 Shutdown),所以窗口从最后一次健康检查通过起算。
// 窗口外退出 0 说明这次运行从没健康过,那不是切赛事而是异常,仍旧按崩溃处理。
isPlannedExit(code) {
return (
code === 0 &&
this.lastAliveAt !== null &&
this.now() - this.lastAliveAt <= this.expectedExitWindowMs()
)
}
// planned=true 表示按设计退出后的拉起:不占崩溃配额、不递增崩溃计数,
// 延迟仍用 restartDelayMs——服务端刚退出,端口与文件句柄要留一点回收时间。
scheduleRestart({ planned = false } = {}) {
const max = Number(this.server.maxRestarts) || 0
if (max > 0 && this.restarts >= max) {
if (!planned && max > 0 && this.restarts >= max) {
this.log.push('console', `已达到最大重启次数 ${max},不再自动拉起`)
return
}
const delay = (Number(this.server.restartDelayMs) || 3000) * Math.min(this.restarts + 1, 5)
this.restarts += 1
this.log.push('console', `${Math.round(delay / 1000)} 秒后自动重启服务端(第 ${this.restarts} 次)`)
// 退避是线性的 ×1..×5(不是翻倍):第 N 次等 N × restartDelayMs
const attempt = planned ? 1 : this.restarts + 1
const delay = (Number(this.server.restartDelayMs) || 3000) * Math.min(attempt, 5)
if (!planned) this.restarts += 1
this.log.push(
'console',
planned
? `${Math.round(delay / 1000)} 秒后自动拉起服务端(按设计退出,不计入崩溃次数)`
: `${Math.round(delay / 1000)} 秒后自动重启服务端(第 ${this.restarts} 次)`
)
this.emitStatus()
if (this.restartTimer) clearTimeout(this.restartTimer)
this.restartTimer = setTimeout(() => {
if (this.restartTimer) this.timers.clearTimeout(this.restartTimer)
this.restartTimer = this.timers.setTimeout(() => {
this.restartTimer = null
if (!this.stopRequested) this.start()
}, delay)
@@ -240,7 +306,7 @@ class ServiceSupervisor extends EventEmitter {
stop() {
return new Promise((resolve) => {
if (this.restartTimer) {
clearTimeout(this.restartTimer)
this.timers.clearTimeout(this.restartTimer)
this.restartTimer = null
}
const child = this.child
@@ -260,7 +326,7 @@ class ServiceSupervisor extends EventEmitter {
if (settled) return
settled = true
if (this.stopTimer) {
clearTimeout(this.stopTimer)
this.timers.clearTimeout(this.stopTimer)
this.stopTimer = null
}
resolve()
@@ -268,7 +334,7 @@ class ServiceSupervisor extends EventEmitter {
// 兜底:握手与强杀链路都可能因进程状态异常收不到 exit,
// 退出流程不能永远挂住,超时后无条件放行并补一刀强杀。
this.stopTimer = setTimeout(() => {
this.stopTimer = this.timers.setTimeout(() => {
this.stopTimer = null
this.log.push('console', '等待服务端退出超时,停止流程强制返回')
if (this.child === child) {
@@ -313,7 +379,7 @@ class ServiceSupervisor extends EventEmitter {
requestShutdown() {
return new Promise((resolve) => {
const body = Buffer.from('{}')
const request = http.request(
const request = this.http.request(
{
host: this.healthHost(),
port: this.server.port,
@@ -344,8 +410,8 @@ class ServiceSupervisor extends EventEmitter {
}
armForceKill(ms) {
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
this.forceKillTimer = setTimeout(() => {
if (this.forceKillTimer) this.timers.clearTimeout(this.forceKillTimer)
this.forceKillTimer = this.timers.setTimeout(() => {
this.forceKillTimer = null
if (!this.child || !this.pid) return
this.forcedKill = true
@@ -368,20 +434,20 @@ class ServiceSupervisor extends EventEmitter {
this.stopHealthCheck()
const interval = Number(this.server.healthIntervalMs) || 5000
const tick = () => this.probe()
setTimeout(tick, 800)
this.healthTimer = setInterval(tick, interval)
this.timers.setTimeout(tick, 800)
this.healthTimer = this.timers.setInterval(tick, interval)
}
stopHealthCheck() {
if (this.healthTimer) {
clearInterval(this.healthTimer)
this.timers.clearInterval(this.healthTimer)
this.healthTimer = null
}
}
probe() {
if (!this.child) return
const request = http.get(
const request = this.http.get(
{
host: this.healthHost(),
port: this.server.port,
@@ -401,7 +467,9 @@ class ServiceSupervisor extends EventEmitter {
setHealthy(ok) {
const changed = this.healthy !== ok
if (ok) {
if (!this.healthySince) this.healthySince = Date.now()
// 探测通过 = 这一刻服务端还在正常服务,是「预期退出窗口」的起算点
this.lastAliveAt = this.now()
if (!this.healthySince) this.healthySince = this.now()
} else {
this.healthySince = null
}
@@ -415,7 +483,7 @@ class ServiceSupervisor extends EventEmitter {
let reset = false
if (ok && this.restarts > 0) {
const resetAfter = Number(this.server.healthyResetMs) || HEALTHY_RESET_MS
if (Date.now() - this.healthySince >= resetAfter) {
if (this.now() - this.healthySince >= resetAfter) {
this.restarts = 0
reset = true
this.log.push('console', '服务端持续健康,崩溃计数已清零')
@@ -426,9 +494,12 @@ class ServiceSupervisor extends EventEmitter {
}
dispose() {
if (this.restartTimer) clearTimeout(this.restartTimer)
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
if (this.stopTimer) clearTimeout(this.stopTimer)
// dispose 之后连子进程的 exit 都不再处理(见 handleExit 的守卫),
// 否则「应用已退出、子进程后死」会重新排一次重启,留下一个没人观察的守护循环
this.disposed = true
if (this.restartTimer) this.timers.clearTimeout(this.restartTimer)
if (this.forceKillTimer) this.timers.clearTimeout(this.forceKillTimer)
if (this.stopTimer) this.timers.clearTimeout(this.stopTimer)
this.stopHealthCheck()
this.removeAllListeners()
}
+485
View File
@@ -0,0 +1,485 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { EventEmitter } = require('node:events')
const { ServiceSupervisor, healthHost } = require('./service')
// service.js 的外部依赖只有三条边界:spawn / 时钟 / HTTP。构造函数支持注入后,
// 「等 3 秒退避」「优雅关闭超时强杀」这些真实要挂几秒的分支可以在毫秒内跑完,
// 而且能精确断言「第几次重启、等了多少毫秒、进程被怎么结束的」。
// 这里不碰真实进程与网络,全部用假实现;真机链路(.exe 拉起、端口探测)只能在 Windows 上跑。
const flush = () => new Promise((resolve) => setImmediate(resolve))
// 假时钟:定时器只在 advance() 里执行,测试因此完全不受真实时间影响
function makeClock(start = Date.parse('2026-09-11T00:00:00.000Z')) {
let now = start
let seq = 0
const tasks = new Map()
const schedule = (fn, ms, every) => {
const id = ++seq
tasks.set(id, { fn, at: now + Math.max(0, Number(ms) || 0), every })
return id
}
return {
now: () => now,
timers: {
setTimeout: (fn, ms) => schedule(fn, ms, 0),
clearTimeout: (id) => tasks.delete(id),
setInterval: (fn, ms) => schedule(fn, ms, Math.max(1, Number(ms) || 1)),
clearInterval: (id) => tasks.delete(id)
},
scheduled: () => tasks.size,
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, task] of tasks) {
if (task.at <= target && (!due || task.at < due.task.at)) due = { id, task }
}
if (!due) break
now = due.task.at
if (due.task.every) due.task.at = now + due.task.every
else tasks.delete(due.id)
due.task.fn()
}
now = target
}
}
}
// 假子进程:只实现 service.js 用到的那几样(stdout/stderr 流、exit/error 事件、kill)
class FakeChild extends EventEmitter {
constructor(pid) {
super()
this.pid = pid
this.stdout = new FakeStream()
this.stderr = new FakeStream()
this.kills = []
}
kill(signal) {
this.kills.push(signal || 'SIGTERM')
return true
}
// code 为 null、signal 非空 = 被信号杀(Node 在 Windows 上强杀时 code 非 0)
exit(code, signal = null) {
this.emit('exit', code, signal)
}
}
class FakeStream extends EventEmitter {
setEncoding() {}
}
// 假 HTTP:健康探测与关闭握手分开控制,测试自己决定服务端「答不答、答什么」
function makeHttpStub() {
const state = {
probeStatus: 200,
probeError: null,
shutdown: 'accept',
probes: [],
shutdowns: []
}
return {
state,
get(options, callback) {
state.probes.push(options)
const request = new EventEmitter()
request.destroy = () => {}
process.nextTick(() => {
if (state.probeError) request.emit('error', new Error(state.probeError))
else callback({ statusCode: state.probeStatus, resume() {} })
})
return request
},
request(options, callback) {
const request = new EventEmitter()
request.destroy = (err) => {
if (err) request.emit('error', err)
}
request.end = () => {
state.shutdowns.push(options)
process.nextTick(() => {
if (state.shutdown === 'accept') callback({ statusCode: 200, resume() {} })
else if (state.shutdown === 'http500') callback({ statusCode: 500, resume() {} })
else request.emit('error', new Error('connect ECONNREFUSED 127.0.0.1:19540'))
})
}
return request
}
}
}
function makeLog() {
const lines = []
return {
lines,
push: (source, message) => lines.push(`[${source}] ${message}`),
append: (source, chunk) => lines.push(`[${source}] ${String(chunk)}`),
text: () => lines.join('\n')
}
}
function setup(t, serverOverrides = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-service-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const exePath = path.join(root, 'scoreboard.exe')
fs.writeFileSync(exePath, 'stub')
const clock = makeClock()
const http = makeHttpStub()
const log = makeLog()
const children = []
const spawnCalls = []
const service = new ServiceSupervisor({
values: {
server: {
exe: 'scoreboard.exe',
args: ['-config', 'config.yaml'],
host: '127.0.0.1',
port: 19540,
healthIntervalMs: 5000,
restartDelayMs: 3000,
maxRestarts: 0,
shutdownPath: '/api/shutdown',
shutdownTimeoutMs: 5000,
healthyResetMs: 60000,
...serverOverrides
}
},
exePath,
log,
spawn: (file, args, options) => {
spawnCalls.push({ file, args, options })
const child = new FakeChild(1000 + children.length)
children.push(child)
return child
},
http,
timers: clock.timers,
now: clock.now
})
return { service, clock, http, log, children, spawnCalls }
}
// 让健康探测通过一次:等 800ms 的首次探测 + 一次 IPC 往返
async function markAlive(clock) {
clock.advance(800)
await flush()
}
test('healthHost:监听 0.0.0.0/:: 时本机探测改走回环地址', () => {
assert.equal(healthHost({ host: '0.0.0.0' }), '127.0.0.1')
assert.equal(healthHost({ host: '::' }), '127.0.0.1')
assert.equal(healthHost({ host: '192.168.1.10' }), '192.168.1.10')
assert.equal(healthHost({}), '127.0.0.1')
})
test('崩溃:退出码非 0 → 红灯 + 崩溃计数 +1,退避是线性的 ×1..×5', async (t) => {
const { service, clock, children, spawnCalls, log } = setup(t)
service.start()
assert.equal(service.state, 'running')
assert.equal(spawnCalls.length, 1)
// 参数里带上了 -config 的绝对路径与目录,spawn 的工作目录是 exe 所在目录
assert.equal(spawnCalls[0].options.cwd, path.dirname(spawnCalls[0].file))
assert.ok(spawnCalls[0].options.env.SCOREBOARD_SHUTDOWN_TOKEN)
children[0].exit(1)
assert.equal(service.state, 'crashed')
assert.equal(service.restarts, 1)
assert.match(service.lastError, /退出码 1/)
assert.match(log.text(), /3 秒后自动重启服务端(第 1 次)/)
// 第 1 次退避 = 3000ms(×1),到点自动拉起
clock.advance(2999)
assert.equal(spawnCalls.length, 1)
clock.advance(1)
assert.equal(spawnCalls.length, 2)
// 第 2 次退避 = 6000ms(×2):线性递增,不是翻倍
children[1].exit(1)
assert.equal(service.restarts, 2)
clock.advance(5999)
assert.equal(spawnCalls.length, 2)
clock.advance(1)
assert.equal(spawnCalls.length, 3)
assert.match(log.text(), /6 秒后自动重启服务端(第 2 次)/)
// 连崩到第 6 次,退避封顶在 5 倍(×5),不再继续放大
for (let i = 0; i < 4; i += 1) {
children[children.length - 1].exit(1)
clock.advance(15000)
}
assert.equal(service.restarts, 6)
children[children.length - 1].exit(1)
assert.match(log.text(), /15 秒后自动重启服务端(第 7 次)/)
})
test('崩溃:达到 maxRestarts 后不再自动拉起', async (t) => {
const { service, clock, children, spawnCalls, log } = setup(t, { maxRestarts: 2 })
service.start()
children[0].exit(1)
clock.advance(3000)
children[1].exit(1)
clock.advance(6000)
// 配额内的两次自动重启已经用掉
assert.equal(service.restarts, 2)
assert.equal(spawnCalls.length, 3)
children[2].exit(1)
assert.equal(service.state, 'crashed')
assert.match(log.text(), /已达到最大重启次数 2,不再自动拉起/)
clock.advance(60000)
assert.equal(spawnCalls.length, 3)
})
test('崩溃:spawn 只发 error 不发 exit 时也要调度重启(否则守护器卡死)', async (t) => {
const { service, clock, children, spawnCalls } = setup(t)
service.start()
children[0].emit('error', new Error('spawn EACCES'))
assert.equal(service.state, 'crashed')
assert.equal(service.child, null)
assert.match(service.lastError, /EACCES/)
clock.advance(3000)
assert.equal(spawnCalls.length, 2)
})
test('优雅停止:握手受理 → 等它自己退出,状态是已停止且不重启', async (t) => {
const { service, clock, children, http, spawnCalls, log } = setup(t)
service.start()
const stopping = service.stop()
await flush()
assert.equal(service.state, 'stopping')
assert.equal(http.state.shutdowns.length, 1)
// 握手带上了启动时下发的 token 与关闭路径
assert.equal(http.state.shutdowns[0].path, '/api/shutdown')
assert.match(http.state.shutdowns[0].headers['X-Shutdown-Token'], /^[0-9a-f]{64}$/)
assert.equal(http.state.shutdowns[0].method, 'POST')
assert.match(log.text(), /已接受优雅关闭请求/)
// 受理后不会先去发信号,等服务端自己退出
assert.deepEqual(children[0].kills, [])
children[0].exit(0)
await stopping
assert.equal(service.state, 'stopped')
assert.equal(service.lastError, null)
assert.match(log.text(), /服务端已优雅退出(退出码 0)/)
clock.advance(60000)
assert.equal(spawnCalls.length, 1)
})
test('优雅停止:握手不可用 → 退回强杀,退出后仍算已停止而不是崩溃', async (t) => {
const { service, clock, children, http, log } = setup(t)
http.state.shutdown = 'unreachable'
service.start()
const stopping = service.stop()
await flush()
assert.match(log.text(), /优雅关闭接口不可用,退回强制结束/)
assert.deepEqual(children[0].kills, ['SIGTERM'])
// 强杀兜底到期前进程自己退了 → 记「已停止」(不是「已被强制结束」)
children[0].exit(1, 'SIGTERM')
await stopping
assert.equal(service.state, 'stopped')
assert.match(log.text(), /服务端已停止(信号 SIGTERM)/)
})
test('优雅停止:服务端拖着不退 → 到点强杀并放行,流程不会永远挂住', async (t) => {
const { service, clock, children, log } = setup(t)
let settled = false
service.start()
const stopping = service.stop().then(() => {
settled = true
})
await flush()
// 受理后等 shutdownTimeoutMs(5000) 再补一刀强杀,不能先发信号
clock.advance(4999)
assert.deepEqual(children[0].kills, [])
clock.advance(1)
assert.deepEqual(children[0].kills, ['SIGKILL'])
assert.match(log.text(), /服务端未在 5 秒内退出,强制结束/)
children[0].exit(null, 'SIGKILL')
await stopping
assert.equal(settled, true)
assert.equal(service.state, 'stopped')
assert.match(log.text(), /服务端已被强制结束(信号 SIGKILL)/)
})
test('停止:没有子进程时直接回到已停止,并取消已排期的重启', async (t) => {
const { service, clock, children, spawnCalls } = setup(t)
service.start()
children[0].exit(1)
assert.equal(service.state, 'crashed')
// 崩溃等待期内点「停止」:不能等到点又把服务端拉起来
await service.stop()
assert.equal(service.state, 'stopped')
clock.advance(60000)
assert.equal(spawnCalls.length, 1)
})
test('按设计退出:健康过的服务端以退出码 0 退出 → 重启中(不是红灯),不计崩溃', async (t) => {
const { service, clock, children, spawnCalls, log } = setup(t)
service.start()
await markAlive(clock)
assert.equal(service.healthy, true)
// 切换赛事 / 一键恢复:服务端回执 200 后自行优雅退出
children[0].exit(0)
assert.equal(service.state, 'restarting')
assert.equal(service.status.stateLabel, '重启中')
assert.equal(service.restarts, 0)
assert.equal(service.lastError, null)
assert.match(log.text(), /服务端按设计退出(退出码 0,切换赛事 \/ 一键恢复)/)
assert.match(log.text(), /不计入崩溃次数/)
// 仍然要自动拉起(延迟与首次崩溃退避一致,留端口回收时间)
clock.advance(3000)
assert.equal(spawnCalls.length, 2)
})
test('按设计退出:全新进程刚起来就退出码 0,不算按设计退出(当崩溃处理)', async (t) => {
const { service, children, log } = setup(t)
service.start()
children[0].exit(0)
// 没健康过就没有「预期退出窗口」:这不是切赛事,按崩溃退避重试
assert.equal(service.state, 'crashed')
assert.equal(service.restarts, 1)
assert.match(service.lastError, /服务端意外退出(退出码 0)/)
assert.doesNotMatch(log.text(), /按设计退出/)
})
test('按设计退出:健康记录超出预期退出窗口后退出码 0 → 仍按崩溃处理', async (t) => {
const { service, clock, http, children } = setup(t)
service.start()
await markAlive(clock)
assert.equal(service.healthy, true)
// 服务端卡死:探测开始失败,最后一次确认「还活着」的时间停留在窗口之前
http.state.probeError = 'connect ECONNREFUSED 127.0.0.1:19540'
clock.advance(20000)
await flush()
assert.equal(service.healthy, false)
children[0].exit(0)
assert.equal(service.state, 'crashed')
assert.equal(service.restarts, 1)
})
test('健康恢复:持续健康超过 healthyResetMs 后崩溃计数清零', async (t) => {
const { service, clock, children, log } = setup(t)
service.start()
children[0].exit(1)
clock.advance(3000)
assert.equal(service.restarts, 1)
await markAlive(clock)
clock.advance(60000)
await flush()
assert.equal(service.restarts, 0)
assert.match(log.text(), /服务端持续健康,崩溃计数已清零/)
})
test('健康检查失败:探测报错时状态为不健康但不改变进程状态', async (t) => {
const { service, clock, http } = setup(t)
http.state.probeError = 'connect ECONNREFUSED 127.0.0.1:19540'
service.start()
await markAlive(clock)
assert.equal(service.healthy, false)
assert.equal(service.state, 'running')
assert.match(service.status.url, /^http:\/\/127\.0\.0\.1:19540$/)
})
test('手动重启:先优雅停再拉起,崩溃计数清零', async (t) => {
const { service, clock, children, spawnCalls } = setup(t)
service.start()
children[0].exit(1)
clock.advance(3000)
assert.equal(service.restarts, 1)
const restarting = service.restart()
await flush()
children[children.length - 1].exit(0)
await restarting
assert.equal(service.restarts, 0)
assert.equal(service.state, 'running')
assert.equal(spawnCalls.length, 3)
})
test('迟到的 exit:换过进程之后旧进程的 exit 不能改当前状态', async (t) => {
const { service, clock, children } = setup(t)
service.start()
children[0].exit(1)
clock.advance(3000)
const current = children[1]
// 第二次崩溃前,第一个进程迟到的 exit 事件又冒出来一次
service.handleExit(children[0], 1, null)
assert.equal(service.state, 'running')
assert.equal(service.child, current)
})
test('dispose:清掉所有排期,之后不再拉起新进程', async (t) => {
const { service, clock, children, spawnCalls } = setup(t)
service.start()
children[0].exit(1)
assert.equal(service.state, 'crashed')
service.dispose()
clock.advance(60000)
assert.equal(spawnCalls.length, 1)
})
test('dispose:子进程随后才退出也不能再排一次重启', async (t) => {
const { service, clock, children, spawnCalls } = setup(t)
service.start()
const child = children[0]
service.dispose()
// 应用已经在退出,这个迟到的 exit 不该再被当成崩溃去安排守护
child.exit(1)
assert.equal(service.state, 'running')
clock.advance(60000)
assert.equal(spawnCalls.length, 1)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.1.9",
"version": "0.2.0",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+31 -4
View File
@@ -41,18 +41,33 @@ 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 || (status.state === 'stopped' ? '服务未运行' : '等待健康检查')
: status.lastError || DETAIL_FALLBACK[status.state] || '等待健康检查'
el.url.textContent = status.url || '—'
el.pid.textContent = status.pid || '—'
@@ -69,7 +84,7 @@ function renderStatus(status) {
el.configWarning.hidden = true
}
const busy = ['starting', 'running', 'stopping'].includes(status.state)
const busy = ['starting', 'running', 'restarting', 'stopping'].includes(status.state)
el.btnStart.disabled = busy
el.btnStop.disabled = !busy
el.btnRestart.disabled = !busy
@@ -198,16 +213,28 @@ function renderInstances(payload) {
}
}
// 日志上限(行):与主进程 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.textContent += `${line}\n`
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 = lines.length ? `${lines.join('\n')}\n` : ''
el.log.textContent = ''
for (const line of lines.slice(-logMaxLines)) el.log.append(document.createTextNode(`${line}\n`))
el.log.scrollTop = el.log.scrollHeight
}