Files
xiaobai-review/xiaobai-datahub/admin/layouts/ledger.js
T
011ecc0d1a 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>
2026-09-13 16:28:50 +08:00

292 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 };
})();