"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 || {});
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) => `
${esc(h)} | `).join("");
const body = rows.length
? rows.map((cols) => `${cols.map((c) => `| ${c} | `).join("")}
`).join("")
: `| 暂无数据 |
`;
return ``;
}
/* ---------------------------------------------------------------- 登录 / 会话 */
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 `总览
正在加载…
`;
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 `
总览 · ${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}
最近调用(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 = 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 `数据集
正在加载…
`;
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 2D 引擎(动效版)—— 第八版「星港中枢」
整页星空,中央星体,四颗数据源卫星沿两层轨道环绕;六幕滚动只切换镜头焦点
(轨道外扩/任务环/发布分层/数据集展开/审计尾迹的透明度与半径),星系本体
(星体 + 两层轨道 + 四颗卫星 + 双灯)始终同屏、连续运转,不退回卡片长页。
坐标系:世界原点为星系中心;屏幕坐标 = 安全盒中心 CX,CY + 世界坐标 * S。
S、CX、CY 按 HEL-538 复审公式在 resize() 中计算,保证 1440/1280/1024 三档
构造性不裁切。真实数据/事件驱动全部复用上方 A2 灯引擎与 Poller,不臆造流量。
========================================================================== */
const Stage = (() => {
let canvas, ctx, W = 0, H = 0, DPR = 1;
let running = false, rafId = null;
let PAL, C;
const T0 = performance.now();
/* ---------------- 安全盒 / 缩放(HEL-538 唯一施工口径) ---------------- */
let cabinW = 300, sizeTier = 0;
let x0 = 0, x1 = 0, y0 = 0, y1 = 0, CX = 0, CY = 0, S = 1;
const FIT_X = 1.28, FIT_Y = 1.28 * 0.52;
const CHIP_SIZES = [{ w: 120, h: 42 }, { w: 108, h: 40 }, { w: 96, h: 38 }];
const LAMP_SIZES = [{ w: 16, h: 6 }, { w: 15, h: 6 }, { w: 14, h: 6 }];
/* ---------------- 星系几何常量(HEL-536 §3,轨道配对按 HEL-537 已确认样图) ---------------- */
const STAR_R = 0.28;
const ORBIT_TILT = -12 * Math.PI / 180;
const ORBITS = { inner: { a: .70, b: .70 * .52 }, outer: { a: .94, b: .94 * .52 } };
const RING_EXPAND_MAX = 0.10; // 幕2 外扩幅度:outer a 最大 .94*1.10=1.034,远低于 1.175 上限
function polarPt(r, deg) { const a = deg * Math.PI / 180; return { x: r * Math.cos(a), y: r * Math.sin(a) }; }
const TX_WORLD = polarPt(1.25, -20); // 盘后发布去向端
const AUD_WORLD = polarPt(1.22, 25); // 审计去向端
/* 四颗卫星:轨道配对与朝向沿用 HEL-537 已确认样图(tushare/eastmoney 内轨,
tencent/ifind 外轨);相位、角速度、方向沿用 HEL-536 §3 逐源数值。 */
const SATS = [
{ id: "tushare", label: "Tushare", sub: "官方盘后", orbit: "inner", phase: 200 * Math.PI / 180, speed: .028, dir: -1 },
{ id: "eastmoney", label: "东方财富", sub: "资讯 · 涨停", orbit: "inner", phase: 20 * Math.PI / 180, speed: .022, dir: -1 },
{ id: "tencent", label: "腾讯", sub: "行情快照", orbit: "outer", phase: 140 * Math.PI / 180, speed: .034, dir: 1 },
{ id: "ifind", label: "iFinD", sub: "机构数据", orbit: "outer", phase: 320 * Math.PI / 180, speed: .026, dir: 1 },
]; // 顺序与 FLOW_PROVIDERS 对齐,供 A2 灯引擎按下标直接取用
const STAR_LANDS = [ // 星体表面数据大陆(装饰性,球心相对坐标,半径相对球半径)
{ x: -.35, y: -.28, rad: .22 }, { x: .28, y: -.12, rad: .16 }, { x: -.08, y: .22, rad: .20 },
{ x: .34, y: .30, rad: .14 }, { x: -.44, y: .14, rad: .12 }, { x: .04, y: -.42, rad: .11 },
];
function initPalette() {
PAL = {
night: {
bgA: "#05070F", bgB: "#0B1026", starHi: "#F2FAFF", starA: "#C9E7FF", starB: "#7FB2EA", starC: "#3E6FAE", starD: "#16305E",
band: "rgba(160,220,255,.35)", landHi: "rgba(190,235,255,.55)", shell: "rgba(126,200,255,.22)",
halo: "rgba(110,170,255,.22)", edge: "#D8F0FF", orbitLine: "rgba(140,180,255,.22)",
link: "#35E0B2", beam: "#7EC8FF", ink: "#E8F1FF", dim: "#93A5C8", faint: "#5C6B8E",
dockBg: "rgba(10,17,36,.85)", dockLine: "rgba(140,180,255,.30)", amber: "#FFB454", red: "#FF6B6B", actOff: "#3A2C18",
sat: { tushare: "#4CC9F0", eastmoney: "#FFB454", tencent: "#6FA8FF", ifind: "#FF6E7F" },
},
day: {
bgA: "#D8E3F5", bgB: "#E9F0FA", starHi: "#FFFFFF", starA: "#EAF6FF", starB: "#A9CCF0", starC: "#5488C4", starD: "#2E5A94",
band: "rgba(70,120,200,.30)", landHi: "rgba(80,140,220,.42)", shell: "rgba(90,140,210,.24)",
halo: "rgba(120,160,220,.28)", edge: "#FFFFFF", orbitLine: "rgba(90,120,180,.40)",
link: "#0E9E7E", beam: "#3E6CA8", ink: "#22304E", dim: "#5B6B8C", faint: "#8B99B8",
dockBg: "rgba(255,255,255,.92)", dockLine: "rgba(34,48,78,.35)", amber: "#B97A0E", red: "#D64F4F", actOff: "#E7DCC6",
sat: { tushare: "#3997B4", eastmoney: "#BF873F", tencent: "#537EBF", ifind: "#BF525F" },
},
};
C = PAL[document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"];
}
const isNight = () => C === PAL.night;
const setAdd = () => { ctx.globalCompositeOperation = isNight() ? "lighter" : "source-over"; };
/* ---------------- 基础几何工具 ---------------- */
function toScreen(wx, wy) { return { x: CX + wx * S, y: CY - wy * S }; }
function quadPt(p0, p1, p2, t) {
const u = 1 - t;
return { x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x, y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y };
}
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();
}
function glowDot(x, y, r, color, alpha, glow) {
ctx.save(); setAdd();
if (glow) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3.4);
g.addColorStop(0, color + "55"); g.addColorStop(1, color + "00");
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r * 3.4, 0, 7); ctx.fill();
}
ctx.globalAlpha = alpha; ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill();
ctx.restore();
}
const smooth = (t) => 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; }
/* ---------------- 六幕内容参数(镜头不再自由环绕,只切换焦点透明度/半径,
星系本体——星体/双轨/四卫星/双灯——全程同屏,满足"连续换场不退回卡片长页") ---------------- */
const CH = [
{ ring: 0, task: 0, explode: 0, unfold: 0, trail: 0 }, // 0 总览
{ ring: 1, task: 0, explode: 0, unfold: 0, trail: 0 }, // 1 数据源:轨道外扩,卫星芯片展开
{ ring: .3, task: 1, explode: 0, unfold: 0, trail: 0 }, // 2 调度任务:外轨兼作时间轮
{ ring: 0, task: 0, explode: 1, unfold: 0, trail: 0 }, // 3 盘后发布:星体分层
{ ring: 0, task: 0, explode: .15, unfold: 1, trail: 0 }, // 4 数据集:六张数据面片展开
{ ring: 0, task: 0, explode: 0, unfold: 0, trail: 1 }, // 5 审计:时间尾迹
];
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) };
}
/* ---------------- 星空背景(两层预渲染星点 + 双团星云,克制层次) ---------------- */
let starLayer = null, starLayerKey = "";
function buildStarLayer() {
const key = `${Math.round(W)}x${Math.round(H)}`;
if (starLayerKey === key && starLayer) return;
starLayerKey = key;
const off = document.createElement("canvas");
off.width = Math.max(1, Math.round(W)); off.height = Math.max(1, Math.round(H));
const octx = off.getContext("2d");
let seed = 20260913;
const rnd = () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const layers = [[70, .55, "rgba(200,215,255,"], [35, .8, "rgba(210,225,255,"], [15, 1.1, "rgba(225,238,255,"]];
layers.forEach(([n, r, col]) => {
for (let i = 0; i < n; i++) {
const x = rnd() * off.width, y = rnd() * off.height, a = .25 + rnd() * .5;
octx.fillStyle = col + a.toFixed(2) + ")"; octx.beginPath(); octx.arc(x, y, r, 0, 7); octx.fill();
}
});
starLayer = off;
}
function drawBackground(vt) {
const bg = ctx.createRadialGradient(W * .5, CY, 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 n1 = ctx.createRadialGradient(W * .18, H * .22, 10, W * .18, H * .22, Math.max(W, H) * .32);
n1.addColorStop(0, C === PAL.night ? "rgba(43,36,92,.16)" : "rgba(255,255,255,.4)"); n1.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H);
const n2 = ctx.createRadialGradient(W * .82, H * .78, 10, W * .82, H * .78, Math.max(W, H) * .30);
n2.addColorStop(0, C === PAL.night ? "rgba(27,36,80,.16)" : "rgba(196,186,240,.24)"); n2.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = n2; ctx.fillRect(0, 0, W, H);
ctx.restore();
if (starLayer) {
ctx.save(); ctx.globalAlpha = .8 + Math.sin(vt * .06) * .06; ctx.drawImage(starLayer, 0, 0); ctx.restore();
}
}
/* ---------------- 轨道 ---------------- */
function drawOrbit(a, b, alpha) {
ctx.save(); ctx.strokeStyle = C.orbitLine; ctx.globalAlpha = alpha; ctx.lineWidth = 1;
ctx.beginPath();
const ct = Math.cos(ORBIT_TILT), st = Math.sin(ORBIT_TILT);
for (let i = 0; i <= 72; i++) {
const t = (i / 72) * Math.PI * 2;
const x0 = a * Math.cos(t), y0 = b * Math.sin(t);
const s = toScreen(x0 * ct - y0 * st, x0 * st + y0 * ct);
if (i === 0) ctx.moveTo(s.x, s.y); else ctx.lineTo(s.x, s.y);
}
ctx.stroke(); ctx.restore();
}
// 装饰性文字标签是否与某颗卫星芯片矩形重叠——重叠则让位(跳过该文字),保证主体(卫星)永不被次要信息遮挡
function nearChip(x, y, chipRects) {
if (!chipRects) return false;
return chipRects.some((r) => Math.abs(x - r.x) < r.w / 2 && Math.abs(y - r.y) < r.h / 2);
}
function drawTaskRing(alpha, expand, chipRects) {
const jobsData = state.data.jobs; if (!jobsData) return;
const jobs = jobsData.jobs || [], runs = jobsData.runs || [];
if (!jobs.length) return;
const latestByJob = new Map();
for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r);
const a = ORBITS.outer.a * expand, b = ORBITS.outer.b * expand;
const ct = Math.cos(ORBIT_TILT), st = Math.sin(ORBIT_TILT);
ctx.save(); ctx.globalAlpha = alpha;
jobs.forEach((job, i) => {
const th = (i / jobs.length) * Math.PI * 2 + .3;
const x0 = a * Math.cos(th), y0 = b * Math.sin(th);
const q = toScreen(x0 * ct - y0 * st, x0 * st + y0 * ct);
const run = latestByJob.get(job.id);
const st2 = run ? run.state : "never";
const color = st2 === "failed" ? C.red : st2 === "ok" ? C.link : st2 === "running" ? C.amber : C.faint;
glowDot(q.x, q.y, st2 === "never" ? 3 : 4.4, color, st2 === "never" ? .4 : .9, st2 !== "never");
const isFresh = (Stage.freshJobRuns || []).some((r) => r.job_id === job.id);
if (isFresh) glowDot(q.x, q.y, 7, C.amber, .5 * Lamps.junction.level(performance.now()), true);
if (!nearChip(q.x, q.y - 12, chipRects)) {
ctx.fillStyle = st2 === "failed" ? C.red : C.dim; ctx.globalAlpha = alpha * .8;
ctx.font = "10px \"Noto Sans SC\""; ctx.textAlign = "center";
ctx.fillText(String(job.title || job.id).slice(0, 8), q.x, q.y - 12);
ctx.textAlign = "left"; ctx.globalAlpha = alpha;
}
});
ctx.restore();
}
/* ---------------- 星体(球体 + 自转经纬带/数据大陆 + 分层展开) ---------------- */
function drawStar(vt, K) {
const c0 = toScreen(0, 0), r = STAR_R * S;
ctx.save(); setAdd();
const halo = ctx.createRadialGradient(c0.x, c0.y, r * .5, c0.x, c0.y, r * 1.7);
halo.addColorStop(0, "rgba(0,0,0,0)"); halo.addColorStop(.55, C.halo); halo.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(c0.x, c0.y, r * 1.7, 0, 7); ctx.fill();
ctx.restore();
// 分层展开(幕4 盘后发布):校验 / 暂存 / 发布 三层扁环
if (K.explode > .04) {
const names = ["校验", "暂存", "发布"];
for (let i = 0; i < 3; i++) {
const oy = (i - 1) * (.42 + K.explode * .55) * STAR_R;
const s0 = toScreen(-.62 * STAR_R, oy), s1 = toScreen(.62 * STAR_R, oy);
ctx.save(); ctx.globalAlpha = .18 + K.explode * .22; ctx.strokeStyle = C.link; ctx.lineWidth = Math.max(1.4, r * .05);
ctx.beginPath(); ctx.ellipse((s0.x + s1.x) / 2, s0.y, Math.abs(s1.x - s0.x) / 2, r * .09, 0, 0, 7); ctx.stroke();
if (K.explode > .5) {
ctx.globalAlpha = .85; ctx.fillStyle = C.ink; ctx.font = "600 11px \"Noto Sans SC\""; ctx.textAlign = "left";
ctx.fillText(names[i], s1.x + 8, s0.y + 4);
}
ctx.restore();
}
}
// 球体本体(裁剪出圆形后画渐变 + 自转经纬带/数据大陆 + 暗面)
ctx.save(); ctx.beginPath(); ctx.arc(c0.x, c0.y, r, 0, 7); ctx.closePath(); ctx.clip();
const grad = ctx.createRadialGradient(c0.x - r * .22, c0.y - r * .24, r * .05, c0.x, c0.y, r * 1.05);
grad.addColorStop(0, C.starHi); grad.addColorStop(.30, C.starA); grad.addColorStop(.60, C.starB);
grad.addColorStop(.85, C.starC); grad.addColorStop(1, C.starD);
ctx.fillStyle = grad; ctx.fillRect(c0.x - r, c0.y - r, r * 2, r * 2);
ctx.save(); ctx.translate(c0.x, c0.y); ctx.rotate((vt * .05) % (Math.PI * 2));
ctx.strokeStyle = C.band; ctx.lineWidth = Math.max(1, r * .018);
for (let i = 0; i < 9; i++) {
const bx = -r + (i + .5) * (2 * r / 9);
const edge = Math.abs(bx) / r;
ctx.globalAlpha = .5 - edge * .32;
ctx.beginPath(); ctx.moveTo(bx, -r * .95); ctx.lineTo(bx, r * .95); ctx.stroke();
}
ctx.globalAlpha = 1;
STAR_LANDS.forEach((land) => {
const lx = land.x * r, ly = land.y * r, lr = land.rad * r;
const g2 = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr);
g2.addColorStop(0, C.landHi); g2.addColorStop(1, "rgba(0,0,0,0)");
ctx.globalAlpha = .55 + K.explode * .2 + K.unfold * .15; ctx.fillStyle = g2;
ctx.beginPath(); ctx.ellipse(lx, ly, lr, lr * .62, 0, 0, 7); ctx.fill();
});
ctx.globalAlpha = 1; ctx.restore();
const shade = ctx.createRadialGradient(c0.x - r * .22, c0.y - r * .24, r * .3, c0.x, c0.y, r * 1.05);
shade.addColorStop(0, "rgba(0,0,0,0)");
shade.addColorStop(.6, isNight() ? "rgba(10,20,50,.18)" : "rgba(60,100,160,.10)");
shade.addColorStop(1, isNight() ? "rgba(2,6,18,.55)" : "rgba(25,55,105,.35)");
ctx.fillStyle = shade; ctx.fillRect(c0.x - r, c0.y - r, r * 2, r * 2);
ctx.restore();
// 外壳环(自转弧段,暗示体积)
ctx.save(); ctx.strokeStyle = C.shell; ctx.lineWidth = Math.max(1, r * .02); ctx.globalAlpha = .85;
const spin2 = (vt * -.014) % (Math.PI * 2);
for (let k = 0; k < 4; k++) {
const a0 = spin2 + k * Math.PI / 2, a1 = a0 + Math.PI * .32;
ctx.beginPath(); ctx.arc(c0.x, c0.y, r * 1.06, a0, a1); ctx.stroke();
}
ctx.restore();
ctx.save(); ctx.strokeStyle = C.edge; ctx.globalAlpha = .5; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(c0.x, c0.y, r, 0, 7); ctx.stroke(); ctx.restore();
return { x: c0.x, y: c0.y, r };
}
/* ---------------- 数据集面片(幕5 数据集:六张卡片沿固定角度展开,半径 ≤0.62) ---------------- */
function drawDatasetCards(alpha, core, chipRects) {
if (alpha < .05) return;
const pubs = (state.data.datasets && state.data.datasets.publications) || [];
ctx.save(); ctx.globalAlpha = alpha;
for (let i = 0; i < 6; i++) {
const ang = (-90 + i * 60) * Math.PI / 180, cosA = Math.cos(ang), sinA = Math.sin(ang);
const rr = .40 + alpha * .22; // ≤0.62 世界半径,安全落在包络内;此处全程用屏幕坐标推导,避免坐标系混用
const from = { x: core.x + cosA * core.r, y: core.y + sinA * core.r };
const cardCenter = { x: core.x + cosA * rr * S, y: core.y + sinA * rr * S * .52 };
const cw = 118, ch = 46;
const cx2 = clamp(cardCenter.x, x0 + cw / 2, x1 - cw / 2), cy2 = clamp(cardCenter.y, y0 + ch / 2, y1 - ch / 2);
if (nearChip(cx2, cy2, chipRects)) continue; // 与卫星芯片重叠——整张卡片让位,宁缺勿遮挡主体
ctx.strokeStyle = C.orbitLine; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(cardCenter.x, cardCenter.y); ctx.stroke();
const ds = pubs[i];
ctx.fillStyle = isNight() ? "rgba(13,20,42,.72)" : "rgba(255,255,255,.82)";
ctx.strokeStyle = C.orbitLine; roundRect(cx2 - cw / 2, cy2 - ch / 2, cw, ch, 9); ctx.fill(); ctx.stroke();
ctx.fillStyle = C.ink; ctx.font = "600 12px \"Noto Sans SC\""; ctx.textAlign = "left";
ctx.fillText(ds ? esc(ds.dataset) : "—", cx2 - cw / 2 + 10, cy2 - 6);
ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\"";
ctx.fillText(ds ? `${ds.state} · ${ds.active_batch || ""}`.slice(0, 18) : "暂无数据", cx2 - cw / 2 + 10, cy2 + 12);
}
ctx.restore();
}
/* ---------------- 审计尾迹(幕6 审计:弧形时间轨,半径 ≤1.02) ---------------- */
function drawAuditTrail(alpha, chipRects) {
const items = ((state.data.audit && state.data.audit.items) || []).slice(0, 5);
if (alpha < .05 || !items.length) return;
ctx.save(); ctx.globalAlpha = alpha;
items.forEach((a, i) => {
const t = i / Math.max(1, items.length - 1);
const ang = (250 + t * 70) * Math.PI / 180, rr = .78 + t * .22; // 底部弧段,远离四颗卫星常驻扇区;≤1.0
const q = toScreen(Math.cos(ang) * rr, Math.sin(ang) * rr * .46);
const col = String(a.action).includes("rollback") ? C.red : C.link;
ctx.globalAlpha = alpha * (1 - t * .5);
glowDot(q.x, q.y, 3.6, col, .9, true);
if (!nearChip(q.x + 40, q.y, chipRects)) {
ctx.fillStyle = C.ink; ctx.font = "10.5px \"Noto Sans SC\""; ctx.textAlign = "left";
ctx.fillText(`${a.action} · ${a.target || ""}`.slice(0, 20), q.x + 10, q.y + 3);
ctx.fillStyle = C.dim; ctx.font = "9px \"DejaVu Sans Mono\"";
ctx.fillText(timeShort(a.created_at), q.x + 10, q.y + 14);
}
});
ctx.restore();
}
/* ---------------- 卫星几何:位置 / 径向锚定 14px / 安全盒硬钳制 ---------------- */
function satWorldPos(sat, vt, expand) {
const th = sat.phase + sat.dir * sat.speed * vt;
const orb = ORBITS[sat.orbit];
const a = orb.a * expand, b = orb.b * expand;
const x0 = a * Math.cos(th), y0 = -b * Math.sin(th);
const ct = Math.cos(ORBIT_TILT), st = Math.sin(ORBIT_TILT);
return { x: x0 * ct - y0 * st, y: x0 * st + y0 * ct, behind: Math.sin(th) > 0 };
}
function computeSatGeo(vt, expand, core) {
return SATS.map((sat) => {
const pos = satWorldPos(sat, vt, expand);
const raw = toScreen(pos.x, pos.y);
let dx = raw.x - CX, dy = raw.y - CY; const d = Math.hypot(dx, dy) || 1;
const ux = dx / d, uy = dy / d;
const anchor = { x: raw.x + ux * 14, y: raw.y + uy * 14 }; // 径向外移 14px 锚定
const chip = CHIP_SIZES[sizeTier];
const chipPos = { // 安全盒硬钳制
x: clamp(anchor.x, x0 + chip.w / 2, x1 - chip.w / 2),
y: clamp(anchor.y, y0 + chip.h / 2, y1 - chip.h / 2),
};
const connFrom = { x: chipPos.x - ux * (chip.w / 2 + 6), y: chipPos.y - uy * (chip.w / 2 + 6) };
const connTo = { x: core.x + ux * core.r, y: core.y + uy * core.r };
const mid = { x: (connFrom.x + connTo.x) / 2, y: (connFrom.y + connTo.y) / 2 };
const px = -uy, py = ux;
const side = (mid.x - CX) * px + (mid.y - CY) * py >= 0 ? 1 : -1;
const connCtrl = { x: mid.x + px * side * 22, y: mid.y + py * side * 22 };
return { sat, pos, raw, ux, uy, chipPos, connFrom, connCtrl, connTo, behind: pos.behind };
});
}
function drawSatConnector(g, alphaMul) {
ctx.save();
ctx.strokeStyle = C.sat[g.sat.id]; ctx.globalAlpha = .55 * alphaMul; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(g.connFrom.x, g.connFrom.y);
ctx.quadraticCurveTo(g.connCtrl.x, g.connCtrl.y, g.connTo.x, g.connTo.y); ctx.stroke();
ctx.restore();
for (let k = 0; k < 3; k++) {
const u = ((performance.now() - T0) * .00012 + k / 3) % 1;
const pt = quadPt(g.connFrom, g.connCtrl, g.connTo, u);
glowDot(pt.x, pt.y, 2.4, C.sat[g.sat.id], .85 * alphaMul, false);
}
}
function drawSatChip(g, alphaMul) {
const chip = CHIP_SIZES[sizeTier];
const x = g.chipPos.x - chip.w / 2, y = g.chipPos.y - chip.h / 2;
ctx.save(); ctx.globalAlpha = alphaMul;
ctx.fillStyle = isNight() ? "rgba(10,17,36,.82)" : "rgba(255,255,255,.86)";
ctx.strokeStyle = C.orbitLine; ctx.lineWidth = 1;
roundRect(x, y, chip.w, chip.h, 10); ctx.fill(); ctx.stroke();
ctx.fillStyle = C.sat[g.sat.id]; roundRect(x, y + 5, 3, chip.h - 10, 2); ctx.fill();
ctx.fillStyle = C.ink; ctx.font = "600 12px \"Noto Sans SC\""; ctx.textAlign = "left";
ctx.fillText(g.sat.label, x + 12, y + chip.h * .42);
ctx.fillStyle = C.dim; ctx.font = "10px \"Noto Sans SC\"";
ctx.fillText(g.sat.sub, x + 12, y + chip.h * .74);
ctx.restore();
}
/* ---------------- A2 双灯(L8:永远最顶层绘制,永不压暗,尺寸下限按档位) ----------------
无论上游几何如何推导锚点,这里再做一次安全盒硬钳制,确保双灯永不出屏。 */
function drawLampDock(rawX, rawY, linkState, actLevel, actKind) {
const lp = LAMP_SIZES[sizeTier];
const padX = 4, padY = 3, gap = 3;
const boxW = lp.w + padX * 2, boxH = lp.h * 2 + gap + padY * 2;
const x = clamp(rawX, x0 + boxW / 2, x1 - boxW / 2);
const y = clamp(rawY, y0 + boxH / 2, y1 - boxH / 2);
ctx.save();
ctx.fillStyle = C.dockBg; ctx.strokeStyle = C.dockLine; ctx.lineWidth = 1;
roundRect(x - boxW / 2, y - boxH / 2, boxW, boxH, Math.min(6, lp.h)); ctx.fill(); ctx.stroke();
const linkColor = linkState === "ok" ? C.link : linkState === "error" ? C.red : C.faint;
const linkAlpha = linkState === "ok" ? .6 : linkState === "error" ? .6 : .3;
ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor;
ctx.shadowBlur = linkState === "unconfigured" ? 0 : 6;
roundRect(x - lp.w / 2, y - boxH / 2 + padY, lp.w, lp.h, lp.h / 2); ctx.fill();
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
const actColor = actKind === "error" || actKind === "rollback" ? C.red : C.amber;
if (actLevel > 0) {
ctx.globalAlpha = .3 + .7 * actLevel; ctx.fillStyle = actColor; ctx.shadowColor = actColor; ctx.shadowBlur = 10 * actLevel;
} else { ctx.globalAlpha = .5; ctx.fillStyle = C.actOff; }
roundRect(x - lp.w / 2, y + boxH / 2 - padY - lp.h, lp.w, lp.h, lp.h / 2); ctx.fill();
ctx.restore();
}
/* ---------------- 去向端(发布 / 审计):固定连线 + 常流光点 + 右对齐内侧文字 ---------------- */
function goConn(core, worldPt) {
const d = Math.hypot(worldPt.x, worldPt.y) || 1, ux = worldPt.x / d, uy = worldPt.y / d;
const from = { x: core.x + ux * core.r, y: core.y - uy * core.r };
const to = toScreen(worldPt.x, worldPt.y);
const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 - Math.abs(to.x - from.x) * .06 };
return { from, ctrl: mid, to };
}
function drawGoLine(conn, color, speed) {
ctx.save(); ctx.strokeStyle = color; ctx.globalAlpha = .6; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(conn.from.x, conn.from.y);
ctx.quadraticCurveTo(conn.ctrl.x, conn.ctrl.y, conn.to.x, conn.to.y); ctx.stroke();
ctx.restore();
for (let k = 0; k < 2; k++) {
const u = ((performance.now() - T0) * speed + k / 2) % 1;
const pt = quadPt(conn.from, conn.ctrl, conn.to, u);
glowDot(pt.x, pt.y, 2.2, color, .85, false);
}
}
function drawGoLabel(pt, text) {
ctx.save(); ctx.fillStyle = C.ink; ctx.globalAlpha = .85; ctx.font = "600 11px \"Noto Sans SC\""; ctx.textAlign = "right";
ctx.fillText(text, pt.x - 14, pt.y - 14); ctx.textAlign = "left"; ctx.restore();
}
/* ---------------- 事件脉冲(来源 ACT 起闪 → 星体接点 → TX/审计回应,因果接力) ---------------- */
const TRAVEL_IN = 900, TRAVEL_OUT = 750;
function drawPackets(satGeo, txConn, audConn) {
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 ? C.red : C.amber;
if (dt < TRAVEL_IN) {
const g = satGeo[pk.src]; if (!g) continue;
const pt = quadPt(g.connFrom, g.connCtrl, g.connTo, clamp(dt / TRAVEL_IN, 0, 1));
glowDot(pt.x, pt.y, 4, col, .9, true);
} else {
const conn = pk.rb ? audConn : txConn;
const pt = quadPt(conn.from, conn.ctrl, conn.to, clamp((dt - TRAVEL_IN) / TRAVEL_OUT, 0, 1));
glowDot(pt.x, pt.y, 4, pk.rb ? C.red : C.beam, .85, true);
}
}
}
/* ---------------- 尺寸 / 交互 ---------------- */
const publicApi = { satScreen: [], freshJobRuns: [], box: null };
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);
sizeTier = W <= 1024 ? 2 : (W <= 1280 ? 1 : 0);
cabinW = [300, 260, 230][sizeTier];
x0 = cabinW + 24; x1 = W - 24; y0 = 64; y1 = H - 118;
CX = (x0 + x1) / 2; CY = (y0 + y1) / 2;
S = Math.min((CX - x0) / FIT_X, (x1 - CX) / FIT_X, (CY - y0) / FIT_Y, (y1 - CY) / FIT_Y);
buildStarLayer();
// 供自测/排障读取的只读几何快照(不影响渲染逻辑,非“演示钩子”)
publicApi.box = { x0, x1, y0, y1, CX, CY, S, sizeTier, cabinW };
}
function onCanvasClick(e) {
const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
let hit = false, bd = 44 * 44;
(publicApi.satScreen || []).forEach((q) => { if (!q) return; const d = (q.x - x) ** 2 + (q.y - y) ** 2; if (d < bd) hit = true; });
const coreHit = Math.hypot(x - CX, y - CY) < STAR_R * S * 1.4;
if (hit || coreHit) Detail.open(SCENES[state.scene].key);
else Detail.close();
}
function positionDetail(idx) {
const el = $("detail");
const anchor = idx === 1 && publicApi.satScreen.length ? publicApi.satScreen[0] : null;
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(x0 + 20, W / 2 - 180) + "px";
el.style.top = "20px";
}
}
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();
}
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((rr) => `
${esc(rr[0])}${esc(rr[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();
});
function render() {
if (!canvas) return;
const vtMs = performance.now() - T0, vt = vtMs / 1000;
const max = Math.max(1, document.body.scrollHeight - innerHeight);
const p = clamp((scrollY / max) * 5, 0, 5);
const K = chapterAt(p);
const nowMs = performance.now();
drawBackground(vt);
const expand = 1 + K.ring * RING_EXPAND_MAX;
const starCenter = toScreen(0, 0);
const core = { x: starCenter.x, y: starCenter.y, r: STAR_R * S };
const satGeo = computeSatGeo(vt, expand, core);
// 卫星芯片当前屏幕矩形:供任务环标签/审计尾迹/数据集卡片避让,防止遮挡卫星(次要装饰永远让位于主体)
const chipRects = satGeo.map((g) => ({ x: g.chipPos.x, y: g.chipPos.y, w: CHIP_SIZES[sizeTier].w + 16, h: CHIP_SIZES[sizeTier].h + 16 }));
drawOrbit(ORBITS.inner.a * expand, ORBITS.inner.b * expand, .55);
drawOrbit(ORBITS.outer.a * expand, ORBITS.outer.b * expand, .55);
if (K.task > .04) drawTaskRing(K.task, expand, chipRects);
if (K.trail > .04) drawAuditTrail(K.trail, chipRects);
satGeo.filter((g) => g.behind).forEach((g) => { drawSatConnector(g, .55); });
const starCore = drawStar(vt, K);
satGeo.filter((g) => !g.behind).forEach((g) => { drawSatConnector(g, 1); });
const txConn = goConn(starCore, TX_WORLD), audConn = goConn(starCore, AUD_WORLD);
drawGoLine(txConn, C.beam, .00011);
drawGoLine(audConn, C.link, .00009);
if (K.unfold > .04) drawDatasetCards(K.unfold, starCore, chipRects);
drawPackets(satGeo, txConn, audConn);
// L8:卫星芯片(可变暗)+ 全部双灯(永不变暗,永远最顶层)
satGeo.forEach((g) => drawSatChip(g, g.behind ? .72 : 1));
const chipSz = CHIP_SIZES[sizeTier], lampSz = LAMP_SIZES[sizeTier];
publicApi.satScreen = satGeo.map((g) => ({
id: g.sat.id, x: g.chipPos.x, y: g.chipPos.y, w: chipSz.w, h: chipSz.h,
lampY: g.chipPos.y + chipSz.h / 2 + 12, lampW: lampSz.w + 8, lampH: lampSz.h * 2 + 9,
}));
satGeo.forEach((g, i) => {
const provider = FLOW_PROVIDERS[i];
const lamp = Lamps[provider];
const lampPt = { x: g.chipPos.x, y: g.chipPos.y + chipSz.h / 2 + 12 };
drawLampDock(lampPt.x, lampPt.y, lamp.link, lamp.level(nowMs), lamp.activeKind(nowMs));
});
const hubDock = { x: starCore.x - starCore.r * .5, y: starCore.y - starCore.r * .46 };
drawLampDock(hubDock.x, hubDock.y, "ok", Lamps.junction.level(nowMs), Lamps.junction.activeKind(nowMs));
drawLampDock(txConn.to.x, txConn.to.y + 14, "ok", Lamps.tx.level(nowMs), Lamps.tx.activeKind(nowMs));
drawLampDock(audConn.to.x, audConn.to.y + 14, "ok", Lamps.audit.level(nowMs), Lamps.audit.activeKind(nowMs));
// L9:去向端文字(右对齐、内侧),不与双灯共享像素范围
drawGoLabel(txConn.to, "盘后发布");
drawGoLabel(audConn.to, "审计留痕");
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);
}
publicApi.init = function init() {
canvas = $("scene"); ctx = canvas.getContext("2d");
initPalette();
addEventListener("resize", resize);
resize();
buildCabinShell();
canvas.addEventListener("click", onCanvasClick);
$("detailClose").addEventListener("click", () => Detail.close());
// 相机不再自由环绕,六幕镜头焦点由 chapterAt(scrollY 派生的 p) 每帧直接读取,
// 天然免疫快速/反向滚动排队问题,无需额外监听 scroll 事件。
};
publicApi.onThemeChange = function onThemeChange() { initPalette(); };
publicApi.start = function start() { if (!running) { running = true; scheduleNav(); loop(); } };
publicApi.resume = function resume() { this.start(); };
publicApi.pause = function pause() { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = null; };
publicApi.stop = function stop() { this.pause(); };
publicApi.scrollToScene = function scrollToScene(idx) {
const track = $("track");
const secH = track.offsetHeight / 6;
scrollTo({ top: track.offsetTop + idx * secH, behavior: "smooth" });
};
publicApi.renderDetail = function renderDetail(idx) {
const body = SCENE_DETAIL[idx]();
$("detailBody").innerHTML = body;
if (SCENE_BIND[idx]) SCENE_BIND[idx]($("detailBody"));
positionDetail(idx);
$("detail").classList.remove("closed");
};
publicApi.closeDetail = function closeDetail() { $("detail").classList.add("closed"); };
return publicApi;
})();
/* ==========================================================================
减少动态效果版:六幕静态区块,正常滚动,无位移/旋转/闪烁,信息与功能等价。
========================================================================== */
const StaticShell = (() => {
let mounted = false;
function drawStaticDiagram(canvasEl) {
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);
const r = Math.min(W, H) * .16;
// 星体
ctx.fillStyle = night ? "rgba(140,190,255,.4)" : "rgba(47,99,214,.35)";
ctx.beginPath(); ctx.arc(0, 0, r, 0, 7); ctx.fill();
ctx.strokeStyle = night ? "rgba(140,190,255,.6)" : "rgba(47,99,214,.55)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, r, 0, 7); ctx.stroke();
// 两层静态轨道 + 四个固定卫星点(静态,不闪烁不移动)
const colors = night ? ["#96CDFF", "#78B4FF", "#B9AFFF", "#FFD6A0"] : ["#2F63D6", "#0A7C92", "#6D5DD6", "#BE780A"];
[[r * 1.9, r * 1.0], [r * 2.6, r * 1.35]].forEach(([a, b]) => {
ctx.strokeStyle = night ? "rgba(140,180,255,.28)" : "rgba(90,120,180,.35)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(0, 0, a, b, -0.2, 0, 7); ctx.stroke();
});
const angles = [200, 20, 140, 320];
angles.forEach((deg, i) => {
const orbit = i < 2 ? [r * 1.9, r * 1.0] : [r * 2.6, r * 1.35];
const a = (deg * Math.PI) / 180;
const x = orbit[0] * Math.cos(a), y = -orbit[1] * Math.sin(a);
const rx = x * Math.cos(-0.2) - y * Math.sin(-0.2), ry = x * Math.sin(-0.2) + y * Math.cos(-0.2);
ctx.strokeStyle = colors[i]; ctx.globalAlpha = .7; ctx.lineWidth = 1.4;
ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(rx * .35, ry * .35 - r * .05); ctx.stroke();
ctx.fillStyle = colors[i]; ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(rx, ry, 4, 0, 7); ctx.fill();
});
ctx.restore();
}
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}`)); 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();