Files
xiaobai-review/xiaobai-datahub/admin/app.js
T
fab9a70451 feat(HEL-539): 数据中枢 admin 按第八版「星港中枢」重做星体中枢
- styles.css:舞台改为全幅星空(100vw × 100vh-52px,去卡片边框/圆角/外边距),
  cabin 定位随安全盒 x0 同步(24px),响应式断点对齐 1024;清理未使用/格式错误
  的旧版画布配色变量(--face/--edge/--slab/--ring/--chan1-4),新增星港中枢
  文档留存 token(--star-*/--orbit-line/--sat1-4,与 app.js PAL 对应)。
- app.js:Stage/StaticShell 模块整体重写为 Canvas 2D 自写透视投影的星体+双轨+
  四卫星连续舞台(不用 CSS 3D/SVG/新依赖);安全盒/缩放严格按 HEL-538 复审公式
  x0=cabinW+24,x1=W-24,y0=64,y1=H-118 与 S=min(...) 计算,1440/1280/1024 三档
  构造性不裁切;卫星径向锚定 14px + 安全盒硬钳制,双灯/去向端文字同样钳制且
  双灯永不隐藏;六幕改为固定投影 + 内容层参数(不再用自由 3D 摄像机),任务环/
  数据集卡片/审计尾迹增加与卫星芯片的避让逻辑防遮挡;A2 灯引擎/真实事件轮询/
  reduced-motion 监听/后台暂停逻辑保持原样复用,未引入 demo 钩子。
- 卫星轨道配对采用 HEL-537 已确认样图口径(tushare/eastmoney 内轨,
  tencent/ifind 外轨),逐源相位/角速度/方向沿用 HEL-536 §3 数值,已在评论中
  向总管说明与 HEL-536 文字表述的差异。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-13 11:58:32 +08:00

1440 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/* ==========================================================================
xiaobai-datahub 管理后台 · 第七版「轨道机芯」
一台持续运转的数据空间:滚动推镜、立体核心换面、四路真实来源持续流动、
A2 路由器式 LINK/ACT 信号灯——全部由真实接口数据驱动,没有任何模拟流量。
只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测
(每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件",
因此只有这四路会闪 ACTths/xgb/akshare 是预留源,永远不闪。
========================================================================== */
/* ---------------------------------------------------------------- 基础工具 */
function $(id) { return document.getElementById(id); }
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
}
function timeShort(value) {
const s = String(value ?? "");
const m = s.match(/(\d{2}:\d{2}:\d{2})/);
return m ? m[1] : s.replace("T", " ").slice(0, 16);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
const state = {
csrf: "",
scene: 0,
releaseDate: "",
reduced: false,
visible: document.visibilityState === "visible",
online: navigator.onLine,
data: { overview: null, sources: null, jobs: null, batches: null, datasets: null, audit: null },
};
async function api(path, options = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {});
if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf;
const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" }));
const body = await res.json();
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
throw new Error(msg);
}
return body;
}
function show(id) {
["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
}
function table(headers, rows) {
const thead = headers.map((h) => `<th>${esc(h)}</th>`).join("");
const body = rows.length
? rows.map((cols) => `<tr>${cols.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")
: `<tr><td colspan="${headers.length}">暂无数据</td></tr>`;
return `<table><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
}
/* ---------------------------------------------------------------- 登录 / 会话 */
async function boot() {
try {
const session = await api("/admin/api/session");
state.csrf = session.csrf;
$("who").textContent = session.username;
if (session.must_change) { show("change-view"); return; }
show("shell");
enterShell();
} catch {
show("login-view");
}
}
$("login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
$("login-error").hidden = true;
try {
const result = await api("/admin/api/login", {
method: "POST",
body: JSON.stringify({ username: form.get("username"), password: form.get("password") }),
});
state.csrf = result.csrf;
if (result.must_change) show("change-view");
else { show("shell"); enterShell(); }
} catch (err) {
$("login-error").hidden = false;
$("login-error").textContent = err.message;
}
});
$("change-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
try {
await api("/admin/api/change-password", {
method: "POST",
body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }),
});
show("shell");
enterShell();
} catch (err) {
$("change-error").hidden = false;
$("change-error").textContent = err.message;
}
});
$("logout-btn").addEventListener("click", async () => {
await api("/admin/api/logout", { method: "POST", body: "{}" });
Poller.stopAll();
Stage.stop();
show("login-view");
});
/* ---------------------------------------------------------------- 主题(日/夜) */
function applyTheme(theme) {
const root = document.documentElement;
if (theme === "night") root.setAttribute("data-theme", "night");
else root.removeAttribute("data-theme");
$("theme-btn").textContent = theme === "night" ? "日间" : "夜间";
try { localStorage.setItem("hub_theme", theme); } catch { /* ignore storage errors */ }
Stage.onThemeChange(theme);
}
$("theme-btn").addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day";
applyTheme(current === "night" ? "day" : "night");
});
/* ---------------------------------------------------------------- 减少动态效果 & 可见性 & 网络 */
const REDUCE_MQ = matchMedia("(prefers-reduced-motion: reduce)");
function applyReduced(reduced) {
state.reduced = reduced;
document.documentElement.classList.toggle("reduced", reduced);
if (reduced) { Stage.stop(); StaticShell.mount(); }
else { StaticShell.unmount(); Stage.start(); }
}
REDUCE_MQ.addEventListener("change", (e) => applyReduced(e.matches));
document.addEventListener("visibilitychange", () => {
state.visible = document.visibilityState === "visible";
onRuntimeAvailabilityChange();
});
window.addEventListener("online", () => { state.online = true; onRuntimeAvailabilityChange(); });
window.addEventListener("offline", () => { state.online = false; onRuntimeAvailabilityChange(); });
function runtimeAvailable() { return state.visible && state.online; }
function onRuntimeAvailabilityChange() {
if (!$("shell") || $("shell").hidden) return;
if (runtimeAvailable()) {
// 恢复:不补播旧事件——所有通道先静默重建基线,再继续真实轮询
Poller.resume();
if (!state.reduced) Stage.resume();
} else {
// 隐藏/切页/断网:取消未播闪簇、清空队列、停止循环
Poller.pause();
Lamps.clearAll();
if (!state.reduced) Stage.pause();
}
updateCrumbStatus();
}
function updateCrumbStatus() {
const el = $("crumb");
if (!el) return;
if (!state.online) { el.textContent = "网络已断开 · 已暂停实时"; return; }
if (!state.visible) { el.textContent = "已切至后台 · 已暂停动效"; return; }
el.textContent = SCENES[state.scene] ? `8766 · ${SCENES[state.scene].label} · 持续运转` : "8766 · 四源汇流 · 持续运转";
}
/* ==========================================================================
A2 信号灯引擎:只有真实事件才能点亮 ACT。
- 单一来源的一簇事件最多折叠为 3 次短闪。
- 全站所有通道共享一个节流阀:任意 1 秒窗口内新起的闪烁簇不超过 3 个,
各来源互不同步(各自独立的小抖动),不做全局统一节拍。
========================================================================== */
const FLASH_ON = 90, FLASH_GAP = 160, MAX_CLUSTER = 3;
const GLOBAL_WINDOW = 1000, GLOBAL_CAP = 3;
const globalFlashStarts = [];
function reserveGlobalSlot(tNow) {
while (globalFlashStarts.length && tNow - globalFlashStarts[0] > GLOBAL_WINDOW) globalFlashStarts.shift();
if (globalFlashStarts.length < GLOBAL_CAP) { globalFlashStarts.push(tNow); return tNow; }
const wait = (globalFlashStarts[0] + GLOBAL_WINDOW - tNow) + 40 + Math.random() * 120;
const start = tNow + Math.max(30, wait);
globalFlashStarts.push(start);
globalFlashStarts.sort((a, b) => a - b);
return start;
}
class LampChannel {
constructor(id) { this.id = id; this.bursts = []; this.link = "unknown"; this.lastAt = 0; }
push(n, kind) {
const count = clamp(Math.round(n) || 1, 1, MAX_CLUSTER);
const start = reserveGlobalSlot(performance.now());
this.bursts.push({ start, n: count, kind: kind || "ok" });
this.lastAt = Date.now();
}
clear() { this.bursts.length = 0; }
level(tMs) {
for (let i = this.bursts.length - 1; i >= 0; i--) {
const b = this.bursts[i];
const dur = (b.n - 1) * FLASH_GAP + FLASH_ON;
const dt = tMs - b.start;
if (dt < 0) continue;
if (dt <= dur) { const ph = dt % FLASH_GAP; return ph < FLASH_ON ? 1 : 0.1; }
if (dt <= dur + 260) return 0.22 * (1 - (dt - dur) / 260);
}
return 0;
}
activeKind(tMs) {
for (let i = this.bursts.length - 1; i >= 0; i--) {
const b = this.bursts[i];
const dur = (b.n - 1) * FLASH_GAP + FLASH_ON + 260;
if (tMs - b.start <= dur) return b.kind;
}
return null;
}
prune(tMs) { this.bursts = this.bursts.filter((b) => tMs - b.start < (b.n - 1) * FLASH_GAP + FLASH_ON + 400); }
}
const Lamps = {
tushare: new LampChannel("tushare"),
eastmoney: new LampChannel("eastmoney"),
tencent: new LampChannel("tencent"),
ifind: new LampChannel("ifind"),
junction: new LampChannel("junction"),
tx: new LampChannel("tx"),
audit: new LampChannel("audit"),
clearAll() { Object.values(this).forEach((c) => { if (c instanceof LampChannel) c.clear(); }); globalFlashStarts.length = 0; },
};
const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"];
const FLOW_LABEL = {
tushare: "tushare · 官方盘后", eastmoney: "eastmoney · 盘中观察",
tencent: "tencent · 盘中观察", ifind: "ifind · 授权实时",
};
/* 事件脉冲:来源 ACT 起闪 → 沿流道 → 核心接点 → TX;只在真实事件时入队 */
const PACKETS = [];
function emitPacket(providerIdx, kindRollback) {
PACKETS.push({ src: providerIdx, t0: performance.now(), rb: !!kindRollback });
if (PACKETS.length > 40) PACKETS.splice(0, PACKETS.length - 40); // 硬上限,防止无限增长
}
function fireSource(provider, n, kind) {
const idx = FLOW_PROVIDERS.indexOf(provider);
if (idx < 0) return;
Lamps[provider].push(n, kind);
emitPacket(idx, kind === "rollback");
setTimeout(() => Lamps.junction.push(1, kind), 900);
setTimeout(() => Lamps.tx.push(1, kind), 1650);
}
/* ==========================================================================
数据轮询 + 真实事件识别(只认已经发生的事实,不臆造)
========================================================================== */
const Seen = {
callsMax: -1, jobRunsMax: -1, auditMax: -1,
batchState: new Map(), pubPublishedAt: new Map(),
firstOverview: true, firstJobs: true, firstAudit: true, firstBatches: true,
};
async function pollOverview() {
const data = await api("/admin/api/overview");
const wasFirst = Seen.firstOverview;
const calls = data.recent_calls || [];
let maxId = Seen.callsMax;
const fresh = [];
for (const c of calls) { if (c.id > Seen.callsMax) fresh.push(c); if (c.id > maxId) maxId = c.id; }
Seen.callsMax = maxId;
Seen.firstOverview = false;
if (!wasFirst && fresh.length) fireSource("tushare", fresh.length, fresh.some((c) => !c.ok) ? "error" : "ok");
state.data.overview = data;
Views.refresh();
}
async function pollSources() {
const data = await api("/admin/api/sources");
for (const item of data.items) {
if (!FLOW_PROVIDERS.includes(item.provider)) continue;
const health = item.health || {};
const link = health.state === "ok" || health.state === "empty" ? "ok" : (health.state === "unconfigured" ? "unconfigured" : "error");
Lamps[item.provider].link = link;
// 每一次 /admin/api/sources 请求都会对该源做一次真实探测(现网行为),
// 探测本身完成即是一次真实事件;未配置的源不闪(没有发生过真实调用)。
if (item.provider !== "tushare" && link !== "unconfigured") {
Lamps[item.provider].push(1, link === "ok" ? "ok" : "error");
emitPacket(FLOW_PROVIDERS.indexOf(item.provider), false);
setTimeout(() => Lamps.junction.push(1, link), 700);
}
}
state.data.sources = data;
Views.refresh();
}
async function pollJobs() {
const data = await api("/admin/api/jobs");
const wasFirst = Seen.firstJobs;
let maxId = Seen.jobRunsMax;
const fresh = [];
for (const r of data.runs || []) { if (r.id > Seen.jobRunsMax) fresh.push(r); if (r.id > maxId) maxId = r.id; }
Seen.jobRunsMax = maxId;
Seen.firstJobs = false;
if (!wasFirst && fresh.length) {
const anyFail = fresh.some((r) => r.state === "failed");
Lamps.junction.push(fresh.length, anyFail ? "error" : "ok");
}
state.data.jobs = data;
Stage.freshJobRuns = fresh;
Views.refresh();
}
async function pollBatches() {
const date = state.releaseDate || "";
const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`);
const wasFirst = Seen.firstBatches;
let changed = false;
for (const b of data.batches || []) {
const prev = Seen.batchState.get(b.batch_id);
if (prev !== b.state) { changed = true; Seen.batchState.set(b.batch_id, b.state); }
}
let published = false;
for (const p of data.publications || []) {
const key = `${p.dataset}:${p.trade_date}`;
const prev = Seen.pubPublishedAt.get(key);
if (prev !== p.published_at) { published = true; Seen.pubPublishedAt.set(key, p.published_at); }
}
Seen.firstBatches = false;
if (!wasFirst && published) Lamps.tx.push(1, "pub");
else if (!wasFirst && changed) Lamps.junction.push(1, "ok");
state.data.batches = data;
Views.refresh();
}
async function pollDatasets() {
const date = "";
const data = await api(`/admin/api/datasets?date=${encodeURIComponent(date)}`);
state.data.datasets = data;
Views.refresh();
}
async function pollAudit() {
const data = await api("/admin/api/audit");
const wasFirst = Seen.firstAudit;
let maxId = Seen.auditMax;
const fresh = [];
for (const a of data.items || []) { if (a.id > Seen.auditMax) fresh.push(a); if (a.id > maxId) maxId = a.id; }
Seen.auditMax = maxId;
Seen.firstAudit = false;
if (!wasFirst && fresh.length) {
const rollback = fresh.some((a) => String(a.action).includes("rollback"));
Lamps.audit.push(fresh.length, rollback ? "rollback" : "ok");
}
state.data.audit = data;
Views.refresh();
}
const Poller = (() => {
const specs = [
{ key: "overview", fn: pollOverview, every: 20000 },
{ key: "sources", fn: pollSources, every: 45000 },
{ key: "jobs", fn: pollJobs, every: 25000 },
{ key: "batches", fn: pollBatches, every: 25000 },
{ key: "datasets", fn: pollDatasets, every: 60000 },
{ key: "audit", fn: pollAudit, every: 20000 },
];
const timers = new Map();
function tickOne(spec) {
spec.fn().catch((err) => console.error(`[hub] poll ${spec.key} failed`, err));
}
function schedule(spec) {
clearTimer(spec.key);
const t = setInterval(() => { if (runtimeAvailable()) tickOne(spec); }, spec.every);
timers.set(spec.key, t);
}
function clearTimer(key) { if (timers.has(key)) { clearInterval(timers.get(key)); timers.delete(key); } }
function startAll() {
specs.forEach((spec, i) => { setTimeout(() => { tickOne(spec); schedule(spec); }, i * 160); });
}
function stopAll() { specs.forEach((s) => clearTimer(s.key)); }
function pause() { stopAll(); }
function resume() {
// 恢复时先静默重建基线(不补播旧事件),随后正常轮询
specs.forEach((spec) => { tickOne(spec); schedule(spec); });
}
return { startAll, stopAll, pause, resume };
})();
/* ==========================================================================
六幕场景内容:cabin(控制舱摘要)与 detail(功能抽屉)共用同一份真实数据渲染,
动效版与「减少动态效果」静态版都调用这里——保证信息与功能完全一致。
========================================================================== */
const SCENES = [
{ key: "overview", label: "总览" },
{ key: "sources", label: "数据源" },
{ key: "jobs", label: "调度任务" },
{ key: "release", label: "盘后发布" },
{ key: "datasets", label: "数据集" },
{ key: "audit", label: "审计" },
];
const EOD_LABELS = {
pending_first_attempt: "等待首次尝试", waiting_upstream: "等待上游", done: "已成功",
cutoff_failed: "已截止失败", closed_day: "休市",
};
const REV_LABELS = {
waiting_review: "等待复核", review_failed: "复核失败", aligned: "已追平",
cutoff: "已截止", pending_publish: "待发布", closed_day: "休市",
};
const PHASE_LABELS = { pre: "盘前", intraday: "盘中", lunch: "午间", eod: "盘后", closed: "休市" };
function todayYmd() {
const d = new Date(); const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
if (!state.releaseDate) state.releaseDate = todayYmd();
const Views = {
listeners: [],
onChange(fn) { this.listeners.push(fn); },
refresh() { this.listeners.forEach((fn) => { try { fn(); } catch (e) { console.error(e); } }); },
};
/* ---- 0 总览 ---- */
function cabinOverview() {
const d = state.data.overview;
if (!d) return { eyebrow: "SCENE 01 / 总览", title: "四源汇流 · 持续运转", sub: "正在加载真实状态…", rows: [], actions: [] };
const eod = d.eod_status || {}, rev = d.revision_status || {};
return {
eyebrow: "SCENE 01 / 总览",
title: "四源汇流 · 持续运转",
sub: `${d.trade_date} · ${PHASE_LABELS[d.session_phase] || d.session_phase}。灯闪一次,就是一次真实调用或探测。`,
rows: [
["今日发布", `${d.publications.length}`, "ok"],
["盘后补跑", EOD_LABELS[eod.state] || eod.state || "-", eod.state === "cutoff_failed" ? "fail" : "ok"],
["估值复核", REV_LABELS[rev.state] || rev.state || "-", rev.state === "review_failed" ? "fail" : "ok"],
["异常批次", `${d.anomalies.length}`, d.anomalies.length ? "fail" : "ok"],
],
actions: [{ label: "进入数据源 →", cls: "pri", action: () => Nav.go(1) }, { label: "查看最近调用", cls: "", action: () => Detail.open("overview") }],
};
}
function detailOverview() {
const d = state.data.overview;
if (!d) return `<h3>总览</h3><div class="dsub">正在加载…</div>`;
const eod = d.eod_status || {}, rev = d.revision_status || {};
const eodExtra = [];
if (eod.state === "waiting_upstream") {
eodExtra.push(`已试 ${eod.attempts} 次`);
if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(timeShort(eod.next_retry_at))}`);
if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`);
}
if (eod.state === "cutoff_failed" && eod.missing_datasets) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`);
const revExtra = [];
if (rev.detail) revExtra.push(esc(String(rev.detail)));
if (rev.window) revExtra.push(esc(String(rev.window)));
return `
<h3>总览 · ${esc(d.trade_date)}</h3>
<div class="dsub">${esc(PHASE_LABELS[d.session_phase] || d.session_phase)} · 源共 ${d.source_count}</div>
<div class="cards">
<div class="card"><div class="muted">交易日</div><strong>${esc(d.trade_date)}</strong></div>
<div class="card"><div class="muted">阶段</div><strong>${esc(PHASE_LABELS[d.session_phase] || d.session_phase)}</strong></div>
<div class="card"><div class="muted">今日发布</div><strong>${d.publications.length}</strong></div>
<div class="card"><div class="muted">盘后补跑</div><strong>${esc(EOD_LABELS[eod.state] || eod.state || "-")}</strong><div class="muted">${eodExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">估值复核</div><strong>${esc(REV_LABELS[rev.state] || rev.state || "-")}</strong><div class="muted">${revExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">异常批次</div><strong class="${d.anomalies.length ? "fail" : "ok"}">${d.anomalies.length}</strong></div>
</div>
<h3>最近调用(tushare</h3>
${table(["时间", "源", "端点", "结果", "耗时"], d.recent_calls.map((row) => [
esc(timeShort(row.created_at)), esc(row.provider), esc(row.endpoint),
row.ok ? '<span class="ok">成功</span>' : `<span class="fail">${esc(row.error)}</span>`,
`${row.latency_ms ?? "-"} ms`,
]))}
`;
}
/* ---- 1 数据源 ---- */
function cabinSources() {
const d = state.data.sources;
if (!d) return { eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的", sub: "正在加载真实状态…", rows: [], actions: [] };
const live = d.items.filter((it) => FLOW_PROVIDERS.includes(it.provider));
const reserved = d.items.filter((it) => !FLOW_PROVIDERS.includes(it.provider));
const rows = live.map((it) => {
const h = it.health || {};
const okish = h.state === "ok" || h.state === "empty";
return [FLOW_LABEL[it.provider] || it.provider, `${h.latency_ms ?? "-"} ms · ${esc(h.state || "-")}`, okish ? "ok" : "warn"];
});
rows.push([`预留源 ${reserved.length} 个`, "未接入 · 无真实调用", "warn"]);
return {
eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的",
sub: "点击任一端口双灯,详情从它的空间位置展开。灯闪代表真实探测或调用刚发生。",
rows,
actions: [{ label: "查看全部来源", cls: "pri", action: () => Detail.open("sources") }],
};
}
function detailSources() {
const d = state.data.sources;
if (!d) return `<h3>数据源</h3><div class="dsub">正在加载…</div>`;
const html = `<h3>数据源</h3><div class="dsub">LINK 常亮表示健康;ACT 只在真实探测/调用发生时短闪</div>` +
table(["源", "角色", "状态", "凭据", "操作"], d.items.map((item) => {
const cred = item.credential || {};
const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置";
return [
esc(item.provider), esc(item.role),
esc((item.health && (item.health.state || item.health.status)) || "-"),
credText,
`<button class="btn ghost" data-probe="${esc(item.provider)}">探测一次</button>`,
];
}));
return html;
}
function bindSourcesDetail(root) {
root.querySelectorAll("[data-probe]").forEach((btn) => {
btn.addEventListener("click", async () => {
const provider = btn.dataset.probe;
btn.disabled = true;
try {
const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" });
if (FLOW_PROVIDERS.includes(provider)) {
const ok = result.state === "ok" || result.state === "empty";
fireSource(provider, 1, ok ? "ok" : "error");
}
alert(JSON.stringify(result));
await pollSources();
Detail.reopen();
} finally { btn.disabled = false; }
});
});
}
/* ---- 2 调度任务 ---- */
function cabinJobs() {
const d = state.data.jobs;
if (!d) return { eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力", sub: "正在加载真实状态…", rows: [], actions: [] };
const runs = d.runs || [];
const latest = runs[0];
const failedRecent = runs.slice(0, 20).filter((r) => r.state === "failed").length;
return {
eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力",
sub: "环上的每个节点对应一个真实定时任务;节点接力亮起来自最近一次真实运行。",
rows: [
["最近运行", latest ? `${esc(latest.job_id)} · ${esc(latest.state)}` : "暂无", latest && latest.state === "failed" ? "fail" : "ok"],
["最近 20 次失败", `${failedRecent}`, failedRecent ? "fail" : "ok"],
["任务总数", `${d.jobs.length}`, "ok"],
],
actions: [{ label: "查看调度日志", cls: "pri", action: () => Detail.open("jobs") }],
};
}
function detailJobs() {
const d = state.data.jobs;
if (!d) return `<h3>调度任务</h3><div class="dsub">正在加载…</div>`;
return `
<h3>调度任务</h3>
${table(["任务", "时刻", "操作"], d.jobs.map((job) => [
`${esc(job.id)} · ${esc(job.title)}`, esc(job.at),
`<button class="btn ghost" data-run="${esc(job.id)}">手动触发</button>`,
]))}
<h3>最近运行</h3>
${table(["ID", "任务", "状态", "开始", "结束", "错误"], d.runs.map((row) => [
row.id, esc(row.job_id),
`<span class="${row.state === "failed" ? "fail" : row.state === "running" ? "warn" : "ok"}">${esc(row.state)}</span>`,
esc(timeShort(row.started_at)), esc(timeShort(row.finished_at)), esc(row.error || ""),
]))}
`;
}
function bindJobsDetail(root) {
root.querySelectorAll("[data-run]").forEach((btn) => {
btn.addEventListener("click", async () => {
const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || "";
btn.disabled = true;
try {
await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) });
await pollJobs();
Detail.reopen();
} finally { btn.disabled = false; }
});
});
}
/* ---- 3 盘后发布 ---- */
function cabinRelease() {
const d = state.data.batches;
if (!d) return { eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布", sub: "正在加载真实状态…", rows: [], actions: [] };
const pubs = d.publications || [];
const rollbacks = pubs.filter((p) => p.prev_batch).length;
const failedBatches = (d.batches || []).filter((b) => b.state === "failed").length;
return {
eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布",
sub: `${d.trade_date} · RX 批次穿过三层,TX 给出回执;校验失败走红色回滚分叉。`,
rows: [
["活跃发布", `${pubs.length} 个数据集`, "ok"],
["可回滚", `${rollbacks}`, rollbacks ? "warn" : "ok"],
["批次异常", `${failedBatches}`, failedBatches ? "fail" : "ok"],
],
actions: [{ label: "查看批次与发布", cls: "pri", action: () => Detail.open("release") }],
};
}
function detailRelease() {
const d = state.data.batches;
if (!d) return `<h3>盘后发布</h3><div class="dsub">正在加载…</div>`;
return `
<h3>盘后发布 ${esc(d.trade_date)}</h3>
<div class="toolbar">
<label>日期 <input id="rel-date" value="${esc(d.trade_date)}" /></label>
<button type="button" class="btn ghost" id="rel-load">查看</button>
<button type="button" class="btn warn" id="rel-backfill">补数</button>
</div>
<h3>当前映射</h3>
${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], d.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
row.prev_batch ? `<button class="btn danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
]))}
<h3>批次</h3>
${table(["batch_id", "数据集", "状态", "行数", "错误"], d.batches.map((row) => [
esc(row.batch_id), esc(row.dataset),
`<span class="${row.state === "failed" ? "fail" : "ok"}">${esc(row.state)}</span>`,
row.rows_out ?? "", esc(row.error || ""),
]))}
`;
}
function bindReleaseDetail(root) {
root.querySelector("#rel-load").addEventListener("click", async () => {
state.releaseDate = root.querySelector("#rel-date").value.trim();
await pollBatches();
Detail.reopen();
});
root.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill"));
root.querySelectorAll("[data-rollback]").forEach((btn) => {
btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback));
});
}
async function dangerous(kind, dataset) {
const date = state.releaseDate || "";
const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference", "daily");
if (!ds) return;
const password = prompt("二次确认:输入管理密码");
if (!password) return;
const confirmWord = `${ds}:${date}`;
const typed = prompt(`请输入确认词:${confirmWord}`);
if (!typed) return;
const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill";
try {
await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) });
if (kind === "rollback") Lamps.tx.push(1, "rollback");
await pollBatches();
await pollAudit();
Detail.reopen();
} catch (err) {
alert(err.message);
}
}
/* ---- 4 数据集 ---- */
function cabinDatasets() {
const d = state.data.datasets;
if (!d) return { eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间", sub: "正在加载真实状态…", rows: [], actions: [] };
const pubs = d.publications || [];
return {
eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间",
sub: `${d.trade_date} · 核心面片翻开成数据集,稳定显示日期与状态。`,
rows: pubs.slice(0, 4).map((p) => [esc(p.dataset), `${esc(p.state)} · ${esc(timeShort(p.published_at))}`, p.state === "published" ? "ok" : "warn"]),
actions: [{ label: "查看差异报告", cls: "pri", action: () => Detail.open("datasets") }],
};
}
function detailDatasets() {
const d = state.data.datasets;
if (!d) return `<h3>数据集</h3><div class="dsub">正在加载…</div>`;
return `
<h3>数据集 / 质量 ${esc(d.trade_date)}</h3>
${table(["数据集", "批次", "状态", "发布时间"], d.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at),
]))}
<h3>源间差异</h3>
${table(["指标", "左", "右", "偏差", "样本"], d.diff_reports.map((row) => [
esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "",
]))}
`;
}
/* ---- 5 审计 ---- */
function cabinAudit() {
const d = state.data.audit;
if (!d) return { eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕", sub: "正在加载真实状态…", rows: [], actions: [] };
const items = d.items || [];
const latest = items[0];
return {
eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕",
sub: "输出流在核心后方留下轨迹,发布、操作、回滚沿轨迹展开。",
rows: latest ? [[esc(latest.actor), `${esc(latest.action)} · ${esc(timeShort(latest.created_at))}`, String(latest.action).includes("rollback") ? "warn" : "ok"]] : [],
actions: [{ label: `查看审计(共 ${items.length} 条)`, cls: "pri", action: () => Detail.open("audit") }],
};
}
function detailAudit() {
const d = state.data.audit;
if (!d) return `<h3>审计</h3><div class="dsub">正在加载…</div>`;
return `<h3>审计</h3><div class="dsub">共 ${d.items.length} 条 · 最新在前</div>` +
table(["时间", "操作者", "动作", "对象", "详情"], d.items.map((row) => [
esc(timeShort(row.created_at)), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail),
]));
}
const SCENE_CABIN = [cabinOverview, cabinSources, cabinJobs, cabinRelease, cabinDatasets, cabinAudit];
const SCENE_DETAIL = [detailOverview, detailSources, detailJobs, detailRelease, detailDatasets, detailAudit];
const SCENE_BIND = [null, bindSourcesDetail, bindJobsDetail, bindReleaseDetail, null, null];
const Detail = {
openKey: null,
open(key) {
const idx = SCENES.findIndex((s) => s.key === key);
if (idx < 0) return;
this.openKey = key;
if (state.reduced) return; // 静态版直接内嵌显示,无需抽屉
Stage.renderDetail(idx);
},
reopen() { if (this.openKey && !state.reduced) this.open(this.openKey); },
close() { this.openKey = null; if (!state.reduced) Stage.closeDetail(); },
};
const Nav = {
go(idx) {
if (state.reduced) { StaticShell.scrollTo(idx); return; }
Stage.scrollToScene(idx);
},
};
/* ==========================================================================
canvas 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) => `<div class="stop" data-i="${i}"><i></i>${esc(s.label)}</div>`).join('<div class="sep"></div>');
scheduleNav();
}
let cabinIdx = -1;
function renderCabin(idx) {
const c = SCENE_CABIN[idx]();
const el = $("cabin");
el.innerHTML = `<div class="eyebrow">${esc(c.eyebrow)}</div><h2>${esc(c.title)}</h2><div class="sub">${esc(c.sub)}</div>
<div class="rows">${c.rows.map((rr) => `<div class="row"><span class="dotlamp${rr[2] === "fail" ? " fail" : rr[2] === "warn" ? " warn" : ""}"></span><span>${esc(rr[0])}</span><span class="fill"></span><b class="mono">${esc(rr[1])}</b></div>`).join("")}</div>
<div class="actions">${c.actions.map((a, i) => `<button class="btn ${esc(a.cls)}" data-act="${i}">${esc(a.label)}</button>`).join("")}</div>`;
el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; });
}
function syncUI(idx, p) {
if (idx !== cabinIdx) {
const el = $("cabin");
el.classList.add("hide");
setTimeout(() => { renderCabin(idx); el.classList.remove("hide"); }, 180);
cabinIdx = idx;
document.querySelectorAll("#rail .stop").forEach((el2, i) => el2.classList.toggle("on", i === idx));
$("hint").classList.toggle("off", p > .15);
$("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`;
$("phase").textContent = `${idx + 1} / ${SCENES.length}`;
state.scene = idx;
} else {
renderCabin(idx);
}
if (Detail.openKey) Detail.reopen();
}
Views.onChange(() => {
if (running && cabinIdx >= 0) renderCabin(cabinIdx);
if (Detail.openKey && !$("detail").classList.contains("closed")) Detail.reopen();
});
function render() {
if (!canvas) return;
const 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 `<span class="lampchip"><span class="lk${linkOn ? " on" : ""}"></span><span class="ac${recent ? " on recent" : ""}"></span>${esc(label)}
${lamp.lastAt ? `<span class="muted">· ACT ${Math.max(0, Math.round((Date.now() - lamp.lastAt) / 1000))}s 前</span>` : '<span class="muted">· 无最近事件</span>'}</span>`;
}
function sceneBlockHTML(idx) {
const s = SCENES[idx];
const cab = SCENE_CABIN[idx]();
return `
<section class="scene-static" id="static-${s.key}" data-scene="${idx}">
<div class="scene-head"><span class="eyebrow">${esc(cab.eyebrow)}</span><h2>${esc(cab.title)}</h2></div>
<canvas class="diagram" id="diagram-${idx}"></canvas>
<div class="scene-static-sub muted">${esc(cab.sub)}</div>
${idx === 1 ? `<div class="lamprow">${FLOW_PROVIDERS.map(lampChipHTML).join("")}</div>` : ""}
<div class="cards">${cab.rows.map((r) => `<div class="card"><div class="muted">${esc(r[0])}</div><strong class="${r[2] === "fail" ? "fail" : r[2] === "warn" ? "warn" : ""}">${esc(r[1])}</strong></div>`).join("")}</div>
<div id="static-detail-${idx}">${SCENE_DETAIL[idx]()}</div>
</section>`;
}
function rebindScene(idx) {
const root = $(`static-detail-${idx}`);
if (SCENE_BIND[idx]) SCENE_BIND[idx](root);
}
function rerenderScene(idx) {
const cab = SCENE_CABIN[idx]();
const section = document.querySelector(`.scene-static[data-scene="${idx}"]`);
if (!section) return;
const cardsEl = section.querySelector(".cards");
if (cardsEl) cardsEl.innerHTML = cab.rows.map((r) => `<div class="card"><div class="muted">${esc(r[0])}</div><strong class="${r[2] === "fail" ? "fail" : r[2] === "warn" ? "warn" : ""}">${esc(r[1])}</strong></div>`).join("");
if (idx === 1) { const lr = section.querySelector(".lamprow"); if (lr) lr.innerHTML = FLOW_PROVIDERS.map(lampChipHTML).join(""); }
const detailEl = $(`static-detail-${idx}`);
if (detailEl) { detailEl.innerHTML = SCENE_DETAIL[idx](); rebindScene(idx); }
}
function mount() {
if (mounted) return;
mounted = true;
$("staticNav").innerHTML = SCENES.map((s, i) => `<button data-i="${i}">${esc(s.label)}</button>`).join("");
$("staticNav").querySelectorAll("button").forEach((btn, i) => {
btn.addEventListener("click", () => { setActive(i); scrollToInternal(i); });
});
$("staticScenes").innerHTML = SCENES.map((_, i) => sceneBlockHTML(i)).join("");
SCENES.forEach((_, i) => { drawStaticDiagram($(`diagram-${i}`)); 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();