feat(HEL-546): 数据中枢 A/B/C 三方向落地为可用页面

- 基于第七版稳定基线(be647bb)重构 admin 前端为模块化架构:
  tokens.css(设计token唯一来源) + shared.css(公共组件) + core.js(状态/API/事件总线/轮询/路由)
  + main.js(入口) + layouts/{flowline,ledger,strata}.{css,js}(三方向独立布局)
  + layouts/shared.js(三方向共用数据视图与操作绑定)
- 三个页面(A装配线/B值班台账/C地层剖面)通过 ?layout= 查询参数独立可达、可刷新、可前进后退导航,
  共享登录态、真实后端数据(overview/sources/jobs/batches/datasets/audit)、错误处理与主题
- 六大板块(总览/数据源/调度任务/盘后发布/数据集/审计)在三个方向均可查看与操作(探测/触发/回滚/补数)
- 修正健康状态语义:CircuitBreaker closed/half_open/open 与适配器 ok/error/empty/unconfigured/unknown
  统一归一化为 ok/warn/error/unconfigured/unknown 供三方向一致展示
- 事件驱动动效:A 水平接力光梭、B 行级高亮+实时调用跑马灯、C 纵向贯穿光点+分层标记;
  统一使用 transform/opacity/WAAPI,避免 transition:all,支持 prefers-reduced-motion 静态降级
- 修复响应式布局在 1024/1280 断点因 CSS Grid 默认 min-width:auto 被内部宽表格撑爆轨道的问题
- 修复布局切换/前进后退时残留 setTimeout 回调在 unmount 后访问 null root 导致的报错(mounted 标志位+
  safeTimeout+统一清理定时器)
- 自测:Playwright 全断点(1440/1280/1024)x 双主题矩阵截图、90 次连续布局切换+前进后退压力测试无报错、
  探测/触发/回滚danger操作流程验证、reduced-motion 验证;后端 pytest 全量 133 用例通过,无回归
- 未改动:登录/鉴权契约、后端 API、'问天'冻结区、生产数据/权限

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-13 16:28:50 +08:00
co-authored by Cursor multica-agent
parent be647bbaba
commit 011ecc0d1a
14 changed files with 1915 additions and 1666 deletions
+291
View File
@@ -0,0 +1,291 @@
"use strict";
/* ==========================================================================
B · 值班台账 Ledger Desk —— 表格本身就是主舞台,每条真实事件一行。
响应/汇入/处理/发布/审计五列只填该行真实具备的阶段,没有发生的阶段留空,
不做跨行编造关联。底部调用带只滚真实 recent_calls。
========================================================================== */
(function () {
const C = window.Core, S = window.HubShared;
const { $, esc, timeShort } = C;
let root = null;
let unsubs = [];
let seenRowKeys = new Set();
let mounted = false;
let pendingTimers = [];
function safeTimeout(fn, ms) {
const id = setTimeout(() => { if (mounted) fn(); }, ms);
pendingTimers.push(id);
return id;
}
const SKELETON = `
<div id="lg-root" class="page-shell">
<div id="lg-grid">
<div id="lg-side">
<section class="card lg-panel">
<span class="sec-label">来源健康 SOURCES</span>
<div id="lg-srcList"></div>
</section>
<section class="card lg-panel">
<span class="sec-label">调度任务 JOBS</span>
<div id="lg-jobList"></div>
</section>
</div>
<section class="card" id="lg-ledger">
<div class="lg-ledger-hd">
<span class="sec-label">事件台账 EVENT LEDGER</span>
<span class="hint">每条事件依次经过 响应→汇入→处理→发布→审计</span>
</div>
<div style="overflow:auto; max-height: 62vh;">
<table class="lg-ledger">
<thead><tr>
<th>时间</th><th>事件</th><th>来源</th>
<th class="stage">响应<br/><span style="opacity:.6">RESP</span></th>
<th class="stage">汇入<br/><span style="opacity:.6">INGEST</span></th>
<th class="stage">处理<br/><span style="opacity:.6">PROC</span></th>
<th class="stage">发布<br/><span style="opacity:.6">PUB</span></th>
<th class="stage">审计<br/><span style="opacity:.6">AUDIT</span></th>
</tr></thead>
<tbody id="lg-ledgerBody"></tbody>
</table>
</div>
<div id="lg-tape">
<span class="sec-label">调用带 CALL TAPE</span>
<div id="lg-tape-track"><div id="lg-tape-inner"></div></div>
</div>
</section>
<div id="lg-side-r">
<section class="card lg-panel" id="lg-release">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="toolrow">
<input id="lg-rel-date" value="" />
<button type="button" class="btn mini" id="lg-rel-load">查看</button>
<button type="button" class="btn danger mini" id="lg-rel-backfill">补数</button>
</div>
<div id="lg-pubTable"></div>
</section>
<section class="card lg-panel">
<span class="sec-label">审计 AUDIT</span>
<div id="lg-auditList"></div>
</section>
</div>
</div>
</div>
`;
function renderSources() {
const v = S.sourcesView();
const host = $("lg-srcList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map((it) => {
const health = it.health || {};
const link = it._link || C.linkState(health);
return `<div class="lg-src-row" data-provider="${esc(it.provider)}">
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}"></i><i class="lamp act" data-act></i></span>
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
${C.chipHtml(link, health.state || link)}
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
<button class="btn ghost mini" data-probe="${esc(it.provider)}">探测</button>
</div>`;
}).join("");
S.bindProbeButtons(host, () => renderSources());
}
function renderJobs() {
const v = S.jobsView();
const host = $("lg-jobList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.jobs.map((job) => {
const latest = v.latestByJob.get(job.id);
return `<div class="lg-jobline">
${C.chipHtml(latest ? latest.state : "idle")}
<span class="nm mono">${esc(job.id)}</span>
<span class="at mono">${esc(job.at)}</span>
<button class="btn ghost mini" data-run="${esc(job.id)}">触发</button>
</div>`;
}).join("");
S.bindRunButtons(host, () => renderJobs());
}
function renderRelease() {
const v = S.releaseView();
const dateInput = $("lg-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!v) { $("lg-pubTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("lg-pubTable").innerHTML = C.table(["数据集", "批次", "状态", "时间", ""], v.pubs.map((p) => [
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
C.chipHtml(p.state), `<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
S.bindRollbackButtons($("lg-pubTable"), () => renderRelease());
}
function renderAuditList() {
const v = S.auditView();
const host = $("lg-auditList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.items.slice(0, 10).map((a) => `
<div class="lg-auditline mono">
<div>${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> <span class="${String(a.action).includes("rollback") ? "warn" : ""}">${esc(a.action)}</span></div>
<div class="dim">${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}</div>
</div>`).join("");
}
/* ---------------------------------------------------------------- 台账行合成
每行只填该事件类型真实具备的阶段;不同类型互不编造对方的字段。 */
function cell(text, kind) {
if (text == null || text === "") return `<span class="lg-cell dash">—</span>`;
return `<span class="lg-cell ${kind || ""}">${esc(text)}</span>`;
}
function buildLedgerRows() {
const ov = S.overviewView(), jv = S.jobsView(), rv = S.releaseView(), av = S.auditView();
const rows = [];
if (ov) {
for (const c of ov.recentCalls) {
rows.push({
key: `call:${c.id}`, t: c.created_at, name: c.endpoint, sub: "来源响应", source: c.provider,
resp: cell(timeShort(c.created_at), c.ok ? "ok" : "err"),
ingest: c.ok ? cell(timeShort(c.created_at), "ok") : cell(),
proc: cell(), pub: cell(), audit: cell(),
err: !c.ok,
});
}
}
if (jv) {
for (const r of jv.runs.slice(0, 30)) {
const job = jv.jobs.find((j) => j.id === r.job_id);
rows.push({
key: `run:${r.id}`, t: r.started_at, name: (job && job.title) || r.job_id, sub: r.job_id, source: "system",
resp: cell(), ingest: cell(timeShort(r.started_at), "ok"),
proc: r.state === "running" ? cell("running", "warn") : r.state === "failed" ? cell(r.error || "failed", "err") : cell(timeShort(r.finished_at), "ok"),
pub: cell(), audit: cell(),
err: r.state === "failed",
});
}
}
if (rv) {
for (const p of rv.pubs) {
if (!p.published_at || p.published_at === "-") continue;
rows.push({
key: `pub:${p.dataset}:${p.active_batch}`, t: p.published_at, name: `批次发布 · ${p.dataset}`, sub: p.active_batch, source: "system",
resp: cell(), ingest: cell(), proc: cell(),
pub: cell(timeShort(p.published_at), p.state === "published" ? "ok" : "warn"),
audit: cell(),
err: p.state === "missing" || p.state === "error",
});
}
}
if (av) {
for (const a of av.items.slice(0, 30)) {
const isRollback = String(a.action).includes("rollback");
const isProbe = a.action === "probe";
rows.push({
key: `audit:${a.id}`, t: a.created_at, name: a.action, sub: a.target, source: a.actor,
resp: isProbe ? cell(timeShort(a.created_at), String(a.detail).includes("失败") ? "err" : "ok") : cell(),
ingest: cell(), proc: cell(),
pub: isRollback ? cell(timeShort(a.created_at), "warn") : cell(),
audit: cell(timeShort(a.created_at), isRollback ? "warn" : "ok"),
err: isRollback,
});
}
}
rows.sort((a, b) => String(b.t).localeCompare(String(a.t)));
return rows.slice(0, 60);
}
function renderLedger() {
const rows = buildLedgerRows();
const body = $("lg-ledgerBody");
body.innerHTML = rows.map((r) => `
<tr class="lg-row ${seenRowKeys.has(r.key) ? "" : "flashnew"} ${r.err ? "err" : ""}" data-key="${esc(r.key)}">
<td class="mono">${esc(timeShort(r.t))}</td>
<td class="ev"><div class="nm">${esc(r.name)}</div><div class="sub">${esc(r.sub || "")}</div></td>
<td class="src mono">${esc(r.source)}</td>
<td class="stage">${r.resp}</td>
<td class="stage">${r.ingest}</td>
<td class="stage">${r.proc}</td>
<td class="stage">${r.pub}</td>
<td class="stage">${r.audit}</td>
</tr>`).join("");
rows.forEach((r) => seenRowKeys.add(r.key));
if (!C.state.reduced) {
body.querySelectorAll("tr.flashnew").forEach((tr) => {
setTimeout(() => tr.classList.remove("flashnew"), 1000);
});
} else {
body.querySelectorAll("tr.flashnew").forEach((tr) => tr.classList.remove("flashnew"));
}
}
/* ---------------------------------------------------------------- 调用带:只滚真实 recent_calls */
function renderTape() {
const ov = S.overviewView();
const inner = $("lg-tape-inner");
if (!ov || !ov.recentCalls.length) { inner.innerHTML = `<span class="lg-tape-item muted">暂无真实调用</span>`; inner.style.animation = "none"; return; }
const items = ov.recentCalls.slice(0, 16).map((c) => `<span class="lg-tape-item ${c.ok ? "" : "err"}">${esc(timeShort(c.created_at))} <b>${esc(c.provider)}</b> ${esc(c.endpoint)} ${c.ok ? "✓" : "× " + esc(c.error)}</span>`).join("");
inner.innerHTML = items + items; // 首尾拼接形成无缝循环,内容仍全部来自真实调用
if (C.state.reduced) { inner.style.animation = "none"; return; }
const width = inner.scrollWidth / 2;
inner.style.animation = "none";
void inner.offsetWidth;
inner.style.setProperty("--tape-w", `-${width}px`);
inner.style.animation = `lg-tape-scroll ${Math.max(12, width / 40)}s linear infinite`;
}
if (!document.getElementById("lg-tape-keyframes")) {
const style = document.createElement("style");
style.id = "lg-tape-keyframes";
style.textContent = `@keyframes lg-tape-scroll { from { transform: translateX(0); } to { transform: translateX(var(--tape-w, -800px)); } }`;
document.head.appendChild(style);
}
function updateTapePauseState() {
const el = $("lg-tape");
if (!el) return;
el.classList.toggle("paused", !C.Poller.runtimeAvailable() || C.state.reduced);
}
function refresh() {
if (!root) return;
renderSources(); renderJobs(); renderRelease(); renderAuditList(); renderLedger(); renderTape(); updateTapePauseState();
}
function onEvent(evt) {
if (!root || !mounted) return;
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
const row = root.querySelector(`.lg-src-row[data-provider="${evt.channel}"] [data-act]`);
if (row) {
row.classList.remove("on", "err");
row.classList.add("on"); if (evt.kind === "error") row.classList.add("err");
setTimeout(() => row.classList.remove("on", "err"), 800);
}
}
renderLedger();
renderTape();
}
function mount(el) {
root = el;
mounted = true;
seenRowKeys = new Set();
root.innerHTML = SKELETON;
refresh();
S.bindReleaseDateReload(root, "#lg-rel-date", "#lg-rel-load", () => renderRelease());
S.bindBackfillButton(root, "#lg-rel-backfill", () => renderRelease());
unsubs.push(C.Bus.on("data", refresh));
unsubs.push(C.Bus.on("event", onEvent));
unsubs.push(C.Bus.on("runtime", updateTapePauseState));
unsubs.push(C.Bus.on("reduced", () => { renderTape(); updateTapePauseState(); }));
}
function unmount() {
mounted = false;
unsubs.forEach((fn) => fn()); unsubs = [];
pendingTimers.forEach(clearTimeout); pendingTimers = [];
root = null;
}
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
window.HUB_LAYOUTS.ledger = { mount, unmount };
})();