diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js
deleted file mode 100644
index 0f567d4..0000000
--- a/xiaobai-datahub/admin/app.js
+++ /dev/null
@@ -1,1341 +0,0 @@
-"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 3D 引擎(动效版)——移植自经白栖知确认的第七版原稿,
- 静态数据换成真实拉取结果,随机模拟事件全部替换为真实事件驱动。
- ========================================================================== */
-const Stage = (() => {
- let canvas, ctx, W = 0, H = 0, DPR = 1;
- let running = false, rafId = null;
- let PAL, C;
- const CAM = { yaw: 0, pitch: .12, dist: 5.4, cx: 0, cy: 0, fov: 1.9 };
- const T0 = performance.now();
- const now = () => performance.now();
-
- function initPalette() {
- PAL = {
- night: { bgA: "#05080F", bgB: "#0A1322", cyan: "#2FD8CE", blue: "#4A86FF", amber: "#FFB84D", red: "#FF6B6B",
- ink: "#E8EEFC", dim: "#9AA6C4", face: "rgba(58,96,180,", edge: "rgba(140,190,255,", slab: "rgba(47,216,206,", ring: "rgba(120,170,255,",
- chan: ["rgba(150,205,255,", "rgba(120,180,255,", "rgba(185,175,255,", "rgba(255,214,160,"] },
- day: { bgA: "#E8ECF5", bgB: "#F7F9FF", cyan: "#0A9C93", blue: "#2F63D6", amber: "#B97A0E", red: "#D64F4F",
- ink: "#1B2547", dim: "#5B6785", face: "rgba(120,160,235,", edge: "rgba(47,99,214,", slab: "rgba(10,156,147,", ring: "rgba(47,99,214,",
- chan: ["rgba(47,99,214,", "rgba(10,124,146,", "rgba(109,93,214,", "rgba(190,120,10,"] },
- };
- C = PAL[document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"];
- }
-
- const V = (x, y, z) => ({ x, y, z });
- function rotY(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x * c + p.z * s, p.y, -p.x * s + p.z * c); }
- function rotX(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x, p.y * c - p.z * s, p.y * s + p.z * c); }
- function bez(p0, p1, p2, p3, t) {
- const u = 1 - t;
- return V(
- u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
- u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
- u * u * u * p0.z + 3 * u * u * t * p1.z + 3 * u * t * t * p2.z + t * t * t * p3.z,
- );
- }
- function project(p) {
- let q = rotY(V(p.x - CAM.cx, p.y - CAM.cy, p.z), CAM.yaw);
- q = rotX(q, -CAM.pitch);
- const s = CAM.fov / Math.max(.3, CAM.dist - q.z);
- return { x: W / 2 + q.x * s * W * .5, y: H / 2 - q.y * s * W * .5, z: q.z, s };
- }
- function roundRect(x, y, w, h, r) {
- ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
- ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
- }
- const setAdd = () => { ctx.globalCompositeOperation = (C === PAL.night ? "lighter" : "source-over"); };
- function polyGlow(pts, color, alpha, width, dashPhase) {
- ctx.save(); setAdd();
- if (C !== PAL.night) alpha = Math.min(1, alpha * 1.5);
- for (let i = 0; i < pts.length - 1; i++) {
- const a = project(pts[i]), b = project(pts[i + 1]);
- const depth = Math.max(.15, Math.min(1, 1.35 - (a.z + 3) / 6));
- let al = alpha * depth;
- if (dashPhase !== null) al *= .35 + .65 * Math.max(0, Math.sin((i / pts.length) * 14 - dashPhase));
- ctx.strokeStyle = color + al.toFixed(3) + ")";
- ctx.lineWidth = width * (a.s * 2.2); ctx.lineCap = "round";
- ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
- }
- ctx.restore();
- }
- function dot3(p, r, color, alpha, glow) {
- const q = project(p);
- const depth = Math.max(.2, Math.min(1, 1.35 - (q.z + 3) / 6));
- const rr = r * q.s * 2.4;
- ctx.save(); setAdd();
- if (C !== PAL.night) alpha = Math.min(1, alpha * 1.35);
- if (glow) {
- const g = ctx.createRadialGradient(q.x, q.y, 0, q.x, q.y, rr * 3.2);
- g.addColorStop(0, color + (.5 * alpha * depth) + ")"); g.addColorStop(1, color + "0)");
- ctx.fillStyle = g; ctx.beginPath(); ctx.arc(q.x, q.y, rr * 3.2, 0, 7); ctx.fill();
- }
- ctx.fillStyle = color + (alpha * depth) + ")";
- ctx.beginPath(); ctx.arc(q.x, q.y, rr, 0, 7); ctx.fill();
- ctx.restore();
- return q;
- }
- /* A2 双灯:LINK 反映真实连通状态(常亮)+ ACT 事件驱动短闪 */
- function lamp3(p, level, scale, kind, linkState) {
- const q = project(p);
- const u = Math.max(.7, q.s * 2.2) * (scale || 1);
- const x = q.x, y = q.y, w = 13 * u, h = 5 * u, gap = 4 * u;
- ctx.save();
- ctx.globalAlpha = .9;
- ctx.fillStyle = C === PAL.night ? "rgba(6,12,24,.78)" : "rgba(255,255,255,.92)";
- ctx.strokeStyle = C.edge + ".5)"; ctx.lineWidth = 1;
- roundRect(x - w / 2 - 4 * u, y - h - gap / 2 - 4 * u, w + 8 * u, h * 2 + gap + 8 * u, 5 * u); ctx.fill(); ctx.stroke();
- const linkColor = linkState === "ok" ? C.cyan : linkState === "error" ? C.red : C.dim;
- const linkAlpha = linkState === "ok" ? .6 : linkState === "error" ? .55 : .25;
- ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor; ctx.shadowBlur = linkState === "ok" || linkState === "error" ? 6 * u : 0;
- roundRect(x - w / 2, y - h - gap / 2, w, h, h / 2); ctx.fill();
- ctx.shadowBlur = 0;
- const actColor = kind === "error" || kind === "rollback" ? C.red : C.amber;
- if (level > 0) {
- ctx.globalAlpha = .3 + .7 * level; ctx.fillStyle = actColor; ctx.shadowColor = actColor; ctx.shadowBlur = 18 * u * level;
- } else { ctx.globalAlpha = .18; ctx.fillStyle = C.amber; }
- roundRect(x - w / 2, y + gap / 2, w, h, h / 2); ctx.fill();
- ctx.restore();
- return q;
- }
-
- /* ---------- 六幕相机关键帧(纯几何/镜头语言,已由白栖知确认) ---------- */
- const CH = [
- { yaw: .00, pitch: .13, dist: 4.7, cx: 0, cy: 0, scale: 1.18, explode: .14, ring: 1.0, spread: 1.0, unfold: 0, trail: 0 },
- { yaw: .62, pitch: .17, dist: 3.7, cx: -.55, cy: .12, scale: .98, explode: .14, ring: .8, spread: 2.0, unfold: 0, trail: 0 },
- { yaw: 1.18, pitch: .30, dist: 4.2, cx: 0, cy: -.1, scale: .72, explode: .1, ring: .95, spread: .9, unfold: 0, trail: 0 },
- { yaw: 1.72, pitch: .06, dist: 4.0, cx: .1, cy: .24, scale: .9, explode: .7, ring: .6, spread: .75, unfold: 0, trail: 0 },
- { yaw: 2.38, pitch: .15, dist: 4.3, cx: 0, cy: 0, scale: 1.0, explode: .22, ring: 1.0, spread: .9, unfold: 1, trail: 0 },
- { yaw: 2.92, pitch: .10, dist: 4.6, cx: .4, cy: 0, scale: .95, explode: .16, ring: 1.0, spread: 1.0, unfold: 0, trail: 1 },
- ];
- function smooth(t) { return t * t * (3 - 2 * t); }
- function lerpK(a, b, f) { const o = {}; for (const k in a) o[k] = a[k] + (b[k] - a[k]) * f; return o; }
- function chapterAt(p) {
- const i = clamp(Math.floor(p), 0, 4), f = smooth(clamp(p - i, 0, 1));
- return { ...lerpK(CH[i], CH[i + 1], f), idx: clamp(Math.round(p), 0, 5) };
- }
-
- /* ---------- 几何:核心 / 端口 / 流道 / 环 ---------- */
- const PORTS = [V(-1.02, .52, .18), V(-1.05, -.42, .38), V(-.5, .98, -.4), V(-.48, -.92, -.42)];
- const SRC_FAR = [V(-3.4, 1.9, -1.6), V(-3.6, -1.7, -.8), V(-2.3, 2.6, -2.4), V(-2.2, -2.7, -2.0)];
- const TX_PORT = V(1.02, .18, .05), RX_PORT = V(.98, -.4, .1);
- const TX_FAR = V(5.6, 1.4, -1.6), AUD_FAR = V(4.6, -2.4, .6);
- function chanPts(from, to, spread, lift) {
- const a = V(from.x * spread, from.y * spread, from.z);
- const c1 = V(a.x * .55, a.y * .8 + lift, a.z * .7), c2 = V(to.x * 2.0, to.y * 1.5, to.z * 1.6);
- const pts = []; for (let i = 0; i <= 42; i++) pts.push(bez(a, c1, c2, to, i / 42));
- return pts;
- }
- function getChannels(spread) {
- const chans = FLOW_PROVIDERS.map((_, i) => chanPts(SRC_FAR[i], PORTS[i], spread, i % 2 ? -.5 : .5));
- chans.tx = chanPts(TX_FAR, TX_PORT, 1, .2).reverse();
- chans.aud = chanPts(AUD_FAR, RX_PORT, 1, -.2).reverse();
- return chans;
- }
-
- const CUBE_F = [
- { n: "来源", key: "sources", idx: [0, 1, 3, 2], nor: V(-1, 0, 0) },
- { n: "发布", key: "release", idx: [4, 6, 7, 5], nor: V(1, 0, 0) },
- { n: "调度", key: "jobs", idx: [2, 3, 7, 6], nor: V(0, 1, 0) },
- { n: "暂存", key: "release2", idx: [0, 4, 5, 1], nor: V(0, -1, 0) },
- { n: "数据集", key: "datasets", idx: [1, 5, 7, 3], nor: V(0, 0, 1) },
- { n: "审计", key: "audit", idx: [0, 2, 6, 4], nor: V(0, 0, -1) },
- ];
- const CUBE_V = []; for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1]) CUBE_V.push(V(x * .5, y * .5, z * .5));
- function facePoint(f, u, v, sc, explode) {
- const nor = f.nor, ex = explode * .5;
- let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0);
- let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x);
- let base = V(nor.x * (.5 + ex), nor.y * (.5 + ex), nor.z * (.5 + ex));
- return V(
- (base.x + t1.x * u * .5 + t2.x * v * .5) * sc,
- (base.y + t1.y * u * .5 + t2.y * v * .5) * sc,
- (base.z + t1.z * u * .5 + t2.z * v * .5) * sc,
- );
- }
- function faceSummary(f) {
- const s = state.data;
- if (f.key === "sources") {
- const items = (s.sources && s.sources.items || []).filter((it) => FLOW_PROVIDERS.includes(it.provider));
- const ok = items.filter((it) => it.health && (it.health.state === "ok" || it.health.state === "empty")).length;
- return `${ok}/${items.length || 4} 已连接`;
- }
- if (f.key === "release" || f.key === "release2") {
- const pubs = (s.batches && s.batches.publications) || [];
- if (f.key === "release2") { const staged = ((s.batches && s.batches.batches) || []).filter((b) => b.state === "staged").length; return `队列 ${staged}`; }
- const latest = pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop();
- return latest ? String(latest.active_batch) : "暂无发布";
- }
- if (f.key === "jobs") { const runs = (s.jobs && s.jobs.runs) || []; const today = todayYmd(); const n = runs.filter((r) => String(r.started_at || "").replace(/-/g, "").startsWith(today.slice(0, 8))).length; return `今日运行 ${n}`; }
- if (f.key === "datasets") { const pubs = (s.datasets && s.datasets.publications) || []; return `${pubs.length} 套`; }
- if (f.key === "audit") { const items = (s.audit && s.audit.items) || []; return `留痕 ${items.length}`; }
- return "";
- }
- function drawCore(sc, explode, unfold, vt) {
- const faces = CUBE_F.map((f, fi) => {
- const corners = f.idx.map((i) => {
- const vtx = CUBE_V[i]; const nor = f.nor;
- let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0);
- let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x);
- const u = 2 * (vtx.x * t1.x + vtx.y * t1.y + vtx.z * t1.z), v = 2 * (vtx.x * t2.x + vtx.y * t2.y + vtx.z * t2.z);
- return facePoint(f, u, v, sc, explode);
- });
- const pr = corners.map(project);
- const zc = pr.reduce((a, p) => a + p.z, 0) / 4;
- const c3 = corners.reduce((a, p) => V(a.x + p.x / 4, a.y + p.y / 4, a.z + p.z / 4), V(0, 0, 0));
- const facing = rotY(V(f.nor.x, f.nor.y, f.nor.z), CAM.yaw).z > .12;
- return { f, fi, pr, zc, c3, facing };
- }).sort((a, b) => b.zc - a.zc);
- // 内部层片:校验 / 暂存 / 发布
- const slabNames = ["校验", "暂存", "发布"];
- for (let s = 0; s < 3; s++) {
- const y = (s - 1) * (.3 + explode * .42) * sc, hw = .36 * sc, hh = .07 * sc;
- const sv = [V(-hw, y - hh, -hw), V(hw, y - hh, -hw), V(hw, y - hh, hw), V(-hw, y - hh, hw),
- V(-hw, y + hh, -hw), V(hw, y + hh, -hw), V(hw, y + hh, hw), V(-hw, y + hh, hw)];
- const edges = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
- ctx.save();
- const fillA = .10 + explode * .10;
- const top = [4, 5, 6, 7].map((i) => project(sv[i]));
- ctx.fillStyle = C.slab + fillA + ")";
- ctx.beginPath(); top.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill();
- ctx.strokeStyle = C.slab + ".5)"; ctx.lineWidth = 1;
- edges.forEach((e) => { const a = project(sv[e[0]]), b = project(sv[e[1]]); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); });
- if (explode > .45) {
- const c = project(V(hw * 1.15, y, 0));
- ctx.fillStyle = C.ink; ctx.globalAlpha = .9; ctx.font = `600 ${Math.max(10, 11 * c.s * 2)}px "Noto Sans SC"`;
- ctx.fillText(slabNames[s], c.x + 8, c.y + 4); ctx.globalAlpha = 1;
- }
- ctx.restore();
- }
- faces.forEach((fc) => {
- const { pr, f, facing } = fc;
- const depth = Math.max(.15, Math.min(1, 1.2 - (fc.zc + 2.5) / 5));
- ctx.save();
- const g = ctx.createLinearGradient(pr[0].x, pr[0].y, pr[2].x, pr[2].y);
- g.addColorStop(0, C.face + ((facing ? .20 : .06) * depth + .03) + ")");
- g.addColorStop(.55, C.face + ((facing ? .07 : .02) * depth + .015) + ")");
- g.addColorStop(1, C.face + ((facing ? .14 : .04) * depth + .02) + ")");
- ctx.fillStyle = g;
- ctx.beginPath(); pr.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill();
- if (facing) { ctx.shadowColor = C.edge + ".9)"; ctx.shadowBlur = 9; }
- ctx.strokeStyle = C.edge + ((facing ? .8 : .3) * depth + .12) + ")"; ctx.lineWidth = facing ? 1.4 : 1; ctx.stroke();
- ctx.shadowBlur = 0;
- if (facing && unfold < .5) {
- const c = project(fc.c3); const fs = Math.max(11, 15 * c.s * 2);
- ctx.globalAlpha = .55 + .45 * depth; ctx.fillStyle = C.ink; ctx.font = `600 ${fs}px "Noto Sans SC"`; ctx.textAlign = "center";
- ctx.fillText(f.n, c.x, c.y - fs * .3);
- ctx.globalAlpha *= .7; ctx.font = `${fs * .74}px "DejaVu Sans Mono"`; ctx.fillStyle = C.cyan;
- ctx.fillText(faceSummary(f), c.x, c.y + fs * .75);
- ctx.textAlign = "left"; ctx.globalAlpha = 1;
- }
- ctx.restore();
- });
- Stage.faceCenters = faces.filter((f) => f.facing).map((fc) => ({ q: project(fc.c3), fi: fc.fi }));
- ctx.save(); setAdd();
- const b0 = project(V(0, -.8 * sc, 0)), b1 = project(V(0, .8 * sc, 0));
- const bg2 = ctx.createLinearGradient(b0.x, b0.y, b1.x, b1.y);
- const beamA = C === PAL.night ? .5 : .3;
- bg2.addColorStop(0, C.slab + "0)"); bg2.addColorStop(.5, C.slab + beamA + ")"); bg2.addColorStop(1, C.slab + "0)");
- ctx.strokeStyle = bg2; ctx.lineCap = "round"; ctx.lineWidth = Math.max(3, 9 * b0.s * 2);
- ctx.beginPath(); ctx.moveTo(b0.x, b0.y); ctx.lineTo(b1.x, b1.y); ctx.stroke();
- dot3(V(0, 0, 0), 4, C === PAL.night ? "rgba(190,240,255," : C.slab, .8, true);
- ctx.restore();
- }
- function drawRing(radius, tilt, vt) {
- const pts = [];
- for (let i = 0; i <= 72; i++) { const a = i / 72 * Math.PI * 2; pts.push(rotX(V(Math.cos(a) * radius, 0, Math.sin(a) * radius), tilt)); }
- polyGlow(pts, C.ring, .5, 1.1, vt * 1.2);
- }
- function drawTasksRing(radius, tilt) {
- const jobsData = state.data.jobs; if (!jobsData) return;
- const jobs = jobsData.jobs || [], runs = jobsData.runs || [];
- const latestByJob = new Map();
- for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r);
- jobs.forEach((job, i) => {
- const a0 = i / jobs.length * Math.PI * 2 + Math.PI * .1;
- let p = V(Math.cos(a0) * radius, 0, Math.sin(a0) * radius); p = rotX(p, tilt);
- const run = latestByJob.get(job.id);
- const st = run ? run.state : "never";
- const color = st === "failed" ? "rgba(255,107,107," : st === "ok" ? "rgba(47,216,206," : st === "running" ? "rgba(255,184,77," : "rgba(150,160,190,";
- const q = dot3(p, st === "never" ? 3 : 4.6, color, st === "never" ? .4 : .9, st !== "never");
- const isFresh = (Stage.freshJobRuns || []).some((r) => r.job_id === job.id);
- if (isFresh) lamp3(p, Lamps.junction.level(now()), .78, st === "failed" ? "error" : "ok");
- ctx.save(); ctx.globalAlpha = .55; ctx.fillStyle = st === "failed" ? C.red : C.dim;
- ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.textAlign = "center";
- ctx.fillText(job.title.slice(0, 8), q.x, q.y - 13 * q.s * 2); ctx.textAlign = "left"; ctx.restore();
- });
- }
- const flowSeeds = FLOW_PROVIDERS.map(() => Array.from({ length: 24 }, (_, i) => ({ o: i / 24, w: .6 + (i % 5) * .12 })));
- function drawFlows(chans, vt, alpha) {
- FLOW_PROVIDERS.forEach((provider, i) => {
- const pts = chans[i];
- polyGlow(pts, C.chan[i], .5 * alpha, 2.2, vt * .0016 * (1.2 + i * .18));
- flowSeeds[i].forEach((p) => {
- const u = (p.o + vt * .00006 * p.w) % 1;
- const pt = pts[Math.floor(u * (pts.length - 1))];
- dot3(pt, 2.6 * p.w, C.chan[i], .95 * alpha, p.w > 1.05);
- });
- });
- polyGlow(chans.tx, C.slab, .5 * alpha, 2.4, vt * .0022);
- polyGlow(chans.aud, C.edge, .3 * alpha, 1.6, vt * .0016);
- }
- const TRAVEL_IN = 900, TRAVEL_OUT = 750;
- function drawPackets(chans) {
- const nowMs = performance.now();
- for (let k = PACKETS.length - 1; k >= 0; k--) {
- const pk = PACKETS[k]; const dt = nowMs - pk.t0;
- if (dt > TRAVEL_IN + TRAVEL_OUT + 400) { PACKETS.splice(k, 1); continue; }
- const col = pk.rb ? "rgba(255,107,107," : "rgba(255,214,150,";
- if (dt < TRAVEL_IN) {
- const u = dt / TRAVEL_IN, pts = chans[pk.src];
- for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.4 - j * .42, col, .9 - j * .13, j === 0); }
- } else {
- const u = (dt - TRAVEL_IN) / TRAVEL_OUT, pts = pk.rb ? chans.aud : chans.tx;
- for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.2 - j * .4, col, .85 - j * .12, j === 0); }
- }
- }
- }
- function drawAuditTrail(amp) {
- const items = ((state.data.audit && state.data.audit.items) || []).slice(0, 5);
- if (amp < .05 || !items.length) return;
- ctx.save(); ctx.globalAlpha = amp;
- items.forEach((a, i) => {
- const t = i / Math.max(1, items.length - 1);
- const ang = Math.PI * (.25 + t * .85), r = 2.6 + t * .9;
- const p = V(Math.cos(ang) * r, .9 - t * 1.9, -1.6 - Math.sin(ang) * 1.2);
- const q = project(p);
- const col = String(a.action).includes("rollback") ? C.red : C.cyan;
- ctx.globalAlpha = amp * (1 - t * .55);
- ctx.fillStyle = col; ctx.beginPath(); ctx.arc(q.x, q.y, 4 * q.s * 2, 0, 7); ctx.fill();
- ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.fillStyle = C.ink;
- ctx.fillText(`${a.action} · ${a.target || ""}`.slice(0, 22), q.x + 12, q.y + 3);
- ctx.font = `${Math.max(8, 9 * q.s * 2)}px "DejaVu Sans Mono"`; ctx.fillStyle = C.dim;
- ctx.fillText(timeShort(a.created_at), q.x + 12, q.y + 15);
- });
- ctx.restore();
- }
- function drawSourceLabels(alpha) {
- if (alpha < .05) return;
- ctx.save(); ctx.globalAlpha = alpha;
- Stage.portScreen.forEach((q, i) => {
- if (!q || q.hide) return;
- const provider = FLOW_PROVIDERS[i];
- const info = ((state.data.sources && state.data.sources.items) || []).find((it) => it.provider === provider) || {};
- const health = info.health || {};
- const OFF = [{ dx: 34, dy: -78 }, { dx: 34, dy: 34 }, { dx: -230, dy: -92 }, { dx: -230, dy: 48 }][i];
- let x = q.x + OFF.dx, y = q.y + OFF.dy;
- x = clamp(x, 320, W - 216); y = clamp(y, 14, H - 96);
- ctx.fillStyle = C.face + ".14)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1;
- roundRect(x, y, 196, 62, 9); ctx.fill(); ctx.stroke();
- ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(FLOW_LABEL[provider] || provider, x + 11, y + 20);
- ctx.fillStyle = C.dim; ctx.font = "10.5px \"DejaVu Sans Mono\"";
- ctx.fillText(`延迟 ${health.latency_ms ?? "-"}ms · ${health.state || "-"}`, x + 11, y + 37);
- ctx.fillStyle = C.cyan; ctx.fillText(info.role || "", x + 11, y + 53);
- ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(q.x + (OFF.dx > 0 ? 10 : -10), q.y); ctx.lineTo(OFF.dx > 0 ? x : x + 196, y + 31); ctx.stroke();
- });
- ctx.restore();
- }
- function drawPublishLabels(alpha) {
- if (alpha < .05) return;
- ctx.save(); ctx.globalAlpha = alpha;
- const rx = project(RX_PORT), tx = project(TX_PORT);
- ctx.font = "600 11px \"Noto Sans SC\"";
- ctx.fillStyle = C.cyan; ctx.fillText("RX 批次入口", rx.x - 70, rx.y + 34);
- ctx.fillStyle = C.ink; ctx.fillText("TX 发布回执", tx.x + 16, tx.y - 10);
- ctx.fillStyle = C.red; ctx.fillText("回滚分叉", tx.x + 60, tx.y + 66);
- ctx.restore();
- }
- function drawDatasetCards(alpha, coreX, coreY) {
- if (alpha < .05 || !Stage.faceCenters.length) return;
- const datasets = (state.data.datasets && state.data.datasets.publications) || [];
- ctx.save(); ctx.globalAlpha = alpha;
- Stage.faceCenters.forEach((fc) => {
- const ds = datasets[fc.fi]; if (!ds) return;
- let dx = fc.q.x - coreX, dy = fc.q.y - coreY; const len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len;
- let x = fc.q.x + dx * 110 - 75, y = fc.q.y + dy * 80 - 30;
- x = clamp(x, 316, W - 170); y = clamp(y, 14, H - 84);
- ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(fc.q.x, fc.q.y); ctx.lineTo(x + 75, y + 30); ctx.stroke();
- ctx.fillStyle = C.face + ".16)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1;
- roundRect(x, y, 150, 60, 9); ctx.fill(); ctx.stroke();
- ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(ds.dataset, x + 11, y + 19);
- ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\""; ctx.fillText(`${ds.active_batch} · ${ds.state}`, x + 11, y + 34);
- });
- ctx.restore();
- }
-
- const publicApi = {
- faceCenters: [], portScreen: [], freshJobRuns: [],
- init() {
- canvas = $("scene"); ctx = canvas.getContext("2d");
- initPalette();
- addEventListener("resize", resize);
- resize();
- buildCabinShell();
- canvas.addEventListener("click", onCanvasClick);
- $("detailClose").addEventListener("click", () => Detail.close());
- // 相机位置由 render() 每帧直接读取 scrollY 计算,天然免疫快速/反向滚动排队问题,
- // 无需额外监听 scroll 事件。
- },
- onThemeChange(theme) { initPalette(); },
- start() { if (!running) { running = true; scheduleNav(); loop(); } },
- resume() { this.start(); },
- pause() { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = null; },
- stop() { this.pause(); },
- scrollToScene(idx) {
- const track = $("track");
- const secH = track.offsetHeight / 6;
- scrollTo({ top: track.offsetTop + idx * secH, behavior: "smooth" });
- },
- renderDetail(idx) {
- const body = SCENE_DETAIL[idx]();
- $("detailBody").innerHTML = body;
- if (SCENE_BIND[idx]) SCENE_BIND[idx]($("detailBody"));
- positionDetail(idx);
- $("detail").classList.remove("closed");
- },
- closeDetail() { $("detail").classList.add("closed"); },
- };
-
- function scheduleNav() {
- document.querySelectorAll("#rail .stop").forEach((el) => {
- el.onclick = () => Nav.go(+el.dataset.i);
- });
- }
- function buildCabinShell() {
- $("rail").innerHTML = SCENES.map((s, i) => `${esc(s.label)}
`).join('');
- scheduleNav();
- }
- function resize() {
- if (!canvas) return;
- DPR = Math.min(devicePixelRatio || 1, 2);
- const r = canvas.parentElement.getBoundingClientRect();
- W = r.width; H = r.height;
- canvas.width = W * DPR; canvas.height = H * DPR;
- ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
- }
- function positionDetail(idx) {
- const el = $("detail");
- let anchor = null;
- if (idx === 1 && Stage.portScreen.length) anchor = Stage.portScreen.find((q) => q && !q.hide);
- if (anchor) {
- el.style.left = clamp(anchor.x + 34, 20, W - 380) + "px";
- el.style.top = clamp(anchor.y - 40, 16, H - 260) + "px";
- } else {
- el.style.left = Math.max(20, W / 2 - 180) + "px";
- el.style.top = "20px";
- }
- }
- function onCanvasClick(e) {
- const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
- // 优先命中当前可见的来源端口(仅在数据源幕出现),否则命中画面中心的机芯本体——
- // 两种情况都应该打开“当前所在的那一幕”的详情,而不是硬编码成数据源。
- let best = -1, bd = 34 * 34;
- Stage.portScreen.forEach((q, i) => { if (!q || q.hide) return; const d = (q.x - x) ** 2 + (q.y - y) ** 2; if (d < bd) { bd = d; best = i; } });
- const coreHit = Math.hypot(x - W / 2, y - H / 2) < Math.min(W, H) * 0.3;
- if (best >= 0 || coreHit) Detail.open(SCENES[state.scene].key);
- else Detail.close();
- }
-
- let cabinIdx = -1;
- function renderCabin(idx) {
- const c = SCENE_CABIN[idx]();
- const el = $("cabin");
- el.innerHTML = `${esc(c.eyebrow)}
${esc(c.title)}
${esc(c.sub)}
- ${c.rows.map((r) => `
${esc(r[0])}${esc(r[1])}
`).join("")}
- ${c.actions.map((a, i) => ``).join("")}
`;
- el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; });
- }
- function syncUI(idx, p) {
- if (idx !== cabinIdx) {
- const el = $("cabin");
- el.classList.add("hide");
- setTimeout(() => { renderCabin(idx); el.classList.remove("hide"); }, 180);
- cabinIdx = idx;
- document.querySelectorAll("#rail .stop").forEach((el2, i) => el2.classList.toggle("on", i === idx));
- $("hint").classList.toggle("off", p > .15);
- $("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`;
- $("phase").textContent = `${idx + 1} / ${SCENES.length}`;
- state.scene = idx;
- } else {
- renderCabin(idx); // 刷新真实数据但不重触发进出动画
- }
- if (Detail.openKey) Detail.reopen();
- }
- Views.onChange(() => {
- if (running && cabinIdx >= 0) renderCabin(cabinIdx);
- if (Detail.openKey && !$("detail").classList.contains("closed")) Detail.reopen();
- });
-
- function render() {
- if (!canvas) return;
- const vt = performance.now() - T0;
- const max = Math.max(1, document.body.scrollHeight - innerHeight);
- const p = clamp((scrollY / max) * 5, 0, 5);
- const K = chapterAt(p);
- CAM.yaw = K.yaw + (vt / 1000) * .05; CAM.pitch = K.pitch; CAM.dist = K.dist; CAM.cx = K.cx; CAM.cy = K.cy;
-
- const bg = ctx.createRadialGradient(W * .5, H * .46, 60, W * .5, H * .5, Math.max(W, H) * .75);
- bg.addColorStop(0, C.bgB); bg.addColorStop(1, C.bgA);
- ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
- ctx.save(); setAdd();
- const fl = ctx.createRadialGradient(W * .5, H * .8, 10, W * .5, H * .8, W * .24);
- fl.addColorStop(0, C.face + ".13)"); fl.addColorStop(1, C.face + "0)");
- ctx.fillStyle = fl; ctx.beginPath(); ctx.ellipse(W * .5, H * .8, W * .24, H * .09, 0, 0, 7); ctx.fill();
- ctx.restore();
-
- const chans = getChannels(K.spread);
- drawFlows(chans, vt, 1);
- drawRing(1.9 * K.ring, .5, vt);
- if (K.idx === 2) drawTasksRing(1.9 * K.ring, .5);
- drawCore(K.scale, K.explode, K.unfold, vt);
- drawRing(2.05 * K.ring, -.5, vt);
- drawPackets(chans);
-
- const nowMs = performance.now();
- Stage.portScreen = PORTS.map((pt, i) => {
- const wp = V(pt.x * K.scale, pt.y * K.scale, pt.z * K.scale);
- const behind = rotY(wp, CAM.yaw).z < -.25;
- const provider = FLOW_PROVIDERS[i];
- const lv = Lamps[provider].level(nowMs) * (behind ? .25 : 1);
- const q = lamp3(wp, lv, 1, Lamps[provider].activeKind(nowMs), Lamps[provider].link);
- q.hide = behind; return q;
- });
- lamp3(V(.35 * K.scale, -.15 * K.scale, .5 * K.scale), Lamps.junction.level(nowMs), .85, Lamps.junction.activeKind(nowMs), "ok");
- lamp3(V(TX_PORT.x * K.scale, TX_PORT.y * K.scale, TX_PORT.z * K.scale), Lamps.tx.level(nowMs), .9, Lamps.tx.activeKind(nowMs), "ok");
-
- drawSourceLabels(K.idx === 1 ? Math.min(1, K.spread - 1) : 0);
- drawPublishLabels(K.idx === 3 ? K.explode : 0);
- drawDatasetCards(K.unfold > .4 ? Math.min(1, (K.unfold - .4) * 2.5) : 0, W / 2, H * .46);
- drawAuditTrail(K.trail);
-
- Object.values(Lamps).forEach((ch) => { if (ch instanceof LampChannel) ch.prune(nowMs); });
- syncUI(K.idx, p);
- }
- function loop() {
- if (!running) return;
- render();
- rafId = requestAnimationFrame(loop);
- }
-
- return publicApi;
-})();
-
-/* ==========================================================================
- 减少动态效果版:六幕静态区块,正常滚动,无位移/旋转/闪烁,信息与功能等价。
- ========================================================================== */
-const StaticShell = (() => {
- let mounted = false;
- function drawStaticDiagram(canvasEl, seedAngle) {
- const ctx = canvasEl.getContext("2d");
- const W = canvasEl.width = canvasEl.clientWidth * 2;
- const H = canvasEl.height = canvasEl.clientHeight * 2;
- const night = document.documentElement.getAttribute("data-theme") === "night";
- ctx.fillStyle = night ? "#0A1020" : "#F1F4FC";
- ctx.fillRect(0, 0, W, H);
- ctx.save();
- ctx.translate(W / 2, H / 2);
- ctx.rotate(seedAngle);
- ctx.strokeStyle = night ? "rgba(140,190,255,.55)" : "rgba(47,99,214,.5)";
- ctx.lineWidth = 2;
- const s = Math.min(W, H) * .16;
- ctx.strokeRect(-s, -s, s * 2, s * 2);
- ctx.beginPath(); ctx.moveTo(-s, -s); ctx.lineTo(s * .4, -s * .5); ctx.lineTo(s * .4, s * .5); ctx.lineTo(-s, s); ctx.closePath(); ctx.stroke();
- ctx.restore();
- // 四条固定流向的直线(静态,不闪烁不移动)
- const colors = night ? ["#96CDFF", "#78B4FF", "#B9AFFF", "#FFD6A0"] : ["#2F63D6", "#0A7C92", "#6D5DD6", "#BE780A"];
- for (let i = 0; i < 4; i++) {
- const y = H * (.2 + i * .18);
- ctx.strokeStyle = colors[i]; ctx.globalAlpha = .55; ctx.lineWidth = 1.6;
- ctx.beginPath(); ctx.moveTo(6, y); ctx.lineTo(W * .42, H / 2); ctx.stroke();
- ctx.globalAlpha = 1;
- }
- ctx.globalAlpha = 1;
- }
- function lampChipHTML(provider) {
- const lamp = Lamps[provider];
- const linkOn = lamp.link === "ok";
- const recent = Date.now() - lamp.lastAt < 8000;
- const label = FLOW_LABEL[provider] || provider;
- return `${esc(label)}
- ${lamp.lastAt ? `· ACT ${Math.max(0, Math.round((Date.now() - lamp.lastAt) / 1000))}s 前` : '· 无最近事件'}`;
- }
- function sceneBlockHTML(idx) {
- const s = SCENES[idx];
- const cab = SCENE_CABIN[idx]();
- return `
-
- ${esc(cab.eyebrow)}
${esc(cab.title)}
-
- ${esc(cab.sub)}
- ${idx === 1 ? `${FLOW_PROVIDERS.map(lampChipHTML).join("")}
` : ""}
- ${cab.rows.map((r) => `
`).join("")}
- ${SCENE_DETAIL[idx]()}
- `;
- }
- function rebindScene(idx) {
- const root = $(`static-detail-${idx}`);
- if (SCENE_BIND[idx]) SCENE_BIND[idx](root);
- }
- function rerenderScene(idx) {
- const cab = SCENE_CABIN[idx]();
- const section = document.querySelector(`.scene-static[data-scene="${idx}"]`);
- if (!section) return;
- const cardsEl = section.querySelector(".cards");
- if (cardsEl) cardsEl.innerHTML = cab.rows.map((r) => ``).join("");
- if (idx === 1) { const lr = section.querySelector(".lamprow"); if (lr) lr.innerHTML = FLOW_PROVIDERS.map(lampChipHTML).join(""); }
- const detailEl = $(`static-detail-${idx}`);
- if (detailEl) { detailEl.innerHTML = SCENE_DETAIL[idx](); rebindScene(idx); }
- }
- function mount() {
- if (mounted) return;
- mounted = true;
- $("staticNav").innerHTML = SCENES.map((s, i) => ``).join("");
- $("staticNav").querySelectorAll("button").forEach((btn, i) => {
- btn.addEventListener("click", () => { setActive(i); scrollToInternal(i); });
- });
- $("staticScenes").innerHTML = SCENES.map((_, i) => sceneBlockHTML(i)).join("");
- SCENES.forEach((_, i) => { drawStaticDiagram($(`diagram-${i}`), i * .5); rebindScene(i); });
- setActive(0);
- Views.onChange(refreshAll);
- }
- function refreshAll() { if (mounted) SCENES.forEach((_, i) => rerenderScene(i)); }
- function setActive(i) {
- $("staticNav").querySelectorAll("button").forEach((b, j) => b.classList.toggle("active", j === i));
- state.scene = i;
- $("crumb").textContent = `8766 · ${SCENES[i].label} · 持续运转`;
- $("phase").textContent = `${i + 1} / ${SCENES.length}`;
- }
- function scrollToInternal(i) {
- const el = document.querySelector(`.scene-static[data-scene="${i}"]`);
- if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
- }
- function unmount() { /* CSS 控制显隐,无需清空内容,避免频繁重建 DOM */ }
- return { mount, unmount, scrollTo: scrollToInternal };
-})();
-
-/* ==========================================================================
- 启动
- ========================================================================== */
-function enterShell() {
- Stage.init();
- applyReduced(REDUCE_MQ.matches);
- const savedTheme = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })();
- applyTheme(savedTheme === "night" ? "night" : "day");
- Poller.startAll();
- updateCrumbStatus();
-}
-
-boot();
diff --git a/xiaobai-datahub/admin/core.js b/xiaobai-datahub/admin/core.js
new file mode 100644
index 0000000..5244cfc
--- /dev/null
+++ b/xiaobai-datahub/admin/core.js
@@ -0,0 +1,371 @@
+"use strict";
+/* ==========================================================================
+ xiaobai-datahub 管理后台 · 第九版核心引擎(A/B/C 三方向唯一共用来源)
+ —— 会话/主题/轮询/真实事件识别/危险操作/路由,三个布局文件只消费这里的
+ state、Bus 事件与 API,禁止各自再写一套请求或业务判断。
+ ========================================================================== */
+window.Core = (function () {
+ 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)); }
+ 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 ``;
+ }
+
+ const state = {
+ csrf: "",
+ layout: "flowline",
+ releaseDate: "",
+ reduced: matchMedia("(prefers-reduced-motion: reduce)").matches,
+ 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;
+ }
+
+ /* ---------------------------------------------------------------- 事件总线
+ 'data' → 任一真实拉取完成,payload = key(overview/sources/...),布局重绘用
+ 'event' → 已经发生的真实事件(供三方向各自演绎接力/推进/贯穿动效)
+ payload = { channel, kind, n, meta }
+ channel: tushare|eastmoney|tencent|ifind|junction|tx|audit
+ kind: ok|error|unconfigured|rollback|pub
+ 'theme' → 主题切换,payload = 'day'|'night'
+ 'reduced' → 减弱动效状态变化,payload = boolean
+ */
+ const Bus = (() => {
+ const listeners = {};
+ return {
+ on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); return () => this.off(type, fn); },
+ off(type, fn) { if (listeners[type]) listeners[type] = listeners[type].filter((f) => f !== fn); },
+ emit(type, payload) { (listeners[type] || []).slice().forEach((fn) => { try { fn(payload); } catch (e) { console.error(e); } }); },
+ };
+ })();
+
+ /* ---------------------------------------------------------------- 常量与标签 */
+ const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"];
+ const FLOW_LABEL = {
+ tushare: "Tushare", eastmoney: "东方财富", tencent: "腾讯行情", ifind: "iFinD",
+ };
+ const FLOW_ROLE = {
+ tushare: "官方盘后", eastmoney: "盘中观察", tencent: "盘中观察", ifind: "授权实时",
+ };
+ 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();
+
+ function chipClass(s) {
+ const map = {
+ ok: "ok", error: "err", warn: "warn", unknown: "unknown", unconfigured: "unconfigured",
+ published: "ok", pending: "warn", missing: "err", building: "warn", staged: "warn", failed: "err",
+ running: "warn", queued: "info", idle: "", success: "ok",
+ };
+ return map[s] ?? "";
+ }
+ function chipHtml(s, textOverride) {
+ return `${esc(textOverride ?? s)}`;
+ }
+
+ /* 真实健康态归一化:tushare 走断路器术语(closed=健康/half_open=试探/open=已跳闸),
+ 其余三路走 ok/empty/error/unconfigured;没有探测记录时是 unknown。
+ 四态必须原样区分显示,不得把 unknown 和 error 混为一谈,也不得把断路器
+ "closed"(健康) 误读成红色错误。 */
+ function linkState(health) {
+ const s = (health && health.state) || "unknown";
+ if (s === "ok" || s === "empty" || s === "closed") return "ok";
+ if (s === "half_open") return "warn";
+ if (s === "unconfigured" || s === "reserved") return "unconfigured";
+ if (s === "unknown") return "unknown";
+ return "error"; // open(断路器已跳闸)、error
+ }
+ function linkLampClass(link) {
+ if (link === "ok") return "on";
+ if (link === "error") return "err on";
+ return "off"; // warn / unknown / unconfigured 灯位统一暗灭,状态文字由 chip 承载
+ }
+
+ /* ---------------------------------------------------------------- 真实事件识别
+ 只认已经发生的事实:recent_calls / probe 健康探测 / job runs / batch 状态变化 /
+ publication 发布时间变化 / audit 新行。没有真实变化就不发事件,不臆造。 */
+ 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) Bus.emit("event", { channel: "tushare", kind: fresh.some((c) => !c.ok) ? "error" : "ok", n: fresh.length, meta: { fresh } });
+ state.data.overview = data;
+ Bus.emit("data", "overview");
+ }
+
+ 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 = linkState(health);
+ item._link = link;
+ if (item.provider !== "tushare" && link !== "unconfigured" && link !== "unknown") {
+ Bus.emit("event", { channel: item.provider, kind: link === "ok" ? "ok" : link === "warn" ? "warn" : "error", n: 1, meta: { item } });
+ }
+ }
+ state.data.sources = data;
+ Bus.emit("data", "sources");
+ }
+
+ 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");
+ Bus.emit("event", { channel: "junction", kind: anyFail ? "error" : "ok", n: fresh.length, meta: { fresh } });
+ }
+ state.data.jobs = data;
+ state.freshJobRuns = fresh;
+ Bus.emit("data", "jobs");
+ }
+
+ 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) Bus.emit("event", { channel: "tx", kind: "pub", n: 1, meta: {} });
+ else if (!wasFirst && changed) Bus.emit("event", { channel: "junction", kind: "ok", n: 1, meta: {} });
+ state.data.batches = data;
+ Bus.emit("data", "batches");
+ }
+
+ async function pollDatasets() {
+ const data = await api(`/admin/api/datasets?date=${encodeURIComponent("")}`);
+ state.data.datasets = data;
+ Bus.emit("data", "datasets");
+ }
+
+ 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"));
+ Bus.emit("event", { channel: "audit", kind: rollback ? "rollback" : "ok", n: fresh.length, meta: { fresh } });
+ }
+ state.data.audit = data;
+ Bus.emit("data", "audit");
+ }
+
+ 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 runtimeAvailable() { return state.visible && state.online; }
+ 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, runtimeAvailable };
+ })();
+
+ function onRuntimeAvailabilityChange() {
+ if (Poller.runtimeAvailable()) Poller.resume();
+ else Poller.pause();
+ Bus.emit("runtime", Poller.runtimeAvailable());
+ }
+ document.addEventListener("visibilitychange", () => { state.visible = document.visibilityState === "visible"; onRuntimeAvailabilityChange(); });
+ window.addEventListener("online", () => { state.online = true; onRuntimeAvailabilityChange(); });
+ window.addEventListener("offline", () => { state.online = false; onRuntimeAvailabilityChange(); });
+
+ /* ---------------------------------------------------------------- 主题 */
+ function applyTheme(theme) {
+ const root = document.documentElement;
+ if (theme === "night") root.setAttribute("data-theme", "night");
+ else root.removeAttribute("data-theme");
+ try { localStorage.setItem("hub_theme", theme); } catch { /* ignore */ }
+ Bus.emit("theme", theme);
+ }
+ function currentTheme() { return document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"; }
+ function toggleTheme() { applyTheme(currentTheme() === "night" ? "day" : "night"); }
+ function bootTheme() {
+ const saved = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })();
+ applyTheme(saved === "night" ? "night" : "day");
+ }
+
+ /* ---------------------------------------------------------------- 减少动态效果 */
+ const REDUCE_MQ = matchMedia("(prefers-reduced-motion: reduce)");
+ function applyReduced(reduced) {
+ state.reduced = reduced;
+ document.documentElement.classList.toggle("reduced", reduced);
+ Bus.emit("reduced", reduced);
+ }
+ REDUCE_MQ.addEventListener("change", (e) => applyReduced(e.matches));
+
+ /* ---------------------------------------------------------------- 危险操作确认 */
+ async function dangerous(kind, dataset, onDone) {
+ 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 {
+ const result = await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) });
+ if (kind === "rollback") Bus.emit("event", { channel: "tx", kind: "rollback", n: 1, meta: {} });
+ await pollBatches();
+ await pollAudit();
+ if (onDone) onDone(result);
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function probeSource(provider) {
+ const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" });
+ if (FLOW_PROVIDERS.includes(provider)) {
+ const link = linkState(result);
+ Bus.emit("event", { channel: provider, kind: link === "ok" ? "ok" : link === "warn" ? "warn" : "error", n: 1, meta: { manual: true } });
+ }
+ await pollSources();
+ return result;
+ }
+
+ async function runJob(jobId, tradeDate) {
+ const result = await api(`/admin/api/jobs/${jobId}/run`, { method: "POST", body: JSON.stringify({ trade_date: tradeDate || "" }) });
+ await pollJobs();
+ return result;
+ }
+
+ /* ---------------------------------------------------------------- 路由:?layout=flowline|ledger|strata
+ 单一静态文件,只靠查询参数区分三页;不改后端路由。
+ 支持直接打开 / 刷新 / 浏览器前进后退。 */
+ const LAYOUT_NAMES = ["flowline", "ledger", "strata"];
+ const Router = (() => {
+ let mounted = null;
+ function parse() {
+ const qs = new URLSearchParams(location.search);
+ const l = qs.get("layout");
+ return LAYOUT_NAMES.includes(l) ? l : "flowline";
+ }
+ function urlFor(name) {
+ const qs = new URLSearchParams(location.search);
+ qs.set("layout", name);
+ return `${location.pathname}?${qs.toString()}${location.hash}`;
+ }
+ function navigate(name) {
+ if (!LAYOUT_NAMES.includes(name)) return;
+ if (parse() === name) return;
+ history.pushState(null, "", urlFor(name));
+ mount(name);
+ }
+ function mount(name) {
+ if (mounted && window.HUB_LAYOUTS[mounted] && window.HUB_LAYOUTS[mounted].unmount) {
+ try { window.HUB_LAYOUTS[mounted].unmount(); } catch (e) { console.error(e); }
+ }
+ state.layout = name;
+ mounted = name;
+ document.querySelectorAll("#layoutSwitch button[data-layout]").forEach((btn) => {
+ const on = btn.dataset.layout === name;
+ if (on) btn.setAttribute("aria-current", "page"); else btn.removeAttribute("aria-current");
+ });
+ const root = $("page-root");
+ const impl = window.HUB_LAYOUTS[name];
+ if (!impl) { root.innerHTML = `布局未加载:${esc(name)}
`; return; }
+ impl.mount(root);
+ }
+ function boot() {
+ document.querySelectorAll("#layoutSwitch button[data-layout]").forEach((btn) => {
+ btn.addEventListener("click", () => navigate(btn.dataset.layout));
+ });
+ window.addEventListener("popstate", () => mount(parse()));
+ mount(parse());
+ }
+ return { boot, navigate, current: parse, urlFor };
+ })();
+
+ return {
+ $, esc, timeShort, clamp, table,
+ state, api, Bus, Poller, Router,
+ FLOW_PROVIDERS, FLOW_LABEL, FLOW_ROLE, EOD_LABELS, REV_LABELS, PHASE_LABELS,
+ todayYmd, chipClass, chipHtml, linkState, linkLampClass,
+ applyTheme, currentTheme, toggleTheme, bootTheme,
+ applyReduced, REDUCE_MQ,
+ dangerous, probeSource, runJob,
+ pollOverview, pollSources, pollJobs, pollBatches, pollDatasets, pollAudit,
+ };
+})();
diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html
index 5d6a164..4279e58 100644
--- a/xiaobai-datahub/admin/index.html
+++ b/xiaobai-datahub/admin/index.html
@@ -4,7 +4,11 @@
xiaobai-datahub 管理后台 · 数据中枢
-
+
+
+
+
+
@@ -30,49 +34,31 @@
-
+
- 小白复盘 · 数据中枢
- 8766 · 四源汇流 · 持续运转
-
+ 小白复盘 · 数据中枢
+
+ 8766 · 四源汇流 · 持续运转
-
-
-
+
+
+
+
-
-
-
-
-
- LINK 链路常亮(低亮)
- ACT 活动灯 · 事件成簇短闪
-
-
-
滚 动 推 进 镜 头
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+