Files
xiaobai-review/xiaobai-datahub/admin/app.js
T
be647bbaba feat(HEL-529): 数据中枢后台改造为「第七版·轨道机芯」全动效单页
按已通过样图(HEL-527,白栖知“按照这个试试吧”)重做 xiaobai-datahub
管理后台(8766/admin/),仅改 admin/index.html、admin/styles.css、
admin/app.js 三个文件,零新依赖、不改构建流程、不触碰 datahub/ 后端、
Docker/Compose、8765 主站与冻结区“问天”。

核心实现:
- 单个 76vh sticky 空间舞台(#stageWrap,position: sticky 钉在视口),
  #track 六段 130vh 透明占位撑高文档、驱动滚动进度,舞台本体在滚动期间
  保持不动,直到六幕滚完才随之离场。
- 一枚持续旋转的 3D 数据机芯(canvas 2D 手工透视投影,无 WebGL/新依赖),
  六幕分别对应总览/数据源/调度任务/盘后发布/数据集/审计,切换幕时机芯
  换面/爆炸展开/合拢,4 条数据流通道持续汇入机芯。
- A2 路由式 LINK/ACT 双灯:LINK 是稳态真实连通性(取自 /admin/api/sources
  健康探测结果,仅 tushare/eastmoney/tencent/ifind 四路可流动,ths/xgb/
  akshare 属永久预留源,不参与流动动画);ACT 严格由真实事件驱动——
  tushare 靠 recent_calls 增量 diff,其余三源靠健康探测“探测动作本身
  就是一次真实网络调用”,手动“探测一次”按钮同样触发真实后端请求
  (已用真实浏览器验证会打到 /admin/api/sources/<provider>/probe)。
  同源事件簇最多闪 3 次,全站闪烁令牌桶限流 ≤3 簇/秒,各源不共享时钟。
- 动效令牌统一:进出用强 ease-out,屏内位移用强 ease-in-out,持续流动用
  linear;全文件禁止 ease-in、禁止 transition: all。
- prefers-reduced-motion:静态六幕面板(StaticShell)与动效版共用同一套
  cabin/detail/bind 渲染函数,信息与交互完全对等,媒体查询变化时可不
  刷新页面实时切换;系统级 CSS 兜底同样生效。
- 页面隐藏 / 断网即暂停所有轮询与 rAF 循环、清空未播闪烁队列,恢复时
  只静默重建基线、不补播错过的事件。
- 保留原有登录/改密/登出、数据源探测、任务重跑、盘后发布二次确认
  (密码+确认词)、回滚/补数等全部后端接口调用与危险操作确认流程。

自测(均在本地临时环境完成,未连接生产库/生产网络):
- `python -m unittest discover -s xiaobai-datahub/tests -v`:133 项全过。
- `node --check xiaobai-datahub/admin/app.js`:语法通过。
- `git diff --check`:无空白/换行问题;`git status`:仅上述 3 个文件改动。
- 起本地 datahub 服务 + Playwright 真实无头浏览器,22 项端到端断言全过:
  画面渲染、六幕滚动到底/导航跳转、日夜切换、机芯点击开合详情、后台
  切换(RAF 真停)、断网/恢复、reduced-motion 实时切换、探测按钮触发
  真实后端调用等。过程中定位并修复两处真实缺陷:
  1) #stageWrap 原为 position: relative,未真正钉住舞台,滚动时机芯会
     随页面滚走——已改为 sticky,现验证滚动任意距离机芯位置不变。
  2) 点击机芯打开详情硬编码成“数据源”,已改为按当前所在幕动态选择。
  另外补上了此前遗漏的 #phase 幕序指示(如“3 / 6”),静态版切幕同步
  更新顶部 crumb/phase。

未覆盖:未在 1440/1280/1024 三档做像素级视觉走查(仅验证 1024 无横向
溢出),未做真实弱网/高延迟环境下的手动观察,只做了断网模拟。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-12 23:35:51 +08:00

1342 lines
65 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";
/* ==========================================================================
xiaobai-datahub 管理后台 · 第七版「轨道机芯」
一台持续运转的数据空间:滚动推镜、立体核心换面、四路真实来源持续流动、
A2 路由器式 LINK/ACT 信号灯——全部由真实接口数据驱动,没有任何模拟流量。
只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测
(每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件",
因此只有这四路会闪 ACTths/xgb/akshare 是预留源,永远不闪。
========================================================================== */
/* ---------------------------------------------------------------- 基础工具 */
function $(id) { return document.getElementById(id); }
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
}
function timeShort(value) {
const s = String(value ?? "");
const m = s.match(/(\d{2}:\d{2}:\d{2})/);
return m ? m[1] : s.replace("T", " ").slice(0, 16);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
const state = {
csrf: "",
scene: 0,
releaseDate: "",
reduced: false,
visible: document.visibilityState === "visible",
online: navigator.onLine,
data: { overview: null, sources: null, jobs: null, batches: null, datasets: null, audit: null },
};
async function api(path, options = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {});
if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf;
const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" }));
const body = await res.json();
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
throw new Error(msg);
}
return body;
}
function show(id) {
["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
}
function table(headers, rows) {
const thead = headers.map((h) => `<th>${esc(h)}</th>`).join("");
const body = rows.length
? rows.map((cols) => `<tr>${cols.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")
: `<tr><td colspan="${headers.length}">暂无数据</td></tr>`;
return `<table><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
}
/* ---------------------------------------------------------------- 登录 / 会话 */
async function boot() {
try {
const session = await api("/admin/api/session");
state.csrf = session.csrf;
$("who").textContent = session.username;
if (session.must_change) { show("change-view"); return; }
show("shell");
enterShell();
} catch {
show("login-view");
}
}
$("login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
$("login-error").hidden = true;
try {
const result = await api("/admin/api/login", {
method: "POST",
body: JSON.stringify({ username: form.get("username"), password: form.get("password") }),
});
state.csrf = result.csrf;
if (result.must_change) show("change-view");
else { show("shell"); enterShell(); }
} catch (err) {
$("login-error").hidden = false;
$("login-error").textContent = err.message;
}
});
$("change-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
try {
await api("/admin/api/change-password", {
method: "POST",
body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }),
});
show("shell");
enterShell();
} catch (err) {
$("change-error").hidden = false;
$("change-error").textContent = err.message;
}
});
$("logout-btn").addEventListener("click", async () => {
await api("/admin/api/logout", { method: "POST", body: "{}" });
Poller.stopAll();
Stage.stop();
show("login-view");
});
/* ---------------------------------------------------------------- 主题(日/夜) */
function applyTheme(theme) {
const root = document.documentElement;
if (theme === "night") root.setAttribute("data-theme", "night");
else root.removeAttribute("data-theme");
$("theme-btn").textContent = theme === "night" ? "日间" : "夜间";
try { localStorage.setItem("hub_theme", theme); } catch { /* ignore storage errors */ }
Stage.onThemeChange(theme);
}
$("theme-btn").addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day";
applyTheme(current === "night" ? "day" : "night");
});
/* ---------------------------------------------------------------- 减少动态效果 & 可见性 & 网络 */
const REDUCE_MQ = matchMedia("(prefers-reduced-motion: reduce)");
function applyReduced(reduced) {
state.reduced = reduced;
document.documentElement.classList.toggle("reduced", reduced);
if (reduced) { Stage.stop(); StaticShell.mount(); }
else { StaticShell.unmount(); Stage.start(); }
}
REDUCE_MQ.addEventListener("change", (e) => applyReduced(e.matches));
document.addEventListener("visibilitychange", () => {
state.visible = document.visibilityState === "visible";
onRuntimeAvailabilityChange();
});
window.addEventListener("online", () => { state.online = true; onRuntimeAvailabilityChange(); });
window.addEventListener("offline", () => { state.online = false; onRuntimeAvailabilityChange(); });
function runtimeAvailable() { return state.visible && state.online; }
function onRuntimeAvailabilityChange() {
if (!$("shell") || $("shell").hidden) return;
if (runtimeAvailable()) {
// 恢复:不补播旧事件——所有通道先静默重建基线,再继续真实轮询
Poller.resume();
if (!state.reduced) Stage.resume();
} else {
// 隐藏/切页/断网:取消未播闪簇、清空队列、停止循环
Poller.pause();
Lamps.clearAll();
if (!state.reduced) Stage.pause();
}
updateCrumbStatus();
}
function updateCrumbStatus() {
const el = $("crumb");
if (!el) return;
if (!state.online) { el.textContent = "网络已断开 · 已暂停实时"; return; }
if (!state.visible) { el.textContent = "已切至后台 · 已暂停动效"; return; }
el.textContent = SCENES[state.scene] ? `8766 · ${SCENES[state.scene].label} · 持续运转` : "8766 · 四源汇流 · 持续运转";
}
/* ==========================================================================
A2 信号灯引擎:只有真实事件才能点亮 ACT。
- 单一来源的一簇事件最多折叠为 3 次短闪。
- 全站所有通道共享一个节流阀:任意 1 秒窗口内新起的闪烁簇不超过 3 个,
各来源互不同步(各自独立的小抖动),不做全局统一节拍。
========================================================================== */
const FLASH_ON = 90, FLASH_GAP = 160, MAX_CLUSTER = 3;
const GLOBAL_WINDOW = 1000, GLOBAL_CAP = 3;
const globalFlashStarts = [];
function reserveGlobalSlot(tNow) {
while (globalFlashStarts.length && tNow - globalFlashStarts[0] > GLOBAL_WINDOW) globalFlashStarts.shift();
if (globalFlashStarts.length < GLOBAL_CAP) { globalFlashStarts.push(tNow); return tNow; }
const wait = (globalFlashStarts[0] + GLOBAL_WINDOW - tNow) + 40 + Math.random() * 120;
const start = tNow + Math.max(30, wait);
globalFlashStarts.push(start);
globalFlashStarts.sort((a, b) => a - b);
return start;
}
class LampChannel {
constructor(id) { this.id = id; this.bursts = []; this.link = "unknown"; this.lastAt = 0; }
push(n, kind) {
const count = clamp(Math.round(n) || 1, 1, MAX_CLUSTER);
const start = reserveGlobalSlot(performance.now());
this.bursts.push({ start, n: count, kind: kind || "ok" });
this.lastAt = Date.now();
}
clear() { this.bursts.length = 0; }
level(tMs) {
for (let i = this.bursts.length - 1; i >= 0; i--) {
const b = this.bursts[i];
const dur = (b.n - 1) * FLASH_GAP + FLASH_ON;
const dt = tMs - b.start;
if (dt < 0) continue;
if (dt <= dur) { const ph = dt % FLASH_GAP; return ph < FLASH_ON ? 1 : 0.1; }
if (dt <= dur + 260) return 0.22 * (1 - (dt - dur) / 260);
}
return 0;
}
activeKind(tMs) {
for (let i = this.bursts.length - 1; i >= 0; i--) {
const b = this.bursts[i];
const dur = (b.n - 1) * FLASH_GAP + FLASH_ON + 260;
if (tMs - b.start <= dur) return b.kind;
}
return null;
}
prune(tMs) { this.bursts = this.bursts.filter((b) => tMs - b.start < (b.n - 1) * FLASH_GAP + FLASH_ON + 400); }
}
const Lamps = {
tushare: new LampChannel("tushare"),
eastmoney: new LampChannel("eastmoney"),
tencent: new LampChannel("tencent"),
ifind: new LampChannel("ifind"),
junction: new LampChannel("junction"),
tx: new LampChannel("tx"),
audit: new LampChannel("audit"),
clearAll() { Object.values(this).forEach((c) => { if (c instanceof LampChannel) c.clear(); }); globalFlashStarts.length = 0; },
};
const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"];
const FLOW_LABEL = {
tushare: "tushare · 官方盘后", eastmoney: "eastmoney · 盘中观察",
tencent: "tencent · 盘中观察", ifind: "ifind · 授权实时",
};
/* 事件脉冲:来源 ACT 起闪 → 沿流道 → 核心接点 → TX;只在真实事件时入队 */
const PACKETS = [];
function emitPacket(providerIdx, kindRollback) {
PACKETS.push({ src: providerIdx, t0: performance.now(), rb: !!kindRollback });
if (PACKETS.length > 40) PACKETS.splice(0, PACKETS.length - 40); // 硬上限,防止无限增长
}
function fireSource(provider, n, kind) {
const idx = FLOW_PROVIDERS.indexOf(provider);
if (idx < 0) return;
Lamps[provider].push(n, kind);
emitPacket(idx, kind === "rollback");
setTimeout(() => Lamps.junction.push(1, kind), 900);
setTimeout(() => Lamps.tx.push(1, kind), 1650);
}
/* ==========================================================================
数据轮询 + 真实事件识别(只认已经发生的事实,不臆造)
========================================================================== */
const Seen = {
callsMax: -1, jobRunsMax: -1, auditMax: -1,
batchState: new Map(), pubPublishedAt: new Map(),
firstOverview: true, firstJobs: true, firstAudit: true, firstBatches: true,
};
async function pollOverview() {
const data = await api("/admin/api/overview");
const wasFirst = Seen.firstOverview;
const calls = data.recent_calls || [];
let maxId = Seen.callsMax;
const fresh = [];
for (const c of calls) { if (c.id > Seen.callsMax) fresh.push(c); if (c.id > maxId) maxId = c.id; }
Seen.callsMax = maxId;
Seen.firstOverview = false;
if (!wasFirst && fresh.length) fireSource("tushare", fresh.length, fresh.some((c) => !c.ok) ? "error" : "ok");
state.data.overview = data;
Views.refresh();
}
async function pollSources() {
const data = await api("/admin/api/sources");
for (const item of data.items) {
if (!FLOW_PROVIDERS.includes(item.provider)) continue;
const health = item.health || {};
const link = health.state === "ok" || health.state === "empty" ? "ok" : (health.state === "unconfigured" ? "unconfigured" : "error");
Lamps[item.provider].link = link;
// 每一次 /admin/api/sources 请求都会对该源做一次真实探测(现网行为),
// 探测本身完成即是一次真实事件;未配置的源不闪(没有发生过真实调用)。
if (item.provider !== "tushare" && link !== "unconfigured") {
Lamps[item.provider].push(1, link === "ok" ? "ok" : "error");
emitPacket(FLOW_PROVIDERS.indexOf(item.provider), false);
setTimeout(() => Lamps.junction.push(1, link), 700);
}
}
state.data.sources = data;
Views.refresh();
}
async function pollJobs() {
const data = await api("/admin/api/jobs");
const wasFirst = Seen.firstJobs;
let maxId = Seen.jobRunsMax;
const fresh = [];
for (const r of data.runs || []) { if (r.id > Seen.jobRunsMax) fresh.push(r); if (r.id > maxId) maxId = r.id; }
Seen.jobRunsMax = maxId;
Seen.firstJobs = false;
if (!wasFirst && fresh.length) {
const anyFail = fresh.some((r) => r.state === "failed");
Lamps.junction.push(fresh.length, anyFail ? "error" : "ok");
}
state.data.jobs = data;
Stage.freshJobRuns = fresh;
Views.refresh();
}
async function pollBatches() {
const date = state.releaseDate || "";
const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`);
const wasFirst = Seen.firstBatches;
let changed = false;
for (const b of data.batches || []) {
const prev = Seen.batchState.get(b.batch_id);
if (prev !== b.state) { changed = true; Seen.batchState.set(b.batch_id, b.state); }
}
let published = false;
for (const p of data.publications || []) {
const key = `${p.dataset}:${p.trade_date}`;
const prev = Seen.pubPublishedAt.get(key);
if (prev !== p.published_at) { published = true; Seen.pubPublishedAt.set(key, p.published_at); }
}
Seen.firstBatches = false;
if (!wasFirst && published) Lamps.tx.push(1, "pub");
else if (!wasFirst && changed) Lamps.junction.push(1, "ok");
state.data.batches = data;
Views.refresh();
}
async function pollDatasets() {
const date = "";
const data = await api(`/admin/api/datasets?date=${encodeURIComponent(date)}`);
state.data.datasets = data;
Views.refresh();
}
async function pollAudit() {
const data = await api("/admin/api/audit");
const wasFirst = Seen.firstAudit;
let maxId = Seen.auditMax;
const fresh = [];
for (const a of data.items || []) { if (a.id > Seen.auditMax) fresh.push(a); if (a.id > maxId) maxId = a.id; }
Seen.auditMax = maxId;
Seen.firstAudit = false;
if (!wasFirst && fresh.length) {
const rollback = fresh.some((a) => String(a.action).includes("rollback"));
Lamps.audit.push(fresh.length, rollback ? "rollback" : "ok");
}
state.data.audit = data;
Views.refresh();
}
const Poller = (() => {
const specs = [
{ key: "overview", fn: pollOverview, every: 20000 },
{ key: "sources", fn: pollSources, every: 45000 },
{ key: "jobs", fn: pollJobs, every: 25000 },
{ key: "batches", fn: pollBatches, every: 25000 },
{ key: "datasets", fn: pollDatasets, every: 60000 },
{ key: "audit", fn: pollAudit, every: 20000 },
];
const timers = new Map();
function tickOne(spec) {
spec.fn().catch((err) => console.error(`[hub] poll ${spec.key} failed`, err));
}
function schedule(spec) {
clearTimer(spec.key);
const t = setInterval(() => { if (runtimeAvailable()) tickOne(spec); }, spec.every);
timers.set(spec.key, t);
}
function clearTimer(key) { if (timers.has(key)) { clearInterval(timers.get(key)); timers.delete(key); } }
function startAll() {
specs.forEach((spec, i) => { setTimeout(() => { tickOne(spec); schedule(spec); }, i * 160); });
}
function stopAll() { specs.forEach((s) => clearTimer(s.key)); }
function pause() { stopAll(); }
function resume() {
// 恢复时先静默重建基线(不补播旧事件),随后正常轮询
specs.forEach((spec) => { tickOne(spec); schedule(spec); });
}
return { startAll, stopAll, pause, resume };
})();
/* ==========================================================================
六幕场景内容:cabin(控制舱摘要)与 detail(功能抽屉)共用同一份真实数据渲染,
动效版与「减少动态效果」静态版都调用这里——保证信息与功能完全一致。
========================================================================== */
const SCENES = [
{ key: "overview", label: "总览" },
{ key: "sources", label: "数据源" },
{ key: "jobs", label: "调度任务" },
{ key: "release", label: "盘后发布" },
{ key: "datasets", label: "数据集" },
{ key: "audit", label: "审计" },
];
const EOD_LABELS = {
pending_first_attempt: "等待首次尝试", waiting_upstream: "等待上游", done: "已成功",
cutoff_failed: "已截止失败", closed_day: "休市",
};
const REV_LABELS = {
waiting_review: "等待复核", review_failed: "复核失败", aligned: "已追平",
cutoff: "已截止", pending_publish: "待发布", closed_day: "休市",
};
const PHASE_LABELS = { pre: "盘前", intraday: "盘中", lunch: "午间", eod: "盘后", closed: "休市" };
function todayYmd() {
const d = new Date(); const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
if (!state.releaseDate) state.releaseDate = todayYmd();
const Views = {
listeners: [],
onChange(fn) { this.listeners.push(fn); },
refresh() { this.listeners.forEach((fn) => { try { fn(); } catch (e) { console.error(e); } }); },
};
/* ---- 0 总览 ---- */
function cabinOverview() {
const d = state.data.overview;
if (!d) return { eyebrow: "SCENE 01 / 总览", title: "四源汇流 · 持续运转", sub: "正在加载真实状态…", rows: [], actions: [] };
const eod = d.eod_status || {}, rev = d.revision_status || {};
return {
eyebrow: "SCENE 01 / 总览",
title: "四源汇流 · 持续运转",
sub: `${d.trade_date} · ${PHASE_LABELS[d.session_phase] || d.session_phase}。灯闪一次,就是一次真实调用或探测。`,
rows: [
["今日发布", `${d.publications.length}`, "ok"],
["盘后补跑", EOD_LABELS[eod.state] || eod.state || "-", eod.state === "cutoff_failed" ? "fail" : "ok"],
["估值复核", REV_LABELS[rev.state] || rev.state || "-", rev.state === "review_failed" ? "fail" : "ok"],
["异常批次", `${d.anomalies.length}`, d.anomalies.length ? "fail" : "ok"],
],
actions: [{ label: "进入数据源 →", cls: "pri", action: () => Nav.go(1) }, { label: "查看最近调用", cls: "", action: () => Detail.open("overview") }],
};
}
function detailOverview() {
const d = state.data.overview;
if (!d) return `<h3>总览</h3><div class="dsub">正在加载…</div>`;
const eod = d.eod_status || {}, rev = d.revision_status || {};
const eodExtra = [];
if (eod.state === "waiting_upstream") {
eodExtra.push(`已试 ${eod.attempts} 次`);
if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(timeShort(eod.next_retry_at))}`);
if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`);
}
if (eod.state === "cutoff_failed" && eod.missing_datasets) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`);
const revExtra = [];
if (rev.detail) revExtra.push(esc(String(rev.detail)));
if (rev.window) revExtra.push(esc(String(rev.window)));
return `
<h3>总览 · ${esc(d.trade_date)}</h3>
<div class="dsub">${esc(PHASE_LABELS[d.session_phase] || d.session_phase)} · 源共 ${d.source_count}</div>
<div class="cards">
<div class="card"><div class="muted">交易日</div><strong>${esc(d.trade_date)}</strong></div>
<div class="card"><div class="muted">阶段</div><strong>${esc(PHASE_LABELS[d.session_phase] || d.session_phase)}</strong></div>
<div class="card"><div class="muted">今日发布</div><strong>${d.publications.length}</strong></div>
<div class="card"><div class="muted">盘后补跑</div><strong>${esc(EOD_LABELS[eod.state] || eod.state || "-")}</strong><div class="muted">${eodExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">估值复核</div><strong>${esc(REV_LABELS[rev.state] || rev.state || "-")}</strong><div class="muted">${revExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">异常批次</div><strong class="${d.anomalies.length ? "fail" : "ok"}">${d.anomalies.length}</strong></div>
</div>
<h3>最近调用(tushare</h3>
${table(["时间", "源", "端点", "结果", "耗时"], d.recent_calls.map((row) => [
esc(timeShort(row.created_at)), esc(row.provider), esc(row.endpoint),
row.ok ? '<span class="ok">成功</span>' : `<span class="fail">${esc(row.error)}</span>`,
`${row.latency_ms ?? "-"} ms`,
]))}
`;
}
/* ---- 1 数据源 ---- */
function cabinSources() {
const d = state.data.sources;
if (!d) return { eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的", sub: "正在加载真实状态…", rows: [], actions: [] };
const live = d.items.filter((it) => FLOW_PROVIDERS.includes(it.provider));
const reserved = d.items.filter((it) => !FLOW_PROVIDERS.includes(it.provider));
const rows = live.map((it) => {
const h = it.health || {};
const okish = h.state === "ok" || h.state === "empty";
return [FLOW_LABEL[it.provider] || it.provider, `${h.latency_ms ?? "-"} ms · ${esc(h.state || "-")}`, okish ? "ok" : "warn"];
});
rows.push([`预留源 ${reserved.length} 个`, "未接入 · 无真实调用", "warn"]);
return {
eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的",
sub: "点击任一端口双灯,详情从它的空间位置展开。灯闪代表真实探测或调用刚发生。",
rows,
actions: [{ label: "查看全部来源", cls: "pri", action: () => Detail.open("sources") }],
};
}
function detailSources() {
const d = state.data.sources;
if (!d) return `<h3>数据源</h3><div class="dsub">正在加载…</div>`;
const html = `<h3>数据源</h3><div class="dsub">LINK 常亮表示健康;ACT 只在真实探测/调用发生时短闪</div>` +
table(["源", "角色", "状态", "凭据", "操作"], d.items.map((item) => {
const cred = item.credential || {};
const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置";
return [
esc(item.provider), esc(item.role),
esc((item.health && (item.health.state || item.health.status)) || "-"),
credText,
`<button class="btn ghost" data-probe="${esc(item.provider)}">探测一次</button>`,
];
}));
return html;
}
function bindSourcesDetail(root) {
root.querySelectorAll("[data-probe]").forEach((btn) => {
btn.addEventListener("click", async () => {
const provider = btn.dataset.probe;
btn.disabled = true;
try {
const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" });
if (FLOW_PROVIDERS.includes(provider)) {
const ok = result.state === "ok" || result.state === "empty";
fireSource(provider, 1, ok ? "ok" : "error");
}
alert(JSON.stringify(result));
await pollSources();
Detail.reopen();
} finally { btn.disabled = false; }
});
});
}
/* ---- 2 调度任务 ---- */
function cabinJobs() {
const d = state.data.jobs;
if (!d) return { eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力", sub: "正在加载真实状态…", rows: [], actions: [] };
const runs = d.runs || [];
const latest = runs[0];
const failedRecent = runs.slice(0, 20).filter((r) => r.state === "failed").length;
return {
eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力",
sub: "环上的每个节点对应一个真实定时任务;节点接力亮起来自最近一次真实运行。",
rows: [
["最近运行", latest ? `${esc(latest.job_id)} · ${esc(latest.state)}` : "暂无", latest && latest.state === "failed" ? "fail" : "ok"],
["最近 20 次失败", `${failedRecent}`, failedRecent ? "fail" : "ok"],
["任务总数", `${d.jobs.length}`, "ok"],
],
actions: [{ label: "查看调度日志", cls: "pri", action: () => Detail.open("jobs") }],
};
}
function detailJobs() {
const d = state.data.jobs;
if (!d) return `<h3>调度任务</h3><div class="dsub">正在加载…</div>`;
return `
<h3>调度任务</h3>
${table(["任务", "时刻", "操作"], d.jobs.map((job) => [
`${esc(job.id)} · ${esc(job.title)}`, esc(job.at),
`<button class="btn ghost" data-run="${esc(job.id)}">手动触发</button>`,
]))}
<h3>最近运行</h3>
${table(["ID", "任务", "状态", "开始", "结束", "错误"], d.runs.map((row) => [
row.id, esc(row.job_id),
`<span class="${row.state === "failed" ? "fail" : row.state === "running" ? "warn" : "ok"}">${esc(row.state)}</span>`,
esc(timeShort(row.started_at)), esc(timeShort(row.finished_at)), esc(row.error || ""),
]))}
`;
}
function bindJobsDetail(root) {
root.querySelectorAll("[data-run]").forEach((btn) => {
btn.addEventListener("click", async () => {
const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || "";
btn.disabled = true;
try {
await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) });
await pollJobs();
Detail.reopen();
} finally { btn.disabled = false; }
});
});
}
/* ---- 3 盘后发布 ---- */
function cabinRelease() {
const d = state.data.batches;
if (!d) return { eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布", sub: "正在加载真实状态…", rows: [], actions: [] };
const pubs = d.publications || [];
const rollbacks = pubs.filter((p) => p.prev_batch).length;
const failedBatches = (d.batches || []).filter((b) => b.state === "failed").length;
return {
eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布",
sub: `${d.trade_date} · RX 批次穿过三层,TX 给出回执;校验失败走红色回滚分叉。`,
rows: [
["活跃发布", `${pubs.length} 个数据集`, "ok"],
["可回滚", `${rollbacks}`, rollbacks ? "warn" : "ok"],
["批次异常", `${failedBatches}`, failedBatches ? "fail" : "ok"],
],
actions: [{ label: "查看批次与发布", cls: "pri", action: () => Detail.open("release") }],
};
}
function detailRelease() {
const d = state.data.batches;
if (!d) return `<h3>盘后发布</h3><div class="dsub">正在加载…</div>`;
return `
<h3>盘后发布 ${esc(d.trade_date)}</h3>
<div class="toolbar">
<label>日期 <input id="rel-date" value="${esc(d.trade_date)}" /></label>
<button type="button" class="btn ghost" id="rel-load">查看</button>
<button type="button" class="btn warn" id="rel-backfill">补数</button>
</div>
<h3>当前映射</h3>
${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], d.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
row.prev_batch ? `<button class="btn danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
]))}
<h3>批次</h3>
${table(["batch_id", "数据集", "状态", "行数", "错误"], d.batches.map((row) => [
esc(row.batch_id), esc(row.dataset),
`<span class="${row.state === "failed" ? "fail" : "ok"}">${esc(row.state)}</span>`,
row.rows_out ?? "", esc(row.error || ""),
]))}
`;
}
function bindReleaseDetail(root) {
root.querySelector("#rel-load").addEventListener("click", async () => {
state.releaseDate = root.querySelector("#rel-date").value.trim();
await pollBatches();
Detail.reopen();
});
root.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill"));
root.querySelectorAll("[data-rollback]").forEach((btn) => {
btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback));
});
}
async function dangerous(kind, dataset) {
const date = state.releaseDate || "";
const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference", "daily");
if (!ds) return;
const password = prompt("二次确认:输入管理密码");
if (!password) return;
const confirmWord = `${ds}:${date}`;
const typed = prompt(`请输入确认词:${confirmWord}`);
if (!typed) return;
const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill";
try {
await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) });
if (kind === "rollback") Lamps.tx.push(1, "rollback");
await pollBatches();
await pollAudit();
Detail.reopen();
} catch (err) {
alert(err.message);
}
}
/* ---- 4 数据集 ---- */
function cabinDatasets() {
const d = state.data.datasets;
if (!d) return { eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间", sub: "正在加载真实状态…", rows: [], actions: [] };
const pubs = d.publications || [];
return {
eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间",
sub: `${d.trade_date} · 核心面片翻开成数据集,稳定显示日期与状态。`,
rows: pubs.slice(0, 4).map((p) => [esc(p.dataset), `${esc(p.state)} · ${esc(timeShort(p.published_at))}`, p.state === "published" ? "ok" : "warn"]),
actions: [{ label: "查看差异报告", cls: "pri", action: () => Detail.open("datasets") }],
};
}
function detailDatasets() {
const d = state.data.datasets;
if (!d) return `<h3>数据集</h3><div class="dsub">正在加载…</div>`;
return `
<h3>数据集 / 质量 ${esc(d.trade_date)}</h3>
${table(["数据集", "批次", "状态", "发布时间"], d.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at),
]))}
<h3>源间差异</h3>
${table(["指标", "左", "右", "偏差", "样本"], d.diff_reports.map((row) => [
esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "",
]))}
`;
}
/* ---- 5 审计 ---- */
function cabinAudit() {
const d = state.data.audit;
if (!d) return { eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕", sub: "正在加载真实状态…", rows: [], actions: [] };
const items = d.items || [];
const latest = items[0];
return {
eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕",
sub: "输出流在核心后方留下轨迹,发布、操作、回滚沿轨迹展开。",
rows: latest ? [[esc(latest.actor), `${esc(latest.action)} · ${esc(timeShort(latest.created_at))}`, String(latest.action).includes("rollback") ? "warn" : "ok"]] : [],
actions: [{ label: `查看审计(共 ${items.length} 条)`, cls: "pri", action: () => Detail.open("audit") }],
};
}
function detailAudit() {
const d = state.data.audit;
if (!d) return `<h3>审计</h3><div class="dsub">正在加载…</div>`;
return `<h3>审计</h3><div class="dsub">共 ${d.items.length} 条 · 最新在前</div>` +
table(["时间", "操作者", "动作", "对象", "详情"], d.items.map((row) => [
esc(timeShort(row.created_at)), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail),
]));
}
const SCENE_CABIN = [cabinOverview, cabinSources, cabinJobs, cabinRelease, cabinDatasets, cabinAudit];
const SCENE_DETAIL = [detailOverview, detailSources, detailJobs, detailRelease, detailDatasets, detailAudit];
const SCENE_BIND = [null, bindSourcesDetail, bindJobsDetail, bindReleaseDetail, null, null];
const Detail = {
openKey: null,
open(key) {
const idx = SCENES.findIndex((s) => s.key === key);
if (idx < 0) return;
this.openKey = key;
if (state.reduced) return; // 静态版直接内嵌显示,无需抽屉
Stage.renderDetail(idx);
},
reopen() { if (this.openKey && !state.reduced) this.open(this.openKey); },
close() { this.openKey = null; if (!state.reduced) Stage.closeDetail(); },
};
const Nav = {
go(idx) {
if (state.reduced) { StaticShell.scrollTo(idx); return; }
Stage.scrollToScene(idx);
},
};
/* ==========================================================================
canvas 3D 引擎(动效版)——移植自经白栖知确认的第七版原稿,
静态数据换成真实拉取结果,随机模拟事件全部替换为真实事件驱动。
========================================================================== */
const Stage = (() => {
let canvas, ctx, W = 0, H = 0, DPR = 1;
let running = false, rafId = null;
let PAL, C;
const CAM = { yaw: 0, pitch: .12, dist: 5.4, cx: 0, cy: 0, fov: 1.9 };
const T0 = performance.now();
const now = () => performance.now();
function initPalette() {
PAL = {
night: { bgA: "#05080F", bgB: "#0A1322", cyan: "#2FD8CE", blue: "#4A86FF", amber: "#FFB84D", red: "#FF6B6B",
ink: "#E8EEFC", dim: "#9AA6C4", face: "rgba(58,96,180,", edge: "rgba(140,190,255,", slab: "rgba(47,216,206,", ring: "rgba(120,170,255,",
chan: ["rgba(150,205,255,", "rgba(120,180,255,", "rgba(185,175,255,", "rgba(255,214,160,"] },
day: { bgA: "#E8ECF5", bgB: "#F7F9FF", cyan: "#0A9C93", blue: "#2F63D6", amber: "#B97A0E", red: "#D64F4F",
ink: "#1B2547", dim: "#5B6785", face: "rgba(120,160,235,", edge: "rgba(47,99,214,", slab: "rgba(10,156,147,", ring: "rgba(47,99,214,",
chan: ["rgba(47,99,214,", "rgba(10,124,146,", "rgba(109,93,214,", "rgba(190,120,10,"] },
};
C = PAL[document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"];
}
const V = (x, y, z) => ({ x, y, z });
function rotY(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x * c + p.z * s, p.y, -p.x * s + p.z * c); }
function rotX(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x, p.y * c - p.z * s, p.y * s + p.z * c); }
function bez(p0, p1, p2, p3, t) {
const u = 1 - t;
return V(
u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
u * u * u * p0.z + 3 * u * u * t * p1.z + 3 * u * t * t * p2.z + t * t * t * p3.z,
);
}
function project(p) {
let q = rotY(V(p.x - CAM.cx, p.y - CAM.cy, p.z), CAM.yaw);
q = rotX(q, -CAM.pitch);
const s = CAM.fov / Math.max(.3, CAM.dist - q.z);
return { x: W / 2 + q.x * s * W * .5, y: H / 2 - q.y * s * W * .5, z: q.z, s };
}
function roundRect(x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}
const setAdd = () => { ctx.globalCompositeOperation = (C === PAL.night ? "lighter" : "source-over"); };
function polyGlow(pts, color, alpha, width, dashPhase) {
ctx.save(); setAdd();
if (C !== PAL.night) alpha = Math.min(1, alpha * 1.5);
for (let i = 0; i < pts.length - 1; i++) {
const a = project(pts[i]), b = project(pts[i + 1]);
const depth = Math.max(.15, Math.min(1, 1.35 - (a.z + 3) / 6));
let al = alpha * depth;
if (dashPhase !== null) al *= .35 + .65 * Math.max(0, Math.sin((i / pts.length) * 14 - dashPhase));
ctx.strokeStyle = color + al.toFixed(3) + ")";
ctx.lineWidth = width * (a.s * 2.2); ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
ctx.restore();
}
function dot3(p, r, color, alpha, glow) {
const q = project(p);
const depth = Math.max(.2, Math.min(1, 1.35 - (q.z + 3) / 6));
const rr = r * q.s * 2.4;
ctx.save(); setAdd();
if (C !== PAL.night) alpha = Math.min(1, alpha * 1.35);
if (glow) {
const g = ctx.createRadialGradient(q.x, q.y, 0, q.x, q.y, rr * 3.2);
g.addColorStop(0, color + (.5 * alpha * depth) + ")"); g.addColorStop(1, color + "0)");
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(q.x, q.y, rr * 3.2, 0, 7); ctx.fill();
}
ctx.fillStyle = color + (alpha * depth) + ")";
ctx.beginPath(); ctx.arc(q.x, q.y, rr, 0, 7); ctx.fill();
ctx.restore();
return q;
}
/* A2 双灯:LINK 反映真实连通状态(常亮)+ ACT 事件驱动短闪 */
function lamp3(p, level, scale, kind, linkState) {
const q = project(p);
const u = Math.max(.7, q.s * 2.2) * (scale || 1);
const x = q.x, y = q.y, w = 13 * u, h = 5 * u, gap = 4 * u;
ctx.save();
ctx.globalAlpha = .9;
ctx.fillStyle = C === PAL.night ? "rgba(6,12,24,.78)" : "rgba(255,255,255,.92)";
ctx.strokeStyle = C.edge + ".5)"; ctx.lineWidth = 1;
roundRect(x - w / 2 - 4 * u, y - h - gap / 2 - 4 * u, w + 8 * u, h * 2 + gap + 8 * u, 5 * u); ctx.fill(); ctx.stroke();
const linkColor = linkState === "ok" ? C.cyan : linkState === "error" ? C.red : C.dim;
const linkAlpha = linkState === "ok" ? .6 : linkState === "error" ? .55 : .25;
ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor; ctx.shadowBlur = linkState === "ok" || linkState === "error" ? 6 * u : 0;
roundRect(x - w / 2, y - h - gap / 2, w, h, h / 2); ctx.fill();
ctx.shadowBlur = 0;
const actColor = kind === "error" || kind === "rollback" ? C.red : C.amber;
if (level > 0) {
ctx.globalAlpha = .3 + .7 * level; ctx.fillStyle = actColor; ctx.shadowColor = actColor; ctx.shadowBlur = 18 * u * level;
} else { ctx.globalAlpha = .18; ctx.fillStyle = C.amber; }
roundRect(x - w / 2, y + gap / 2, w, h, h / 2); ctx.fill();
ctx.restore();
return q;
}
/* ---------- 六幕相机关键帧(纯几何/镜头语言,已由白栖知确认) ---------- */
const CH = [
{ yaw: .00, pitch: .13, dist: 4.7, cx: 0, cy: 0, scale: 1.18, explode: .14, ring: 1.0, spread: 1.0, unfold: 0, trail: 0 },
{ yaw: .62, pitch: .17, dist: 3.7, cx: -.55, cy: .12, scale: .98, explode: .14, ring: .8, spread: 2.0, unfold: 0, trail: 0 },
{ yaw: 1.18, pitch: .30, dist: 4.2, cx: 0, cy: -.1, scale: .72, explode: .1, ring: .95, spread: .9, unfold: 0, trail: 0 },
{ yaw: 1.72, pitch: .06, dist: 4.0, cx: .1, cy: .24, scale: .9, explode: .7, ring: .6, spread: .75, unfold: 0, trail: 0 },
{ yaw: 2.38, pitch: .15, dist: 4.3, cx: 0, cy: 0, scale: 1.0, explode: .22, ring: 1.0, spread: .9, unfold: 1, trail: 0 },
{ yaw: 2.92, pitch: .10, dist: 4.6, cx: .4, cy: 0, scale: .95, explode: .16, ring: 1.0, spread: 1.0, unfold: 0, trail: 1 },
];
function smooth(t) { return t * t * (3 - 2 * t); }
function lerpK(a, b, f) { const o = {}; for (const k in a) o[k] = a[k] + (b[k] - a[k]) * f; return o; }
function chapterAt(p) {
const i = clamp(Math.floor(p), 0, 4), f = smooth(clamp(p - i, 0, 1));
return { ...lerpK(CH[i], CH[i + 1], f), idx: clamp(Math.round(p), 0, 5) };
}
/* ---------- 几何:核心 / 端口 / 流道 / 环 ---------- */
const PORTS = [V(-1.02, .52, .18), V(-1.05, -.42, .38), V(-.5, .98, -.4), V(-.48, -.92, -.42)];
const SRC_FAR = [V(-3.4, 1.9, -1.6), V(-3.6, -1.7, -.8), V(-2.3, 2.6, -2.4), V(-2.2, -2.7, -2.0)];
const TX_PORT = V(1.02, .18, .05), RX_PORT = V(.98, -.4, .1);
const TX_FAR = V(5.6, 1.4, -1.6), AUD_FAR = V(4.6, -2.4, .6);
function chanPts(from, to, spread, lift) {
const a = V(from.x * spread, from.y * spread, from.z);
const c1 = V(a.x * .55, a.y * .8 + lift, a.z * .7), c2 = V(to.x * 2.0, to.y * 1.5, to.z * 1.6);
const pts = []; for (let i = 0; i <= 42; i++) pts.push(bez(a, c1, c2, to, i / 42));
return pts;
}
function getChannels(spread) {
const chans = FLOW_PROVIDERS.map((_, i) => chanPts(SRC_FAR[i], PORTS[i], spread, i % 2 ? -.5 : .5));
chans.tx = chanPts(TX_FAR, TX_PORT, 1, .2).reverse();
chans.aud = chanPts(AUD_FAR, RX_PORT, 1, -.2).reverse();
return chans;
}
const CUBE_F = [
{ n: "来源", key: "sources", idx: [0, 1, 3, 2], nor: V(-1, 0, 0) },
{ n: "发布", key: "release", idx: [4, 6, 7, 5], nor: V(1, 0, 0) },
{ n: "调度", key: "jobs", idx: [2, 3, 7, 6], nor: V(0, 1, 0) },
{ n: "暂存", key: "release2", idx: [0, 4, 5, 1], nor: V(0, -1, 0) },
{ n: "数据集", key: "datasets", idx: [1, 5, 7, 3], nor: V(0, 0, 1) },
{ n: "审计", key: "audit", idx: [0, 2, 6, 4], nor: V(0, 0, -1) },
];
const CUBE_V = []; for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1]) CUBE_V.push(V(x * .5, y * .5, z * .5));
function facePoint(f, u, v, sc, explode) {
const nor = f.nor, ex = explode * .5;
let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0);
let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x);
let base = V(nor.x * (.5 + ex), nor.y * (.5 + ex), nor.z * (.5 + ex));
return V(
(base.x + t1.x * u * .5 + t2.x * v * .5) * sc,
(base.y + t1.y * u * .5 + t2.y * v * .5) * sc,
(base.z + t1.z * u * .5 + t2.z * v * .5) * sc,
);
}
function faceSummary(f) {
const s = state.data;
if (f.key === "sources") {
const items = (s.sources && s.sources.items || []).filter((it) => FLOW_PROVIDERS.includes(it.provider));
const ok = items.filter((it) => it.health && (it.health.state === "ok" || it.health.state === "empty")).length;
return `${ok}/${items.length || 4} 已连接`;
}
if (f.key === "release" || f.key === "release2") {
const pubs = (s.batches && s.batches.publications) || [];
if (f.key === "release2") { const staged = ((s.batches && s.batches.batches) || []).filter((b) => b.state === "staged").length; return `队列 ${staged}`; }
const latest = pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop();
return latest ? String(latest.active_batch) : "暂无发布";
}
if (f.key === "jobs") { const runs = (s.jobs && s.jobs.runs) || []; const today = todayYmd(); const n = runs.filter((r) => String(r.started_at || "").replace(/-/g, "").startsWith(today.slice(0, 8))).length; return `今日运行 ${n}`; }
if (f.key === "datasets") { const pubs = (s.datasets && s.datasets.publications) || []; return `${pubs.length} 套`; }
if (f.key === "audit") { const items = (s.audit && s.audit.items) || []; return `留痕 ${items.length}`; }
return "";
}
function drawCore(sc, explode, unfold, vt) {
const faces = CUBE_F.map((f, fi) => {
const corners = f.idx.map((i) => {
const vtx = CUBE_V[i]; const nor = f.nor;
let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0);
let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x);
const u = 2 * (vtx.x * t1.x + vtx.y * t1.y + vtx.z * t1.z), v = 2 * (vtx.x * t2.x + vtx.y * t2.y + vtx.z * t2.z);
return facePoint(f, u, v, sc, explode);
});
const pr = corners.map(project);
const zc = pr.reduce((a, p) => a + p.z, 0) / 4;
const c3 = corners.reduce((a, p) => V(a.x + p.x / 4, a.y + p.y / 4, a.z + p.z / 4), V(0, 0, 0));
const facing = rotY(V(f.nor.x, f.nor.y, f.nor.z), CAM.yaw).z > .12;
return { f, fi, pr, zc, c3, facing };
}).sort((a, b) => b.zc - a.zc);
// 内部层片:校验 / 暂存 / 发布
const slabNames = ["校验", "暂存", "发布"];
for (let s = 0; s < 3; s++) {
const y = (s - 1) * (.3 + explode * .42) * sc, hw = .36 * sc, hh = .07 * sc;
const sv = [V(-hw, y - hh, -hw), V(hw, y - hh, -hw), V(hw, y - hh, hw), V(-hw, y - hh, hw),
V(-hw, y + hh, -hw), V(hw, y + hh, -hw), V(hw, y + hh, hw), V(-hw, y + hh, hw)];
const edges = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
ctx.save();
const fillA = .10 + explode * .10;
const top = [4, 5, 6, 7].map((i) => project(sv[i]));
ctx.fillStyle = C.slab + fillA + ")";
ctx.beginPath(); top.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill();
ctx.strokeStyle = C.slab + ".5)"; ctx.lineWidth = 1;
edges.forEach((e) => { const a = project(sv[e[0]]), b = project(sv[e[1]]); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); });
if (explode > .45) {
const c = project(V(hw * 1.15, y, 0));
ctx.fillStyle = C.ink; ctx.globalAlpha = .9; ctx.font = `600 ${Math.max(10, 11 * c.s * 2)}px "Noto Sans SC"`;
ctx.fillText(slabNames[s], c.x + 8, c.y + 4); ctx.globalAlpha = 1;
}
ctx.restore();
}
faces.forEach((fc) => {
const { pr, f, facing } = fc;
const depth = Math.max(.15, Math.min(1, 1.2 - (fc.zc + 2.5) / 5));
ctx.save();
const g = ctx.createLinearGradient(pr[0].x, pr[0].y, pr[2].x, pr[2].y);
g.addColorStop(0, C.face + ((facing ? .20 : .06) * depth + .03) + ")");
g.addColorStop(.55, C.face + ((facing ? .07 : .02) * depth + .015) + ")");
g.addColorStop(1, C.face + ((facing ? .14 : .04) * depth + .02) + ")");
ctx.fillStyle = g;
ctx.beginPath(); pr.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill();
if (facing) { ctx.shadowColor = C.edge + ".9)"; ctx.shadowBlur = 9; }
ctx.strokeStyle = C.edge + ((facing ? .8 : .3) * depth + .12) + ")"; ctx.lineWidth = facing ? 1.4 : 1; ctx.stroke();
ctx.shadowBlur = 0;
if (facing && unfold < .5) {
const c = project(fc.c3); const fs = Math.max(11, 15 * c.s * 2);
ctx.globalAlpha = .55 + .45 * depth; ctx.fillStyle = C.ink; ctx.font = `600 ${fs}px "Noto Sans SC"`; ctx.textAlign = "center";
ctx.fillText(f.n, c.x, c.y - fs * .3);
ctx.globalAlpha *= .7; ctx.font = `${fs * .74}px "DejaVu Sans Mono"`; ctx.fillStyle = C.cyan;
ctx.fillText(faceSummary(f), c.x, c.y + fs * .75);
ctx.textAlign = "left"; ctx.globalAlpha = 1;
}
ctx.restore();
});
Stage.faceCenters = faces.filter((f) => f.facing).map((fc) => ({ q: project(fc.c3), fi: fc.fi }));
ctx.save(); setAdd();
const b0 = project(V(0, -.8 * sc, 0)), b1 = project(V(0, .8 * sc, 0));
const bg2 = ctx.createLinearGradient(b0.x, b0.y, b1.x, b1.y);
const beamA = C === PAL.night ? .5 : .3;
bg2.addColorStop(0, C.slab + "0)"); bg2.addColorStop(.5, C.slab + beamA + ")"); bg2.addColorStop(1, C.slab + "0)");
ctx.strokeStyle = bg2; ctx.lineCap = "round"; ctx.lineWidth = Math.max(3, 9 * b0.s * 2);
ctx.beginPath(); ctx.moveTo(b0.x, b0.y); ctx.lineTo(b1.x, b1.y); ctx.stroke();
dot3(V(0, 0, 0), 4, C === PAL.night ? "rgba(190,240,255," : C.slab, .8, true);
ctx.restore();
}
function drawRing(radius, tilt, vt) {
const pts = [];
for (let i = 0; i <= 72; i++) { const a = i / 72 * Math.PI * 2; pts.push(rotX(V(Math.cos(a) * radius, 0, Math.sin(a) * radius), tilt)); }
polyGlow(pts, C.ring, .5, 1.1, vt * 1.2);
}
function drawTasksRing(radius, tilt) {
const jobsData = state.data.jobs; if (!jobsData) return;
const jobs = jobsData.jobs || [], runs = jobsData.runs || [];
const latestByJob = new Map();
for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r);
jobs.forEach((job, i) => {
const a0 = i / jobs.length * Math.PI * 2 + Math.PI * .1;
let p = V(Math.cos(a0) * radius, 0, Math.sin(a0) * radius); p = rotX(p, tilt);
const run = latestByJob.get(job.id);
const st = run ? run.state : "never";
const color = st === "failed" ? "rgba(255,107,107," : st === "ok" ? "rgba(47,216,206," : st === "running" ? "rgba(255,184,77," : "rgba(150,160,190,";
const q = dot3(p, st === "never" ? 3 : 4.6, color, st === "never" ? .4 : .9, st !== "never");
const isFresh = (Stage.freshJobRuns || []).some((r) => r.job_id === job.id);
if (isFresh) lamp3(p, Lamps.junction.level(now()), .78, st === "failed" ? "error" : "ok");
ctx.save(); ctx.globalAlpha = .55; ctx.fillStyle = st === "failed" ? C.red : C.dim;
ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.textAlign = "center";
ctx.fillText(job.title.slice(0, 8), q.x, q.y - 13 * q.s * 2); ctx.textAlign = "left"; ctx.restore();
});
}
const flowSeeds = FLOW_PROVIDERS.map(() => Array.from({ length: 24 }, (_, i) => ({ o: i / 24, w: .6 + (i % 5) * .12 })));
function drawFlows(chans, vt, alpha) {
FLOW_PROVIDERS.forEach((provider, i) => {
const pts = chans[i];
polyGlow(pts, C.chan[i], .5 * alpha, 2.2, vt * .0016 * (1.2 + i * .18));
flowSeeds[i].forEach((p) => {
const u = (p.o + vt * .00006 * p.w) % 1;
const pt = pts[Math.floor(u * (pts.length - 1))];
dot3(pt, 2.6 * p.w, C.chan[i], .95 * alpha, p.w > 1.05);
});
});
polyGlow(chans.tx, C.slab, .5 * alpha, 2.4, vt * .0022);
polyGlow(chans.aud, C.edge, .3 * alpha, 1.6, vt * .0016);
}
const TRAVEL_IN = 900, TRAVEL_OUT = 750;
function drawPackets(chans) {
const nowMs = performance.now();
for (let k = PACKETS.length - 1; k >= 0; k--) {
const pk = PACKETS[k]; const dt = nowMs - pk.t0;
if (dt > TRAVEL_IN + TRAVEL_OUT + 400) { PACKETS.splice(k, 1); continue; }
const col = pk.rb ? "rgba(255,107,107," : "rgba(255,214,150,";
if (dt < TRAVEL_IN) {
const u = dt / TRAVEL_IN, pts = chans[pk.src];
for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.4 - j * .42, col, .9 - j * .13, j === 0); }
} else {
const u = (dt - TRAVEL_IN) / TRAVEL_OUT, pts = pk.rb ? chans.aud : chans.tx;
for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.2 - j * .4, col, .85 - j * .12, j === 0); }
}
}
}
function drawAuditTrail(amp) {
const items = ((state.data.audit && state.data.audit.items) || []).slice(0, 5);
if (amp < .05 || !items.length) return;
ctx.save(); ctx.globalAlpha = amp;
items.forEach((a, i) => {
const t = i / Math.max(1, items.length - 1);
const ang = Math.PI * (.25 + t * .85), r = 2.6 + t * .9;
const p = V(Math.cos(ang) * r, .9 - t * 1.9, -1.6 - Math.sin(ang) * 1.2);
const q = project(p);
const col = String(a.action).includes("rollback") ? C.red : C.cyan;
ctx.globalAlpha = amp * (1 - t * .55);
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(q.x, q.y, 4 * q.s * 2, 0, 7); ctx.fill();
ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.fillStyle = C.ink;
ctx.fillText(`${a.action} · ${a.target || ""}`.slice(0, 22), q.x + 12, q.y + 3);
ctx.font = `${Math.max(8, 9 * q.s * 2)}px "DejaVu Sans Mono"`; ctx.fillStyle = C.dim;
ctx.fillText(timeShort(a.created_at), q.x + 12, q.y + 15);
});
ctx.restore();
}
function drawSourceLabels(alpha) {
if (alpha < .05) return;
ctx.save(); ctx.globalAlpha = alpha;
Stage.portScreen.forEach((q, i) => {
if (!q || q.hide) return;
const provider = FLOW_PROVIDERS[i];
const info = ((state.data.sources && state.data.sources.items) || []).find((it) => it.provider === provider) || {};
const health = info.health || {};
const OFF = [{ dx: 34, dy: -78 }, { dx: 34, dy: 34 }, { dx: -230, dy: -92 }, { dx: -230, dy: 48 }][i];
let x = q.x + OFF.dx, y = q.y + OFF.dy;
x = clamp(x, 320, W - 216); y = clamp(y, 14, H - 96);
ctx.fillStyle = C.face + ".14)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1;
roundRect(x, y, 196, 62, 9); ctx.fill(); ctx.stroke();
ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(FLOW_LABEL[provider] || provider, x + 11, y + 20);
ctx.fillStyle = C.dim; ctx.font = "10.5px \"DejaVu Sans Mono\"";
ctx.fillText(`延迟 ${health.latency_ms ?? "-"}ms · ${health.state || "-"}`, x + 11, y + 37);
ctx.fillStyle = C.cyan; ctx.fillText(info.role || "", x + 11, y + 53);
ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(q.x + (OFF.dx > 0 ? 10 : -10), q.y); ctx.lineTo(OFF.dx > 0 ? x : x + 196, y + 31); ctx.stroke();
});
ctx.restore();
}
function drawPublishLabels(alpha) {
if (alpha < .05) return;
ctx.save(); ctx.globalAlpha = alpha;
const rx = project(RX_PORT), tx = project(TX_PORT);
ctx.font = "600 11px \"Noto Sans SC\"";
ctx.fillStyle = C.cyan; ctx.fillText("RX 批次入口", rx.x - 70, rx.y + 34);
ctx.fillStyle = C.ink; ctx.fillText("TX 发布回执", tx.x + 16, tx.y - 10);
ctx.fillStyle = C.red; ctx.fillText("回滚分叉", tx.x + 60, tx.y + 66);
ctx.restore();
}
function drawDatasetCards(alpha, coreX, coreY) {
if (alpha < .05 || !Stage.faceCenters.length) return;
const datasets = (state.data.datasets && state.data.datasets.publications) || [];
ctx.save(); ctx.globalAlpha = alpha;
Stage.faceCenters.forEach((fc) => {
const ds = datasets[fc.fi]; if (!ds) return;
let dx = fc.q.x - coreX, dy = fc.q.y - coreY; const len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len;
let x = fc.q.x + dx * 110 - 75, y = fc.q.y + dy * 80 - 30;
x = clamp(x, 316, W - 170); y = clamp(y, 14, H - 84);
ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(fc.q.x, fc.q.y); ctx.lineTo(x + 75, y + 30); ctx.stroke();
ctx.fillStyle = C.face + ".16)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1;
roundRect(x, y, 150, 60, 9); ctx.fill(); ctx.stroke();
ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(ds.dataset, x + 11, y + 19);
ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\""; ctx.fillText(`${ds.active_batch} · ${ds.state}`, x + 11, y + 34);
});
ctx.restore();
}
const publicApi = {
faceCenters: [], portScreen: [], freshJobRuns: [],
init() {
canvas = $("scene"); ctx = canvas.getContext("2d");
initPalette();
addEventListener("resize", resize);
resize();
buildCabinShell();
canvas.addEventListener("click", onCanvasClick);
$("detailClose").addEventListener("click", () => Detail.close());
// 相机位置由 render() 每帧直接读取 scrollY 计算,天然免疫快速/反向滚动排队问题,
// 无需额外监听 scroll 事件。
},
onThemeChange(theme) { initPalette(); },
start() { if (!running) { running = true; scheduleNav(); loop(); } },
resume() { this.start(); },
pause() { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = null; },
stop() { this.pause(); },
scrollToScene(idx) {
const track = $("track");
const secH = track.offsetHeight / 6;
scrollTo({ top: track.offsetTop + idx * secH, behavior: "smooth" });
},
renderDetail(idx) {
const body = SCENE_DETAIL[idx]();
$("detailBody").innerHTML = body;
if (SCENE_BIND[idx]) SCENE_BIND[idx]($("detailBody"));
positionDetail(idx);
$("detail").classList.remove("closed");
},
closeDetail() { $("detail").classList.add("closed"); },
};
function scheduleNav() {
document.querySelectorAll("#rail .stop").forEach((el) => {
el.onclick = () => Nav.go(+el.dataset.i);
});
}
function buildCabinShell() {
$("rail").innerHTML = SCENES.map((s, i) => `<div class="stop" data-i="${i}"><i></i>${esc(s.label)}</div>`).join('<div class="sep"></div>');
scheduleNav();
}
function resize() {
if (!canvas) return;
DPR = Math.min(devicePixelRatio || 1, 2);
const r = canvas.parentElement.getBoundingClientRect();
W = r.width; H = r.height;
canvas.width = W * DPR; canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
function positionDetail(idx) {
const el = $("detail");
let anchor = null;
if (idx === 1 && Stage.portScreen.length) anchor = Stage.portScreen.find((q) => q && !q.hide);
if (anchor) {
el.style.left = clamp(anchor.x + 34, 20, W - 380) + "px";
el.style.top = clamp(anchor.y - 40, 16, H - 260) + "px";
} else {
el.style.left = Math.max(20, W / 2 - 180) + "px";
el.style.top = "20px";
}
}
function onCanvasClick(e) {
const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
// 优先命中当前可见的来源端口(仅在数据源幕出现),否则命中画面中心的机芯本体——
// 两种情况都应该打开“当前所在的那一幕”的详情,而不是硬编码成数据源。
let best = -1, bd = 34 * 34;
Stage.portScreen.forEach((q, i) => { if (!q || q.hide) return; const d = (q.x - x) ** 2 + (q.y - y) ** 2; if (d < bd) { bd = d; best = i; } });
const coreHit = Math.hypot(x - W / 2, y - H / 2) < Math.min(W, H) * 0.3;
if (best >= 0 || coreHit) Detail.open(SCENES[state.scene].key);
else Detail.close();
}
let cabinIdx = -1;
function renderCabin(idx) {
const c = SCENE_CABIN[idx]();
const el = $("cabin");
el.innerHTML = `<div class="eyebrow">${esc(c.eyebrow)}</div><h2>${esc(c.title)}</h2><div class="sub">${esc(c.sub)}</div>
<div class="rows">${c.rows.map((r) => `<div class="row"><span class="dotlamp${r[2] === "fail" ? " fail" : r[2] === "warn" ? " warn" : ""}"></span><span>${esc(r[0])}</span><span class="fill"></span><b class="mono">${esc(r[1])}</b></div>`).join("")}</div>
<div class="actions">${c.actions.map((a, i) => `<button class="btn ${esc(a.cls)}" data-act="${i}">${esc(a.label)}</button>`).join("")}</div>`;
el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; });
}
function syncUI(idx, p) {
if (idx !== cabinIdx) {
const el = $("cabin");
el.classList.add("hide");
setTimeout(() => { renderCabin(idx); el.classList.remove("hide"); }, 180);
cabinIdx = idx;
document.querySelectorAll("#rail .stop").forEach((el2, i) => el2.classList.toggle("on", i === idx));
$("hint").classList.toggle("off", p > .15);
$("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`;
$("phase").textContent = `${idx + 1} / ${SCENES.length}`;
state.scene = idx;
} else {
renderCabin(idx); // 刷新真实数据但不重触发进出动画
}
if (Detail.openKey) Detail.reopen();
}
Views.onChange(() => {
if (running && cabinIdx >= 0) renderCabin(cabinIdx);
if (Detail.openKey && !$("detail").classList.contains("closed")) Detail.reopen();
});
function render() {
if (!canvas) return;
const vt = performance.now() - T0;
const max = Math.max(1, document.body.scrollHeight - innerHeight);
const p = clamp((scrollY / max) * 5, 0, 5);
const K = chapterAt(p);
CAM.yaw = K.yaw + (vt / 1000) * .05; CAM.pitch = K.pitch; CAM.dist = K.dist; CAM.cx = K.cx; CAM.cy = K.cy;
const bg = ctx.createRadialGradient(W * .5, H * .46, 60, W * .5, H * .5, Math.max(W, H) * .75);
bg.addColorStop(0, C.bgB); bg.addColorStop(1, C.bgA);
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
ctx.save(); setAdd();
const fl = ctx.createRadialGradient(W * .5, H * .8, 10, W * .5, H * .8, W * .24);
fl.addColorStop(0, C.face + ".13)"); fl.addColorStop(1, C.face + "0)");
ctx.fillStyle = fl; ctx.beginPath(); ctx.ellipse(W * .5, H * .8, W * .24, H * .09, 0, 0, 7); ctx.fill();
ctx.restore();
const chans = getChannels(K.spread);
drawFlows(chans, vt, 1);
drawRing(1.9 * K.ring, .5, vt);
if (K.idx === 2) drawTasksRing(1.9 * K.ring, .5);
drawCore(K.scale, K.explode, K.unfold, vt);
drawRing(2.05 * K.ring, -.5, vt);
drawPackets(chans);
const nowMs = performance.now();
Stage.portScreen = PORTS.map((pt, i) => {
const wp = V(pt.x * K.scale, pt.y * K.scale, pt.z * K.scale);
const behind = rotY(wp, CAM.yaw).z < -.25;
const provider = FLOW_PROVIDERS[i];
const lv = Lamps[provider].level(nowMs) * (behind ? .25 : 1);
const q = lamp3(wp, lv, 1, Lamps[provider].activeKind(nowMs), Lamps[provider].link);
q.hide = behind; return q;
});
lamp3(V(.35 * K.scale, -.15 * K.scale, .5 * K.scale), Lamps.junction.level(nowMs), .85, Lamps.junction.activeKind(nowMs), "ok");
lamp3(V(TX_PORT.x * K.scale, TX_PORT.y * K.scale, TX_PORT.z * K.scale), Lamps.tx.level(nowMs), .9, Lamps.tx.activeKind(nowMs), "ok");
drawSourceLabels(K.idx === 1 ? Math.min(1, K.spread - 1) : 0);
drawPublishLabels(K.idx === 3 ? K.explode : 0);
drawDatasetCards(K.unfold > .4 ? Math.min(1, (K.unfold - .4) * 2.5) : 0, W / 2, H * .46);
drawAuditTrail(K.trail);
Object.values(Lamps).forEach((ch) => { if (ch instanceof LampChannel) ch.prune(nowMs); });
syncUI(K.idx, p);
}
function loop() {
if (!running) return;
render();
rafId = requestAnimationFrame(loop);
}
return publicApi;
})();
/* ==========================================================================
减少动态效果版:六幕静态区块,正常滚动,无位移/旋转/闪烁,信息与功能等价。
========================================================================== */
const StaticShell = (() => {
let mounted = false;
function drawStaticDiagram(canvasEl, seedAngle) {
const ctx = canvasEl.getContext("2d");
const W = canvasEl.width = canvasEl.clientWidth * 2;
const H = canvasEl.height = canvasEl.clientHeight * 2;
const night = document.documentElement.getAttribute("data-theme") === "night";
ctx.fillStyle = night ? "#0A1020" : "#F1F4FC";
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.rotate(seedAngle);
ctx.strokeStyle = night ? "rgba(140,190,255,.55)" : "rgba(47,99,214,.5)";
ctx.lineWidth = 2;
const s = Math.min(W, H) * .16;
ctx.strokeRect(-s, -s, s * 2, s * 2);
ctx.beginPath(); ctx.moveTo(-s, -s); ctx.lineTo(s * .4, -s * .5); ctx.lineTo(s * .4, s * .5); ctx.lineTo(-s, s); ctx.closePath(); ctx.stroke();
ctx.restore();
// 四条固定流向的直线(静态,不闪烁不移动)
const colors = night ? ["#96CDFF", "#78B4FF", "#B9AFFF", "#FFD6A0"] : ["#2F63D6", "#0A7C92", "#6D5DD6", "#BE780A"];
for (let i = 0; i < 4; i++) {
const y = H * (.2 + i * .18);
ctx.strokeStyle = colors[i]; ctx.globalAlpha = .55; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(6, y); ctx.lineTo(W * .42, H / 2); ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.globalAlpha = 1;
}
function lampChipHTML(provider) {
const lamp = Lamps[provider];
const linkOn = lamp.link === "ok";
const recent = Date.now() - lamp.lastAt < 8000;
const label = FLOW_LABEL[provider] || provider;
return `<span class="lampchip"><span class="lk${linkOn ? " on" : ""}"></span><span class="ac${recent ? " on recent" : ""}"></span>${esc(label)}
${lamp.lastAt ? `<span class="muted">· ACT ${Math.max(0, Math.round((Date.now() - lamp.lastAt) / 1000))}s 前</span>` : '<span class="muted">· 无最近事件</span>'}</span>`;
}
function sceneBlockHTML(idx) {
const s = SCENES[idx];
const cab = SCENE_CABIN[idx]();
return `
<section class="scene-static" id="static-${s.key}" data-scene="${idx}">
<div class="scene-head"><span class="eyebrow">${esc(cab.eyebrow)}</span><h2>${esc(cab.title)}</h2></div>
<canvas class="diagram" id="diagram-${idx}"></canvas>
<div class="scene-static-sub muted">${esc(cab.sub)}</div>
${idx === 1 ? `<div class="lamprow">${FLOW_PROVIDERS.map(lampChipHTML).join("")}</div>` : ""}
<div class="cards">${cab.rows.map((r) => `<div class="card"><div class="muted">${esc(r[0])}</div><strong class="${r[2] === "fail" ? "fail" : r[2] === "warn" ? "warn" : ""}">${esc(r[1])}</strong></div>`).join("")}</div>
<div id="static-detail-${idx}">${SCENE_DETAIL[idx]()}</div>
</section>`;
}
function rebindScene(idx) {
const root = $(`static-detail-${idx}`);
if (SCENE_BIND[idx]) SCENE_BIND[idx](root);
}
function rerenderScene(idx) {
const cab = SCENE_CABIN[idx]();
const section = document.querySelector(`.scene-static[data-scene="${idx}"]`);
if (!section) return;
const cardsEl = section.querySelector(".cards");
if (cardsEl) cardsEl.innerHTML = cab.rows.map((r) => `<div class="card"><div class="muted">${esc(r[0])}</div><strong class="${r[2] === "fail" ? "fail" : r[2] === "warn" ? "warn" : ""}">${esc(r[1])}</strong></div>`).join("");
if (idx === 1) { const lr = section.querySelector(".lamprow"); if (lr) lr.innerHTML = FLOW_PROVIDERS.map(lampChipHTML).join(""); }
const detailEl = $(`static-detail-${idx}`);
if (detailEl) { detailEl.innerHTML = SCENE_DETAIL[idx](); rebindScene(idx); }
}
function mount() {
if (mounted) return;
mounted = true;
$("staticNav").innerHTML = SCENES.map((s, i) => `<button data-i="${i}">${esc(s.label)}</button>`).join("");
$("staticNav").querySelectorAll("button").forEach((btn, i) => {
btn.addEventListener("click", () => { setActive(i); scrollToInternal(i); });
});
$("staticScenes").innerHTML = SCENES.map((_, i) => sceneBlockHTML(i)).join("");
SCENES.forEach((_, i) => { drawStaticDiagram($(`diagram-${i}`), i * .5); rebindScene(i); });
setActive(0);
Views.onChange(refreshAll);
}
function refreshAll() { if (mounted) SCENES.forEach((_, i) => rerenderScene(i)); }
function setActive(i) {
$("staticNav").querySelectorAll("button").forEach((b, j) => b.classList.toggle("active", j === i));
state.scene = i;
$("crumb").textContent = `8766 · ${SCENES[i].label} · 持续运转`;
$("phase").textContent = `${i + 1} / ${SCENES.length}`;
}
function scrollToInternal(i) {
const el = document.querySelector(`.scene-static[data-scene="${i}"]`);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}
function unmount() { /* CSS 控制显隐,无需清空内容,避免频繁重建 DOM */ }
return { mount, unmount, scrollTo: scrollToInternal };
})();
/* ==========================================================================
启动
========================================================================== */
function enterShell() {
Stage.init();
applyReduced(REDUCE_MQ.matches);
const savedTheme = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })();
applyTheme(savedTheme === "night" ? "night" : "day");
Poller.startAll();
updateCrumbStatus();
}
boot();