Files
xiaobai-review/xiaobai-datahub/admin/layouts/strata.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

249 lines
12 KiB
JavaScript

"use strict";
/* ==========================================================================
C · 地层剖面 Strata —— 来源层 → 加工层 → 发布层 → 审计沉积层 四条全宽横带。
真实事件自上而下贯穿(脊线上的光点从上一层滑到下一层);点击任一层的项目
进入"选中"态,用同一颜色在其它层高亮可关联的真实行,解决跨层追踪费眼问题。
========================================================================== */
(function () {
const C = window.Core, S = window.HubShared;
const { $, esc, timeShort } = C;
let root = null;
let mounted = false;
let pendingTimers = [];
function safeTimeout(fn, ms) {
const id = setTimeout(() => { if (mounted) fn(); }, ms);
pendingTimers.push(id);
return id;
}
let unsubs = [];
let selection = null; // { type: 'provider'|'job'|'dataset', value }
const SKELETON = `
<div id="st-root" class="page-shell">
<div id="st-spine"></div>
<div id="st-dot"></div>
<section class="st-band card" data-band="sources">
<div class="st-idx"><span class="n">01</span><span class="lb">SOURCES<br/>来源层</span></div>
<div id="st-sources"></div>
</section>
<section class="st-band card" data-band="process">
<div class="st-idx"><span class="n">02</span><span class="lb">PROCESS<br/>加工层</span></div>
<div id="st-process">
<div class="st-jobs">
<span class="sec-label">调度任务 JOBS</span>
<div id="st-jobList"></div>
</div>
<div class="st-runs">
<span class="sec-label">最近运行 RUNS</span>
<div id="st-runsTable"></div>
</div>
</div>
</section>
<section class="st-band card" data-band="publish">
<div class="st-idx"><span class="n">03</span><span class="lb">PUBLISH<br/>发布层</span></div>
<div id="st-publish">
<div class="st-gate">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="bignum"><span class="n mono" id="st-pubnum">-</span><span class="cap">今日发布</span></div>
<div class="win mono" id="st-pubwin"></div>
<div class="toolrow" style="margin-top:8px;display:flex;gap:6px;">
<input id="st-rel-date" style="width:88px;font-family:var(--font-mono);font-size:11px;background:var(--bg2);border:1px solid var(--line);border-radius:5px;padding:4px 6px;color:var(--t1)" />
<button type="button" class="btn mini" id="st-rel-load">查看</button>
<button type="button" class="btn danger mini" id="st-rel-backfill">补数</button>
</div>
</div>
<div class="st-datasets">
<span class="sec-label">数据集 DATASETS</span>
<div id="st-datasetsTable"></div>
</div>
</div>
</section>
<section class="st-band card" data-band="audit">
<div class="st-idx"><span class="n">04</span><span class="lb">AUDIT<br/>审计层</span></div>
<div id="st-audit"></div>
</section>
</div>
`;
function applySelectionClasses() {
root.querySelectorAll("[data-sel-provider]").forEach((el) => el.classList.toggle("sel", selection && selection.type === "provider" && el.dataset.selProvider === selection.value));
root.querySelectorAll("[data-sel-job]").forEach((el) => el.classList.toggle("sel", selection && selection.type === "job" && el.dataset.selJob === selection.value));
root.querySelectorAll("[data-rel-job]").forEach((el) => el.classList.toggle("hi", selection && selection.type === "job" && el.dataset.relJob === selection.value));
root.querySelectorAll("[data-rel-dataset]").forEach((el) => el.classList.toggle("hi", selection && selection.type === "dataset" && el.dataset.relDataset === selection.value));
}
function setSelection(type, value) {
selection = (selection && selection.type === type && selection.value === value) ? null : { type, value };
applySelectionClasses();
}
function renderSources() {
const v = S.sourcesView();
const host = $("st-sources");
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="st-src card" data-sel-provider="${esc(it.provider)}" data-provider="${esc(it.provider)}">
<div class="row">
<span class="lamp link ${C.linkLampClass(link)}"></span>
<span class="lamp act" data-act></span>
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
${C.chipHtml(link, health.state || link)}
</div>
<div class="sub">${esc(C.FLOW_ROLE[it.provider] || it.role)} · 配置 ${(it.credential || {}).configured ? "已配置" : "未配置"} · ${it.calls_today ?? 0} 次</div>
<div class="row" style="margin-top:6px;justify-content:space-between">
<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>
</div>`;
}).join("") + (v.reserved.length ? `<div class="st-reserved">预留 ${v.reserved.length} 个<br/>未接入</div>` : "");
S.bindProbeButtons(host, () => renderSources());
host.querySelectorAll(".st-src").forEach((el) => el.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
setSelection("provider", el.dataset.provider);
}));
applySelectionClasses();
}
function renderProcess() {
const v = S.jobsView();
const jobHost = $("st-jobList");
if (!v) { jobHost.innerHTML = `<div class="muted">正在加载…</div>`; return; }
jobHost.innerHTML = v.jobs.map((job) => {
const latest = v.latestByJob.get(job.id);
return `<div class="st-job" data-sel-job="${esc(job.id)}" data-job="${esc(job.id)}">
${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(jobHost, () => renderProcess());
jobHost.querySelectorAll(".st-job").forEach((el) => el.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
setSelection("job", el.dataset.job);
}));
$("st-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], v.runs.map((r) => [
`<span class="mono" data-rel-job="${esc(r.job_id)}">${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>`,
]));
root.querySelectorAll("#st-runsTable tr").forEach((tr, i) => {
if (i === 0) return;
const run = v.runs[i - 1]; if (run) tr.dataset.relJob = run.job_id;
});
applySelectionClasses();
}
function renderPublish() {
const v = S.releaseView();
const dateInput = $("st-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!v) { $("st-pubnum").textContent = "-"; $("st-datasetsTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("st-pubnum").textContent = v.pubs.length;
const eod = (S.overviewView() || {}).eod || {};
$("st-pubwin").textContent = eod.state === "waiting_upstream" ? `等待上游 · 已试 ${eod.attempts ?? "-"} 次` : "";
$("st-datasetsTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], v.pubs.map((p) => [
`<span class="mono k" data-rel-dataset="${esc(p.dataset)}">${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>` : "-",
]));
root.querySelectorAll("#st-datasetsTable tr").forEach((tr, i) => {
if (i === 0) return;
const p = v.pubs[i - 1]; if (p) tr.dataset.relDataset = p.dataset;
});
S.bindRollbackButtons($("st-datasetsTable"), () => renderPublish());
applySelectionClasses();
}
function renderAudit() {
const v = S.auditView();
const host = $("st-audit");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.items.slice(0, 8).map((a) => `
<div class="st-audit-line ${String(a.action).includes("rollback") ? "warn" : ""}">
${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> ${esc(a.action)} · ${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}
</div>`).join("");
}
function refresh() { if (!root) return; renderSources(); renderProcess(); renderPublish(); renderAudit(); }
/* ---------------------------------------------------------------- 纵向贯穿动效 */
const BAND_ORDER = ["sources", "process", "publish", "audit"];
function pulseBand(key, err) {
if (!root) return;
const el = root.querySelector(`.st-band[data-band="${key}"]`);
if (!el) return;
el.classList.add("pulse"); el.classList.toggle("err", !!err);
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove("pulse", "err"), 700);
}
function travelSpine(fromKey, toKey) {
if (C.state.reduced || !root) return;
const dot = $("st-dot");
const a = root.querySelector(`.st-band[data-band="${fromKey}"]`);
const b = root.querySelector(`.st-band[data-band="${toKey}"]`);
if (!a || !b) return;
const rootRect = $("st-root").getBoundingClientRect();
const ar = a.getBoundingClientRect(), br = b.getBoundingClientRect();
const y0 = ar.top - rootRect.top + 20, y1 = br.top - rootRect.top + 20;
dot.style.top = y0 + "px"; dot.style.opacity = "0";
if (dot._anim) dot._anim.cancel();
dot._anim = dot.animate([
{ transform: "translateY(0)", opacity: 0 },
{ transform: "translateY(0)", opacity: 1, offset: .1 },
{ transform: `translateY(${y1 - y0}px)`, opacity: 1, offset: .9 },
{ transform: `translateY(${y1 - y0}px)`, opacity: 0 },
], { duration: 620, easing: "linear" });
}
function onEvent(evt) {
if (!root || !mounted) return;
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
const act = root.querySelector(`.st-src[data-provider="${evt.channel}"] [data-act]`);
if (act) {
act.classList.remove("on", "err"); act.classList.add("on"); if (evt.kind === "error") act.classList.add("err");
setTimeout(() => act.classList.remove("on", "err"), 800);
}
pulseBand("sources", evt.kind === "error");
safeTimeout(() => { travelSpine("sources", "process"); pulseBand("process"); renderSources(); }, 200);
} else if (evt.channel === "junction") {
pulseBand("process", evt.kind === "error");
safeTimeout(() => { travelSpine("process", "publish"); pulseBand("publish"); renderProcess(); }, 200);
} else if (evt.channel === "tx") {
pulseBand("publish", evt.kind === "rollback");
safeTimeout(() => { travelSpine("publish", "audit"); pulseBand("audit", evt.kind === "rollback"); renderPublish(); }, 200);
} else if (evt.channel === "audit") {
pulseBand("audit", evt.kind === "rollback");
renderAudit();
}
}
function mount(el) {
root = el;
mounted = true;
selection = null;
root.innerHTML = SKELETON;
refresh();
S.bindReleaseDateReload(root, "#st-rel-date", "#st-rel-load", () => renderPublish());
S.bindBackfillButton(root, "#st-rel-backfill", () => renderPublish());
unsubs.push(C.Bus.on("data", refresh));
unsubs.push(C.Bus.on("event", onEvent));
}
function unmount() {
mounted = false;
unsubs.forEach((fn) => fn()); unsubs = [];
pendingTimers.forEach(clearTimeout); pendingTimers = [];
root = null;
}
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
window.HUB_LAYOUTS.strata = { mount, unmount };
})();