- 基于第七版稳定基线(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>
249 lines
12 KiB
JavaScript
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 };
|
|
})();
|