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
+300
View File
@@ -0,0 +1,300 @@
"use strict";
/* ==========================================================================
A · 装配线 Flowline —— 来源 → 加工 → 发布 → 审计 四工位水平因果链。
数据卡是线上工件,真实事件沿发丝导线从触发的那一工位接力到下一工位。
========================================================================== */
(function () {
const C = window.Core, S = window.HubShared;
const { $, esc, timeShort } = C;
let root = null;
let unsubs = [];
let stationFlashTimers = {};
let mounted = false;
let pendingTimers = [];
/* 布局随时可能被切走:任何跨阶段延时回调都必须先确认还挂载着,
否则 unmount 之后残留的 setTimeout 会在 root=null 时报错。 */
function safeTimeout(fn, ms) {
const id = setTimeout(() => { if (mounted) fn(); }, ms);
pendingTimers.push(id);
return id;
}
const SKELETON = `
<div id="fl-root" class="page-shell">
<section id="fl-band" class="card">
<div class="lane-labels">
<span class="sec-label">来源 SOURCES</span>
<span class="sec-label">加工 PROCESS</span>
<span class="sec-label">发布 PUBLISH</span>
<span class="sec-label">审计 AUDIT</span>
</div>
<div id="fl-wire"><span class="chev" style="left:26%">&rsaquo;</span><span class="chev" style="left:57%">&rsaquo;</span><span class="chev" style="left:80%">&rsaquo;</span></div>
<div id="fl-lanes">
<div id="fl-srcCards"></div>
<div id="fl-process" class="card fl-station" data-station="process">
<div class="hd"><span class="nm">中枢处理</span><span class="grow"></span><span data-pct class="mono">-</span></div>
<div id="fl-processBody"></div>
</div>
<div id="fl-publish" class="card fl-station fl-gate" data-station="publish">
<div class="hd"><span class="nm">发布闸口</span><span class="grow"></span><span class="sec-label">GATE</span></div>
<div id="fl-publishBody"></div>
</div>
<div id="fl-audit" class="card fl-station fl-audit" data-station="audit">
<div class="hd"><span class="nm">审计末端</span></div>
<div id="fl-auditBody"></div>
</div>
</div>
<div id="fl-packet"></div>
</section>
<div id="fl-cols">
<section id="fl-col1" class="card fl-col">
<span class="sec-label">最近调用 RECENT CALLS</span>
<div id="fl-callsTable"></div>
<div id="fl-callsFoot"></div>
</section>
<section id="fl-col2" class="card fl-col">
<span class="sec-label">调度任务 JOBS</span>
<div id="fl-jobRows"></div>
<span class="sec-label sub-label">最近运行 RUNS</span>
<div id="fl-runsTable"></div>
</section>
<section id="fl-col3" class="card fl-col">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="toolrow">
<input id="fl-rel-date" value="" spellcheck="false" />
<button type="button" class="btn" id="fl-rel-load">查看</button>
<button type="button" class="btn danger" id="fl-rel-backfill">补数</button>
</div>
<span class="sec-label sub-label">当前映射 MAPPING</span>
<div id="fl-pubTable"></div>
<div id="fl-anom" class="fl-anom"></div>
</section>
</div>
</div>
`;
function srcCardHtml(item) {
const health = item.health || {};
const link = item._link || C.linkState(health);
const cred = item.credential || {};
const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置";
return `
<div class="fl-src-card card" data-provider="${esc(item.provider)}">
<div class="edge"></div>
<div class="row">
<span class="name">${esc(C.FLOW_LABEL[item.provider] || item.provider)}</span>
<span class="role">${esc(C.FLOW_ROLE[item.provider] || item.role)}</span>
<span class="grow"></span>
${C.chipHtml(link, health.state || link)}
</div>
<div class="row">
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}" data-link></i><span class="lamp-tag">LINK</span></span>
<span class="lamps"><i class="lamp act" data-act></i><span class="lamp-tag">ACT</span></span>
<span class="grow"></span>
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
</div>
<div class="row">
<span class="cred mono">${credText}</span><span class="grow"></span>
<button class="btn ghost mini" data-probe="${esc(item.provider)}">探测一次</button>
</div>
</div>`;
}
function renderSources() {
const v = S.sourcesView();
const host = $("fl-srcCards");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map(srcCardHtml).join("") +
(v.reserved.length ? `<div class="fl-src-card card muted" style="align-items:center;justify-content:center;font-size:12px;grid-column: 1 / -1">预留源 ${v.reserved.length} 个 · 未接入 · 无真实调用</div>` : "");
S.bindProbeButtons(host);
}
function renderProcess() {
const jv = S.jobsView();
const body = $("fl-processBody");
const pctEl = root.querySelector('[data-pct]');
if (!jv || !jv.latest) { body.innerHTML = `<div class="muted">正在加载…</div>`; return; }
const latest = jv.latest;
const running = jv.runs.filter((r) => r.state === "running").length;
const pct = latest.state === "running" ? 62 : latest.state === "success" ? 100 : latest.state === "failed" ? 100 : 0;
pctEl.textContent = latest.state === "running" ? `${pct}%` : "空闲";
body.innerHTML = `
<div class="task mono"><b>${esc(latest.job_id)}</b> · ${esc((jv.jobs.find((j) => j.id === latest.job_id) || {}).title || "")}</div>
<div class="fl-pbar"><i style="width:${pct}%;background:${latest.state === "failed" ? "var(--error)" : "var(--action)"}"></i></div>
<div class="queue">队列 <b class="mono">${running}</b> · 最近 20 次失败 <span class="chip ${jv.failedRecent ? "err" : "ok"}">${jv.failedRecent}</span></div>
<div class="fl-miniruns">
${jv.runs.slice(0, 3).map((r) => `<div class="mr mono"><span class="id">${r.id}</span><span class="${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span><span class="${r.error ? "why" : "muted"}">${esc(r.error || (r.finished_at !== "-" && r.finished_at ? timeShort(r.finished_at) : "—"))}</span></div>`).join("")}
</div>`;
}
function renderPublish() {
const rv = S.releaseView();
const body = $("fl-publishBody");
if (!rv) { body.innerHTML = `<div class="muted">正在加载…</div>`; return; }
const valuation = rv.pubs.find((p) => p.dataset === "valuation");
const latestPub = rv.pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop();
body.innerHTML = `
<div class="bignum"><span class="n mono" data-pubnum>${rv.pubs.length}</span><span class="cap">今日发布</span></div>
<div class="latest mono">最新批次 ${esc(latestPub ? `${latestPub.active_batch} · ${latestPub.published_at}` : "暂无")}</div>
${valuation ? `<div class="valrow"><span class="mono">valuation</span>${C.chipHtml(valuation.state)}</div>` : ""}`;
}
function renderAudit() {
const av = S.auditView();
const body = $("fl-auditBody");
if (!av || !av.items.length) { body.innerHTML = `<div class="muted">正在加载…</div>`; return; }
const a0 = av.items[0];
body.innerHTML = `
<div class="al mono">${esc(timeShort(a0.created_at))} ${esc(a0.actor)} · ${esc(a0.action)}</div>
<div class="al mono dim">${esc(a0.target)}${a0.detail ? " · " + esc(a0.detail) : ""}</div>
<div class="adiv"></div>
${av.items.slice(1, 3).map((a) => `<div class="al mono dim">${esc(timeShort(a.created_at))} ${esc(a.actor)} · ${esc(a.action)}</div>`).join("")}`;
}
function renderCalls() {
const ov = S.overviewView();
const sv = S.sourcesView();
if (!ov) { $("fl-callsTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("fl-callsTable").innerHTML = C.table(["时间", "源", "端点", "结果", "耗时"], ov.recentCalls.map((row) => [
`<span class="mono">${esc(timeShort(row.created_at))}</span>`,
`<span class="mono k">${esc(row.provider)}</span>`,
`<span class="mono">${esc(row.endpoint)}</span>`,
row.ok ? '<span class="res-ok">成功</span>' : `<span class="res-err mono">${esc(row.error)}</span>`,
`<span class="mono">${row.latency_ms ?? "-"} ms</span>`,
]));
if (sv) {
$("fl-callsFoot").textContent = "今日调用 · " + sv.live.map((s) => `${s.provider} ${s.calls_today ?? 0}`).join(" · ");
}
}
function renderJobsCol() {
const jv = S.jobsView();
const jobRows = $("fl-jobRows");
if (!jv) { jobRows.innerHTML = `<div class="muted">正在加载…</div>`; return; }
jobRows.innerHTML = jv.jobs.map((job) => `
<div class="fl-jobrow">
<span class="jid mono">${esc(job.id)}</span><span class="jti">${esc(job.title)}</span>
<span class="jat mono">${esc(job.at)}</span>${C.chipHtml(jv.latestByJob.get(job.id) ? jv.latestByJob.get(job.id).state : "idle")}
<span class="grow"></span><button class="btn ghost mini" data-run="${esc(job.id)}">手动触发</button>
</div>`).join("");
S.bindRunButtons(jobRows);
$("fl-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], jv.runs.map((r) => [
`<span class="mono">${r.id}</span>`, `<span class="mono k">${esc(r.job_id)}</span>`,
`<span class="mono ${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span>`,
`<span class="mono">${esc(timeShort(r.started_at))}</span>`, `<span class="mono">${esc(timeShort(r.finished_at))}</span>`,
`<span class="fail">${esc(r.error || "")}</span>`,
]));
}
function renderReleaseCol() {
const rv = S.releaseView();
const dateInput = $("fl-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!rv) { $("fl-pubTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("fl-pubTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], rv.pubs.map((p) => [
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
`<span class="mono">${esc(p.prev_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($("fl-pubTable"), () => renderReleaseCol());
const failed = rv.batches.filter((b) => b.state === "failed" || b.state === "error");
$("fl-anom").innerHTML = failed.length
? failed.map((b) => `<span class="chip err mono">${esc(b.batch_id)} · ${esc(b.dataset)} · ${esc(b.error || "异常")}</span>`).join(" ")
: `<span class="chip ok">批次全部正常</span>`;
}
function refresh() {
if (!root) return;
renderSources(); renderProcess(); renderPublish(); renderAudit();
renderCalls(); renderJobsCol(); renderReleaseCol();
}
/* ---------------------------------------------------------------- 真实事件动效
水平接力:source ACT 闪 → 沿导线光梭滑到 process → process 高亮 → 光梭滑到
publish → publish 高亮 → 光梭滑到 audit → audit 高亮。reduced 时只做状态高亮,
不做位移。 */
function flashSource(provider, kind) {
const card = root.querySelector(`.fl-src-card[data-provider="${provider}"]`);
if (!card) return;
const act = card.querySelector("[data-act]");
act.classList.remove("on", "err");
act.classList.add("on"); if (kind === "error" || kind === "rollback") act.classList.add("err");
const edge = card.querySelector(".edge");
edge.classList.toggle("err", kind === "error");
edge.style.opacity = "1";
clearTimeout(card._flashTimer);
card._flashTimer = setTimeout(() => { act.classList.remove("on", "err"); edge.style.opacity = "0"; }, 820);
return card;
}
function pulseStation(key, ms) {
const el = root.querySelector(`.fl-station[data-station="${key}"]`);
if (!el) return;
el.classList.add("pulse");
clearTimeout(stationFlashTimers[key]);
stationFlashTimers[key] = setTimeout(() => el.classList.remove("pulse"), ms || 640);
}
function travelPacket(fromEl, toEl, color) {
if (!fromEl || !toEl || C.state.reduced) return;
const pk = $("fl-packet");
const band = $("fl-band").getBoundingClientRect();
const a = fromEl.getBoundingClientRect(), b = toEl.getBoundingClientRect();
const x0 = a.left + a.width / 2 - band.left, y0 = a.top + a.height / 2 - band.top;
const x1 = b.left + b.width / 2 - band.left, y1 = b.top + b.height / 2 - band.top;
pk.style.background = color || "var(--packet)";
pk.style.top = y0 - 1.5 + "px"; pk.style.left = x0 - 17 + "px"; pk.style.opacity = "0";
if (pk._anim) pk._anim.cancel();
pk._anim = pk.animate([
{ transform: "translate(0,0)", opacity: 0 },
{ transform: "translate(0,0)", opacity: 1, offset: .08 },
{ transform: `translate(${x1 - x0}px, ${y1 - y0}px)`, opacity: 1, offset: .92 },
{ transform: `translate(${x1 - x0}px, ${y1 - y0}px)`, opacity: 0 },
], { duration: 640, easing: "linear" });
pk._anim.onfinish = () => { pk.style.opacity = "0"; };
}
function onEvent(evt) {
if (!root || !mounted) return;
const process = root.querySelector('.fl-station[data-station="process"]');
const publish = root.querySelector('.fl-station[data-station="publish"]');
const audit = root.querySelector('.fl-station[data-station="audit"]');
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
const card = flashSource(evt.channel, evt.kind);
safeTimeout(() => { travelPacket(card, process, evt.kind === "error" ? "var(--error)" : "var(--packet)"); pulseStation("process", 700); }, 260);
renderCalls();
} else if (evt.channel === "junction") {
pulseStation("process", 700);
safeTimeout(() => { travelPacket(process, publish, "var(--packet)"); pulseStation("publish", 700); renderProcess(); }, 260);
} else if (evt.channel === "tx") {
pulseStation("publish", 700);
safeTimeout(() => { travelPacket(publish, audit, evt.kind === "rollback" ? "var(--error)" : "var(--packet)"); pulseStation("audit", 700); renderPublish(); renderReleaseCol(); }, 260);
} else if (evt.channel === "audit") {
pulseStation("audit", 700);
renderAudit();
}
}
function mount(el) {
root = el;
mounted = true;
root.innerHTML = SKELETON;
refresh();
S.bindReleaseDateReload(root, "#fl-rel-date", "#fl-rel-load", () => renderReleaseCol());
S.bindBackfillButton(root, "#fl-rel-backfill", () => renderReleaseCol());
unsubs.push(C.Bus.on("data", refresh));
unsubs.push(C.Bus.on("event", onEvent));
}
function unmount() {
mounted = false;
unsubs.forEach((fn) => fn()); unsubs = [];
Object.values(stationFlashTimers).forEach(clearTimeout); stationFlashTimers = {};
pendingTimers.forEach(clearTimeout); pendingTimers = [];
root = null;
}
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
window.HUB_LAYOUTS.flowline = { mount, unmount };
})();