From ab98f636924edd84a9c873830491ee78fc5e9798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sun, 13 Sep 2026 12:50:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(HEL-540):=20=E6=98=9F=E6=B8=AF=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E8=BF=94=E5=B7=A5=E2=80=94=E2=80=94=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E6=AF=8F=E5=B8=A7DOM=E9=87=8D=E5=BB=BA=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=8B=E4=BB=B6=E9=A3=8E=E6=9A=B4=E8=8A=82=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncUI 不再在幕次未变化时每帧调用 renderCabin,也不再每帧无条件 Detail.reopen; 仅在幕次切换(数据驱动的 idx 变化)时重建 #cabin,同幕内容更新交给既有的 Views.onChange(真实数据变化时才刷新),彻底避免正在点击/输入/聚焦的节点被 60fps 循环整体替换。 - reserveGlobalSlot 排队延迟起点改为基于「倒数第 GLOBAL_CAP 个已排队起点」 递增计算,而不是只锚定队首,修复事件风暴下多簇延迟起点挤在同一时间段、 导致任意1秒窗口新起簇数远超3的问题。 自测(本地 127.0.0.1,真实 Hub 后端 + Playwright/Chromium 真实鼠标事件): - python -m pytest xiaobai-datahub/tests:133/133 通过 - node --check xiaobai-datahub/admin/app.js:通过 - git diff --check:无尾随空白 - 控制舱按钮连续 8 次真实点击:0 次失败(此前 0 次命中) - 数据源“探测一次”连续 6 次点击:产生 6 次真实 POST(此前 0 个) - 盘后发布日期输入 20991231:保持 2 秒以上未被重置,值仍为 20991231 - 10 个并发 pollSources() 事件风暴复测:共 29 簇,任意滚动1秒窗口最大新起簇数 = 3(此前最多 20) Co-authored-by: Cursor Co-authored-by: multica-agent --- xiaobai-datahub/admin/app.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index 00f8691..a71abbd 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -174,10 +174,20 @@ const FLASH_ON = 90, FLASH_GAP = 160, MAX_CLUSTER = 3; const GLOBAL_WINDOW = 1000, GLOBAL_CAP = 3; const globalFlashStarts = []; function reserveGlobalSlot(tNow) { + // 清掉确定已经过去(早于 tNow 一个窗口以上)的排队记录, + // 未来还会发生的排队起点必须保留,否则后续簇的计算会失去锚点。 while (globalFlashStarts.length && tNow - globalFlashStarts[0] > GLOBAL_WINDOW) globalFlashStarts.shift(); - if (globalFlashStarts.length < GLOBAL_CAP) { globalFlashStarts.push(tNow); return tNow; } - const wait = (globalFlashStarts[0] + GLOBAL_WINDOW - tNow) + 40 + Math.random() * 120; - const start = tNow + Math.max(30, wait); + const n = globalFlashStarts.length; + let start; + if (n < GLOBAL_CAP) { + // 队列未满:本簇可以立即起闪。 + start = tNow; + } else { + // 队列已排到 CAP:新起点必须晚于「倒数第 CAP 个已排队起点」一个窗口以上, + // 这样任何滚动 1 秒窗口内的新起簇数量都 ≤ CAP,而不是全都挤在同一段延迟里。 + const anchor = globalFlashStarts[n - GLOBAL_CAP]; + start = Math.max(tNow, anchor + GLOBAL_WINDOW + 40 + Math.random() * 120); + } globalFlashStarts.push(start); globalFlashStarts.sort((a, b) => a - b); return start; @@ -1245,6 +1255,9 @@ const Stage = (() => { el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; }); } function syncUI(idx, p) { + // 脏标记驱动:只在幕次真正切换时才重建 #cabin / 重新定位 rail, + // 不再每帧(60x/秒)重建,避免真实鼠标点击/输入所在节点被整体替换; + // 同幕内的数据更新交给下方 Views.onChange(真实数据变化时才刷新)。 if (idx !== cabinIdx) { const el = $("cabin"); el.classList.add("hide"); @@ -1255,10 +1268,7 @@ const Stage = (() => { $("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`; $("phase").textContent = `${idx + 1} / ${SCENES.length}`; state.scene = idx; - } else { - renderCabin(idx); } - if (Detail.openKey) Detail.reopen(); } Views.onChange(() => { if (running && cabinIdx >= 0) renderCabin(cabinIdx);