diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js
index afaf599..0f567d4 100644
--- a/xiaobai-datahub/admin/app.js
+++ b/xiaobai-datahub/admin/app.js
@@ -1,6 +1,34 @@
-const state = { csrf: "", page: "overview" };
+"use strict";
+/* ==========================================================================
+ xiaobai-datahub 管理后台 · 第七版「轨道机芯」
+ 一台持续运转的数据空间:滚动推镜、立体核心换面、四路真实来源持续流动、
+ A2 路由器式 LINK/ACT 信号灯——全部由真实接口数据驱动,没有任何模拟流量。
+ 只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测
+ (每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件",
+ 因此只有这四路会闪 ACT;ths/xgb/akshare 是预留源,永远不闪。
+ ========================================================================== */
+/* ---------------------------------------------------------------- 基础工具 */
function $(id) { return document.getElementById(id); }
+function esc(value) {
+ return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 || {});
@@ -18,10 +46,6 @@ function show(id) {
["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
}
-function esc(value) {
- return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch]));
-}
-
function table(headers, rows) {
const thead = headers.map((h) => `
-
-
-
+
总览 · ${esc(d.trade_date)}
+
${esc(PHASE_LABELS[d.session_phase] || d.session_phase)} · 源共 ${d.source_count}
+
+
+
阶段
${esc(PHASE_LABELS[d.session_phase] || d.session_phase)}
+
今日发布
${d.publications.length}
+
盘后补跑
${esc(EOD_LABELS[eod.state] || eod.state || "-")}${eodExtra.join(" · ")}
+
估值复核
${esc(REV_LABELS[rev.state] || rev.state || "-")}${revExtra.join(" · ")}
+
异常批次
${d.anomalies.length}
-
当前映射
- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [
- esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
- row.prev_batch ? `
` : "-",
- ]))}
-
批次
- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [
- esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error),
+
最近调用(tushare)
+ ${table(["时间", "源", "端点", "结果", "耗时"], d.recent_calls.map((row) => [
+ esc(timeShort(row.created_at)), esc(row.provider), esc(row.endpoint),
+ row.ok ? '
成功' : `
${esc(row.error)}`,
+ `${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 `
数据源
正在加载…
`;
+ const html = `
数据源
LINK 常亮表示健康;ACT 只在真实探测/调用发生时短闪
` +
+ 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,
+ `
`,
+ ];
+ }));
+ 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 `
调度任务
正在加载…
`;
+ return `
+
调度任务
+ ${table(["任务", "时刻", "操作"], d.jobs.map((job) => [
+ `${esc(job.id)} · ${esc(job.title)}`, esc(job.at),
+ `
`,
+ ]))}
+
最近运行
+ ${table(["ID", "任务", "状态", "开始", "结束", "错误"], d.runs.map((row) => [
+ row.id, esc(row.job_id),
+ `
${esc(row.state)}`,
+ 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 `
盘后发布
正在加载…
`;
+ return `
+
盘后发布 ${esc(d.trade_date)}
+
+
+
+
+
+
当前映射
+ ${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 ? `
` : "-",
+ ]))}
+
批次
+ ${table(["batch_id", "数据集", "状态", "行数", "错误"], d.batches.map((row) => [
+ esc(row.batch_id), esc(row.dataset),
+ `
${esc(row.state)}`,
+ 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 = ($("rel-date") && $("rel-date").value) || "";
+ 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";
- await api(path, {
- method: "POST",
- body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }),
+ 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 `
数据集
正在加载…
`;
+ return `
+
数据集 / 质量 ${esc(d.trade_date)}
+ ${table(["数据集", "批次", "状态", "发布时间"], d.publications.map((row) => [
+ esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at),
+ ]))}
+
源间差异
+ ${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 `
审计
正在加载…
`;
+ return `
审计
共 ${d.items.length} 条 · 最新在前
` +
+ 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) => `
${esc(s.label)}
`).join('
');
+ 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 = `
${esc(c.eyebrow)}
${esc(c.title)}
${esc(c.sub)}
+
${c.rows.map((r) => `
${esc(r[0])}${esc(r[1])}
`).join("")}
+
${c.actions.map((a, i) => ``).join("")}
`;
+ 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();
});
- render();
+
+ 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 `
${esc(label)}
+ ${lamp.lastAt ? `· ACT ${Math.max(0, Math.round((Date.now() - lamp.lastAt) / 1000))}s 前` : '· 无最近事件'}`;
+ }
+ function sceneBlockHTML(idx) {
+ const s = SCENES[idx];
+ const cab = SCENE_CABIN[idx]();
+ return `
+
+ ${esc(cab.eyebrow)}
${esc(cab.title)}
+
+ ${esc(cab.sub)}
+ ${idx === 1 ? `${FLOW_PROVIDERS.map(lampChipHTML).join("")}
` : ""}
+ ${cab.rows.map((r) => `
`).join("")}
+ ${SCENE_DETAIL[idx]()}
+ `;
+ }
+ 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) => `
`).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) => `
`).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();
diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html
index b7eb893..5d6a164 100644
--- a/xiaobai-datahub/admin/index.html
+++ b/xiaobai-datahub/admin/index.html
@@ -3,14 +3,15 @@
-
xiaobai-datahub 管理后台
+
xiaobai-datahub 管理后台 · 数据中枢
+ 内网 · 8766
数据中枢
- 内网管理后台,用于查看源状态、调度和盘后发布批次。
+ 四路来源持续汇流、调度、发布与审计的运转空间。
diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css
index 1631ac1..e62ead7 100644
--- a/xiaobai-datahub/admin/styles.css
+++ b/xiaobai-datahub/admin/styles.css
@@ -1,51 +1,286 @@
+/* xiaobai-datahub 数据中枢后台 —— 第七版「轨道机芯」
+ 统一动效 Token:进入/退出用强 ease-out,屏内移动用强 ease-in-out,持续流动用 linear。
+ 禁止 ease-in、禁止 transition:all —— 全文件遵守。 */
:root {
color-scheme: light;
- --bg: #f4f5f7;
- --surface: #ffffff;
- --text: #1f2329;
- --muted: #646a73;
- --line: #dee0e3;
- --action: #3370ff;
- --danger: #e04536;
- --ok: #16a34a;
- --warn: #b45309;
- --radius: 8px;
+ --bar-h: 52px;
+ --radius: 14px;
--pad: 16px;
- font-family: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
+ --font-cn: "Segoe UI", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
+ --font-mono: "JetBrains Mono", "DejaVu Sans Mono", Consolas, monospace;
+ --ease-out: cubic-bezier(.22, 1, .36, 1);
+ --ease-in-out: cubic-bezier(.65, 0, .35, 1);
+ --ease-linear: linear;
+
+ /* day (default) */
+ --bg0: #EDF0F7;
+ --bg1: #F8FAFF;
+ --surface: #ffffff;
+ --stage-edge: rgba(47, 99, 214, .16);
+ --text: #1B2547;
+ --muted: #5B6785;
+ --faint: #93A0BD;
+ --line: rgba(47, 99, 214, .18);
+ --action: #2F63D6;
+ --cyan: #0A9C93;
+ --amber: #B97A0E;
+ --danger: #D64F4F;
+ --ok: #1E9E6A;
+ --warn: #B97A0E;
+ --glass: rgba(255, 255, 255, .82);
+ --chip: rgba(47, 99, 214, .08);
+ --face: rgba(120, 160, 235,);
+ --edge: rgba(47, 99, 214,);
+ --slab: rgba(10, 156, 147,);
+ --ring: rgba(47, 99, 214,);
+ --chan1: rgba(47, 99, 214,);
+ --chan2: rgba(10, 124, 146,);
+ --chan3: rgba(109, 93, 214,);
+ --chan4: rgba(190, 120, 10,);
}
:root[data-theme="night"] {
color-scheme: dark;
- --bg: #111318;
- --surface: #1b1e24;
- --text: #e8eaed;
- --muted: #9aa0a6;
- --line: #2a2f38;
- --action: #5b8cff;
+ --bg0: #05080F;
+ --bg1: #0A1020;
+ --surface: #12172A;
+ --stage-edge: rgba(90, 140, 255, .14);
+ --text: #E8EEFC;
+ --muted: #8B97B8;
+ --faint: #59637F;
+ --line: rgba(120, 160, 255, .16);
+ --action: #4A86FF;
+ --cyan: #2FD8CE;
+ --amber: #FFB84D;
+ --danger: #FF6B6B;
+ --ok: #3ED598;
+ --warn: #FFB84D;
+ --glass: rgba(13, 20, 38, .72);
+ --chip: rgba(74, 134, 255, .10);
+ --face: rgba(58, 96, 180,);
+ --edge: rgba(140, 190, 255,);
+ --slab: rgba(47, 216, 206,);
+ --ring: rgba(120, 170, 255,);
+ --chan1: rgba(150, 205, 255,);
+ --chan2: rgba(120, 180, 255,);
+ --chan3: rgba(185, 175, 255,);
+ --chan4: rgba(255, 214, 160,);
}
+
* { box-sizing: border-box; }
-body { margin: 0; background: var(--bg); color: var(--text); }
-.panel, header.top, nav, main { background: var(--surface); }
-.auth-panel { max-width: 420px; margin: 12vh auto; padding: 28px; border-radius: var(--radius); border: 1px solid var(--line); }
-label { display: block; margin: 12px 0; }
-input, select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--text); }
-button { background: var(--action); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; }
-button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }
-button.danger { background: var(--danger); }
+html, body { margin: 0; background: var(--bg0); color: var(--text); font-family: var(--font-cn); }
+body { overflow-x: hidden; }
+.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
.error { color: var(--danger); }
-.top { display: flex; gap: 12px; align-items: center; padding: 10px var(--pad); border-bottom: 1px solid var(--line); }
-nav { display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); }
-nav button { background: transparent; color: var(--muted); }
-nav button.active { color: var(--action); background: transparent; font-weight: 600; }
-main { padding: var(--pad); min-height: calc(100vh - 96px); }
-.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; }
-.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; }
-table { width: 100%; border-collapse: collapse; font-size: 13px; }
-th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
-.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.fail { color: var(--danger); }
-.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: end; }
-.toolbar label { margin: 0; }
+
+/* ---------- 登录 / 改密(未进入舞台的前置流程) ---------- */
+.auth-panel {
+ max-width: 420px; margin: 12vh auto; padding: 30px 32px; border-radius: var(--radius);
+ background: var(--glass); border: 1px solid var(--line); backdrop-filter: blur(14px);
+ box-shadow: 0 30px 70px -32px rgba(20, 30, 60, .35);
+}
+.auth-panel h1 { margin: 0 0 6px; font-size: 21px; }
+.auth-panel label { display: block; margin: 12px 0; font-size: 13px; color: var(--muted); }
+.auth-panel input {
+ width: 100%; margin-top: 6px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px;
+ background: var(--chip); color: var(--text); font-family: var(--font-cn);
+}
+.auth-panel input:focus { outline: 2px solid var(--action); outline-offset: 1px; }
+.auth-panel .badge-sim { display: inline-block; margin-bottom: 10px; }
+
+button { font-family: var(--font-cn); cursor: pointer; }
+.btn, button.primary, .auth-panel button[type="submit"] {
+ background: linear-gradient(135deg, var(--action), var(--cyan)); color: #fff; border: 0; border-radius: 8px;
+ padding: 8px 16px; font-size: 13px; transition: transform .12s var(--ease-out), opacity .12s var(--ease-out);
+}
+.btn:active, button:active { transform: translateY(1px); }
+.btn.ghost, button.ghost {
+ background: var(--chip); color: var(--text); border: 1px solid var(--line);
+}
+.btn.warn { background: transparent; color: var(--warn); border: 1px solid var(--warn); }
+.btn.danger, button.danger { background: var(--danger); color: #fff; border: 0; }
+.btn.pri { background: linear-gradient(135deg, var(--action), var(--cyan)); color: #fff; border: 0; }
+.btn { padding: 6px 13px; border-radius: 8px; font-size: 12px; }
+
+.badge-sim {
+ font-size: 11px; padding: 3px 10px; border-radius: 99px; letter-spacing: .1em;
+ color: var(--amber); border: 1px solid var(--amber); opacity: .85;
+}
+
+/* ---------- 通用表格 / 卡片(cabin、detail、reduced 三处共用同一来源) ---------- */
+table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
+th, td { text-align: left; padding: 7px 6px; border-bottom: 1px dashed var(--line); vertical-align: top; }
+th { color: var(--faint); font-weight: 500; font-size: 11px; letter-spacing: .04em; }
+.pill { font-size: 12px; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 14px; }
+.card { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: var(--chip); }
+.card strong { font-size: 18px; }
+.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0; align-items: center; }
+.toolbar label { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
+.toolbar input { padding: 6px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--chip); color: var(--text); }
+h2 { font-size: 16px; margin: 4px 0 10px; }
+h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); }
+
+/* ---------- 顶部总控条 ---------- */
+#topbar {
+ height: var(--bar-h); display: flex; align-items: center; gap: 14px; padding: 0 22px;
+ border-bottom: 1px solid var(--line); background: linear-gradient(180deg, var(--bg1), transparent);
+ backdrop-filter: blur(10px);
+ position: sticky; top: 0; z-index: 4;
+}
+#topbar .logo { font-weight: 700; font-size: 15px; letter-spacing: .04em; }
+#topbar .logo em { font-style: normal; color: var(--cyan); }
+#topbar .crumb { font-size: 12px; color: var(--faint); }
+#topbar .spacer { flex: 1; }
+#topbar .who { font-size: 12px; }
+
+/* ---------- 固定空间舞台(76vh,六幕连续场景) ----------
+ #track 的六段高幕(每段 130vh)在它身后正常参与文档流把页面撑高,
+ #stageWrap 用 sticky 钉在视口里,滚动时只有 #track 的高度被“划过”,
+ 舞台本体在这段距离内始终可见、位置不变,直到 #track 撑出的空间耗尽才随之离场。 */
+#stageWrap {
+ flex: none; height: 76vh; min-height: 480px; margin: 0 18px; border-radius: 18px;
+ overflow: hidden; border: 1px solid var(--stage-edge);
+ box-shadow: 0 30px 80px -30px rgba(0, 0, 0, .45), inset 0 0 120px rgba(0, 0, 0, .08);
+ position: sticky; top: var(--bar-h); z-index: 2;
+}
+#scene { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
+
+/* ---------- 控制舱:当前场景的关键数据与操作 ---------- */
+#cabin {
+ position: absolute; left: 22px; top: 22px; width: 300px; z-index: 5; max-height: calc(100% - 44px);
+ overflow: auto; background: var(--glass); border: 1px solid var(--line); border-radius: 14px;
+ backdrop-filter: blur(14px); padding: 16px 18px;
+ transition: opacity .45s var(--ease-out), transform .45s var(--ease-out);
+}
+#cabin.hide { opacity: 0; transform: translateX(-14px); pointer-events: none; }
+#cabin .eyebrow { font-size: 10px; letter-spacing: .22em; color: var(--cyan); margin-bottom: 6px; }
+#cabin h2 { font-size: 18px; margin: 0 0 4px; }
+#cabin .sub { font-size: 11.5px; color: var(--muted); line-height: 1.6; margin-bottom: 10px; }
+#cabin .rows { display: flex; flex-direction: column; gap: 7px; margin-bottom: 4px; }
+#cabin .row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
+#cabin .row b { color: var(--text); font-weight: 600; }
+#cabin .row .fill { flex: 1; }
+#cabin .dotlamp { width: 7px; height: 7px; border-radius: 99px; background: var(--ok); box-shadow: 0 0 8px var(--ok); flex: none; }
+#cabin .dotlamp.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
+#cabin .dotlamp.fail { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
+#cabin .actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
+
+/* ---------- 底部章节轨(导航等同滚动跳转) ---------- */
+#rail {
+ position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 5;
+ display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-radius: 99px;
+ background: var(--glass); border: 1px solid var(--line); backdrop-filter: blur(14px);
+}
+#rail .stop {
+ display: flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 99px; cursor: pointer;
+ font-size: 11.5px; color: var(--faint); background: transparent; border: 0;
+ transition: color .25s var(--ease-in-out), background .25s var(--ease-in-out);
+ font-family: var(--font-cn); white-space: nowrap;
+}
+#rail .stop i { width: 6px; height: 6px; border-radius: 99px; background: var(--faint); transition: background .25s var(--ease-in-out), box-shadow .25s var(--ease-in-out); display: inline-block; }
+#rail .stop.on { color: var(--text); background: var(--chip); }
+#rail .stop.on i { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
+#rail .sep { width: 12px; height: 1px; background: var(--line); }
+
+/* ---------- 图例 ---------- */
+#legend {
+ position: absolute; right: 22px; top: 22px; z-index: 5; font-size: 10.5px; color: var(--muted);
+ background: var(--glass); border: 1px solid var(--line); border-radius: 12px; padding: 10px 13px;
+ backdrop-filter: blur(14px); line-height: 1.9;
+}
+#legend .lampico { display: inline-block; width: 14px; height: 5px; border-radius: 99px; vertical-align: middle; margin-right: 6px; }
+#legend .li-link { background: rgba(47, 216, 206, .5); box-shadow: 0 0 6px rgba(47, 216, 206, .6); }
+#legend .li-act { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
+
+/* ---------- 详情面板:从端口/来源空间位置展开的功能抽屉 ---------- */
+#detail {
+ position: absolute; z-index: 8; width: 360px; max-width: calc(100% - 40px); max-height: calc(100% - 40px);
+ overflow: auto; pointer-events: auto; background: var(--glass); border: 1px solid var(--line);
+ border-radius: 14px; backdrop-filter: blur(16px); padding: 16px 18px;
+ transition: transform .4s var(--ease-out), opacity .3s var(--ease-out);
+ box-shadow: 0 24px 60px -20px rgba(0, 0, 0, .45);
+}
+#detail.closed { transform: scale(.14); opacity: 0; pointer-events: none; }
+#detail h3 { font-size: 14px; margin: 0 0 2px; color: var(--text); }
+#detail .dsub { font-size: 10.5px; color: var(--faint); margin-bottom: 10px; letter-spacing: .04em; }
+#detailClose {
+ position: absolute; right: 10px; top: 10px; width: 24px; height: 24px; border-radius: 8px;
+ border: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1;
+}
+#detail .dtag {
+ position: absolute; left: -7px; top: 26px; width: 14px; height: 14px; transform: rotate(45deg);
+ background: var(--glass); border-left: 1px solid var(--line); border-bottom: 1px solid var(--line);
+}
+
+/* ---------- 滚动提示 ---------- */
+#hint {
+ position: absolute; left: 50%; bottom: 100px; transform: translateX(-50%); z-index: 5;
+ font-size: 11px; letter-spacing: .3em; color: var(--faint); transition: opacity .5s var(--ease-out);
+}
+#hint.off { opacity: 0; }
+
+/* 滚动章节占位:仅提供滚动行程,无可见内容 */
+#track { position: relative; z-index: 1; pointer-events: none; }
+#track section { height: 130vh; }
+
+/* ---------- 危险操作 / 确认弹层,复用 dialog ---------- */
dialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 20px; }
+
+/* ================================================================
+ 减少动态效果(prefers-reduced-motion):舞台切换为六幕静态空间图 + 固定文字状态
+ 信息与功能与动效版完全一致,只是去掉滚动镜头、旋转与闪烁。
+ ================================================================ */
+#staticShell { display: none; }
+html.reduced #stageWrap, html.reduced #track, html.reduced #hint { display: none !important; }
+html.reduced #staticShell { display: block; }
+
+#staticNav {
+ display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line);
+ background: var(--surface); flex-wrap: wrap; position: sticky; top: 0; z-index: 3;
+}
+#staticNav button {
+ background: transparent; color: var(--muted); border: 0; padding: 7px 12px; border-radius: 8px; font-size: 13px;
+}
+#staticNav button.active { color: var(--action); font-weight: 600; background: var(--chip); }
+
+.scene-static { padding: var(--pad); border-bottom: 1px solid var(--line); }
+.scene-static .scene-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
+.scene-static .scene-head .eyebrow { font-size: 11px; letter-spacing: .18em; color: var(--cyan); }
+.scene-static .diagram {
+ width: 100%; max-width: 620px; height: 190px; border-radius: 12px; border: 1px solid var(--line);
+ background: var(--chip); display: block; margin-bottom: 12px;
+}
+.scene-static .lamprow { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
+.lampchip {
+ display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted);
+ border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; background: var(--chip);
+}
+.lampchip .lk, .lampchip .ac { width: 7px; height: 7px; border-radius: 99px; background: var(--faint); display: inline-block; }
+.lampchip .lk.on { background: var(--cyan); box-shadow: 0 0 5px var(--cyan); }
+.lampchip .ac.on { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
+.lampchip .ac.recent { outline: 1px solid var(--amber); transition: outline-color .8s var(--ease-out); }
+
+/* ---------- 主内容区(main/reduced 内容容器统一样式,供动效版 detail 与静态版共用) ---------- */
+main#page { padding: var(--pad); min-height: calc(100vh - 96px); background: var(--surface); }
+
+/* ---------- 响应式:1440 / 1280 / 1024 常见桌面宽 ---------- */
+@media (max-width: 1280px) {
+ #cabin { width: 260px; }
+ #detail { width: 320px; }
+}
+@media (max-width: 1100px) {
+ #stageWrap { margin: 0 10px; }
+ #cabin { width: 230px; padding: 13px 14px; }
+ #legend { display: none; }
+}
+
+/* ---------- 系统级兜底:即便 JS 未及时接管,也不留半成品动效 ---------- */
+@media (prefers-reduced-motion: reduce) {
+ #stageWrap, #track, #hint { display: none !important; }
+ #staticShell { display: block !important; }
+ * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
+}