- 基于第七版稳定基线(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>
301 lines
15 KiB
JavaScript
301 lines
15 KiB
JavaScript
"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%">›</span><span class="chev" style="left:57%">›</span><span class="chev" style="left:80%">›</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 };
|
|
})();
|