fix(HEL-540): 星港中枢返工——去除每帧DOM重建、修复事件风暴节流

- 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 <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-13 12:50:11 +08:00
co-authored by Cursor multica-agent
parent 475ed4a872
commit ab98f63692
+16 -6
View File
@@ -174,10 +174,20 @@ const FLASH_ON = 90, FLASH_GAP = 160, MAX_CLUSTER = 3;
const GLOBAL_WINDOW = 1000, GLOBAL_CAP = 3; const GLOBAL_WINDOW = 1000, GLOBAL_CAP = 3;
const globalFlashStarts = []; const globalFlashStarts = [];
function reserveGlobalSlot(tNow) { function reserveGlobalSlot(tNow) {
// 清掉确定已经过去(早于 tNow 一个窗口以上)的排队记录,
// 未来还会发生的排队起点必须保留,否则后续簇的计算会失去锚点。
while (globalFlashStarts.length && tNow - globalFlashStarts[0] > GLOBAL_WINDOW) globalFlashStarts.shift(); while (globalFlashStarts.length && tNow - globalFlashStarts[0] > GLOBAL_WINDOW) globalFlashStarts.shift();
if (globalFlashStarts.length < GLOBAL_CAP) { globalFlashStarts.push(tNow); return tNow; } const n = globalFlashStarts.length;
const wait = (globalFlashStarts[0] + GLOBAL_WINDOW - tNow) + 40 + Math.random() * 120; let start;
const start = tNow + Math.max(30, wait); 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.push(start);
globalFlashStarts.sort((a, b) => a - b); globalFlashStarts.sort((a, b) => a - b);
return start; return start;
@@ -1245,6 +1255,9 @@ const Stage = (() => {
el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; }); el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; });
} }
function syncUI(idx, p) { function syncUI(idx, p) {
// 脏标记驱动:只在幕次真正切换时才重建 #cabin / 重新定位 rail
// 不再每帧(60x/秒)重建,避免真实鼠标点击/输入所在节点被整体替换;
// 同幕内的数据更新交给下方 Views.onChange(真实数据变化时才刷新)。
if (idx !== cabinIdx) { if (idx !== cabinIdx) {
const el = $("cabin"); const el = $("cabin");
el.classList.add("hide"); el.classList.add("hide");
@@ -1255,10 +1268,7 @@ const Stage = (() => {
$("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`; $("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`;
$("phase").textContent = `${idx + 1} / ${SCENES.length}`; $("phase").textContent = `${idx + 1} / ${SCENES.length}`;
state.scene = idx; state.scene = idx;
} else {
renderCabin(idx);
} }
if (Detail.openKey) Detail.reopen();
} }
Views.onChange(() => { Views.onChange(() => {
if (running && cabinIdx >= 0) renderCabin(cabinIdx); if (running && cabinIdx >= 0) renderCabin(cabinIdx);