diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index afaf599..0f567d4 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1,6 +1,34 @@ -const state = { csrf: "", page: "overview" }; +"use strict"; +/* ========================================================================== + xiaobai-datahub 管理后台 · 第七版「轨道机芯」 + 一台持续运转的数据空间:滚动推镜、立体核心换面、四路真实来源持续流动、 + A2 路由器式 LINK/ACT 信号灯——全部由真实接口数据驱动,没有任何模拟流量。 + 只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测 + (每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件", + 因此只有这四路会闪 ACT;ths/xgb/akshare 是预留源,永远不闪。 + ========================================================================== */ +/* ---------------------------------------------------------------- 基础工具 */ function $(id) { return document.getElementById(id); } +function esc(value) { + return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); +} +function timeShort(value) { + const s = String(value ?? ""); + const m = s.match(/(\d{2}:\d{2}:\d{2})/); + return m ? m[1] : s.replace("T", " ").slice(0, 16); +} +function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } + +const state = { + csrf: "", + scene: 0, + releaseDate: "", + reduced: false, + visible: document.visibilityState === "visible", + online: navigator.onLine, + data: { overview: null, sources: null, jobs: null, batches: null, datasets: null, audit: null }, +}; async function api(path, options = {}) { const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {}); @@ -18,10 +46,6 @@ function show(id) { ["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; }); } -function esc(value) { - return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); -} - function table(headers, rows) { const thead = headers.map((h) => `${esc(h)}`).join(""); const body = rows.length @@ -30,6 +54,7 @@ function table(headers, rows) { return `${thead}${body}
`; } +/* ---------------------------------------------------------------- 登录 / 会话 */ async function boot() { try { const session = await api("/admin/api/session"); @@ -37,7 +62,7 @@ async function boot() { $("who").textContent = session.username; if (session.must_change) { show("change-view"); return; } show("shell"); - await render(); + enterShell(); } catch { show("login-view"); } @@ -54,7 +79,7 @@ $("login-form").addEventListener("submit", async (event) => { }); state.csrf = result.csrf; if (result.must_change) show("change-view"); - else { show("shell"); await render(); } + else { show("shell"); enterShell(); } } catch (err) { $("login-error").hidden = false; $("login-error").textContent = err.message; @@ -70,7 +95,7 @@ $("change-form").addEventListener("submit", async (event) => { body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }), }); show("shell"); - await render(); + enterShell(); } catch (err) { $("change-error").hidden = false; $("change-error").textContent = err.message; @@ -79,221 +104,1238 @@ $("change-form").addEventListener("submit", async (event) => { $("logout-btn").addEventListener("click", async () => { await api("/admin/api/logout", { method: "POST", body: "{}" }); + Poller.stopAll(); + Stage.stop(); show("login-view"); }); -$("theme-btn").addEventListener("click", () => { +/* ---------------------------------------------------------------- 主题(日/夜) */ +function applyTheme(theme) { const root = document.documentElement; - const next = root.getAttribute("data-theme") === "night" ? "" : "night"; - if (next) root.setAttribute("data-theme", next); + if (theme === "night") root.setAttribute("data-theme", "night"); else root.removeAttribute("data-theme"); - $("theme-btn").textContent = next ? "日间" : "夜间"; + $("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"); }); -document.querySelectorAll("nav button").forEach((btn) => { - btn.addEventListener("click", () => { - document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active")); - btn.classList.add("active"); - state.page = btn.dataset.page; - render(); - }); +/* ---------------------------------------------------------------- 减少动态效果 & 可见性 & 网络 */ +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(); }); -async function render() { - const page = $("page"); - if (state.page === "overview") { - const data = await api("/admin/api/overview"); - $("phase").textContent = data.session_phase; - const eod = data.eod_status || {}; - const rev = data.revision_status || {}; - const eodLabels = { - pending_first_attempt: "等待首次尝试", - waiting_upstream: "等待上游", - done: "已成功", - cutoff_failed: "已截止失败", - closed_day: "休市", - }; - const revLabels = { - waiting_review: "等待复核", - review_failed: "复核失败", - aligned: "已追平", - cutoff: "已截止", - pending_publish: "待发布", - closed_day: "休市", - }; - const eodExtra = []; - if (eod.state === "waiting_upstream") { - eodExtra.push(`已试 ${eod.attempts} 次`); - if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(String(eod.next_retry_at).replace("T", " ").slice(11, 16))}`); - 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))); - page.innerHTML = ` -
-
交易日
${esc(data.trade_date)}
-
阶段
${esc(data.session_phase)}
-
今日发布
${data.publications.length}
-
盘后补跑
${esc(eodLabels[eod.state] || eod.state || "-")}
${eodExtra.join(" · ")}
-
估值复核
${esc(revLabels[rev.state] || rev.state || "-")}
${revExtra.join(" · ")}
-
异常批次
${data.anomalies.length}
-
-

最近调用

- ${table(["时间", "源", "端点", "结果", "耗时"], data.recent_calls.map((row) => [ - esc(row.created_at), esc(row.provider), esc(row.endpoint), - row.ok ? '成功' : `${esc(row.error)}`, - `${row.latency_ms ?? "-"} ms`, - ]))} - `; - return; - } - if (state.page === "sources") { - const data = await api("/admin/api/sources"); - page.innerHTML = `

数据源

` + table( - ["源", "角色", "状态", "凭据", "操作"], - data.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, - ``, - ]; - }), - ); - page.querySelectorAll("[data-probe]").forEach((btn) => { - btn.addEventListener("click", async () => { - const result = await api(`/admin/api/sources/${btn.dataset.probe}/probe`, { method: "POST", body: "{}" }); - alert(JSON.stringify(result)); - render(); - }); - }); - return; - } - if (state.page === "jobs") { - const data = await api("/admin/api/jobs"); - page.innerHTML = ` -

调度任务

- ${table(["任务", "时刻", "操作"], data.jobs.map((job) => [ - `${esc(job.id)} · ${esc(job.title)}`, esc(job.at), - ``, - ]))} -

最近运行

- ${table(["ID", "任务", "状态", "开始", "结束", "错误"], data.runs.map((row) => [ - row.id, esc(row.job_id), esc(row.state), esc(row.started_at), esc(row.finished_at), esc(row.error), - ]))} - `; - page.querySelectorAll("[data-run]").forEach((btn) => { - btn.addEventListener("click", async () => { - const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || ""; - await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) }); - render(); - }); - }); - return; - } - if (state.page === "release") { - const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); - const data = await api(`/admin/api/batches?date=${date}`); - page.innerHTML = ` -

盘后发布 ${esc(data.trade_date)}

-
- - - -
-

当前映射

- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), - row.prev_batch ? `` : "-", - ]))} -

批次

- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ - esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), - ]))} - `; - $bindRelease(page); - return; - } - if (state.page === "datasets") { - const data = await api("/admin/api/datasets?date="); - page.innerHTML = ` -

数据集 / 质量 ${esc(data.trade_date)}

- ${table(["数据集", "批次", "状态", "发布时间"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at), - ]))} -

源间差异

- ${table(["指标", "左", "右", "偏差", "样本"], data.diff_reports.map((row) => [ - esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "", - ]))} - `; - return; - } - if (state.page === "audit") { - const data = await api("/admin/api/audit"); - page.innerHTML = `

审计

` + table( - ["时间", "操作者", "动作", "对象", "详情"], - data.items.map((row) => [esc(row.created_at), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail)]), - ); +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 · 四源汇流 · 持续运转"; } -function $bindRelease(page) { - page.querySelector("#rel-load").addEventListener("click", async () => { - const date = page.querySelector("#rel-date").value; - const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); - state.page = "release"; - // re-render with fetched date by writing location hash - history.replaceState(null, "", `#release-${date}`); - $("page").innerHTML = renderRelease(data); - $bindRelease($("page")); - }); - page.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill")); - page.querySelectorAll("[data-rollback]").forEach((btn) => { - btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback)); - }); +/* ========================================================================== + 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; } -function renderRelease(data) { +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(data.trade_date)}

-
- - - +

总览 · ${esc(d.trade_date)}

+
${esc(PHASE_LABELS[d.session_phase] || d.session_phase)} · 源共 ${d.source_count}
+
+
交易日
${esc(d.trade_date)}
+
阶段
${esc(PHASE_LABELS[d.session_phase] || d.session_phase)}
+
今日发布
${d.publications.length}
+
盘后补跑
${esc(EOD_LABELS[eod.state] || eod.state || "-")}
${eodExtra.join(" · ")}
+
估值复核
${esc(REV_LABELS[rev.state] || rev.state || "-")}
${revExtra.join(" · ")}
+
异常批次
${d.anomalies.length}
-

当前映射

- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), - row.prev_batch ? `` : "-", - ]))} -

批次

- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ - esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), +

最近调用(tushare)

+ ${table(["时间", "源", "端点", "结果", "耗时"], d.recent_calls.map((row) => [ + esc(timeShort(row.created_at)), esc(row.provider), esc(row.endpoint), + row.ok ? '成功' : `${esc(row.error)}`, + `${row.latency_ms ?? "-"} ms`, ]))} `; } +/* ---- 1 数据源 ---- */ +function cabinSources() { + const d = state.data.sources; + if (!d) return { eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的", sub: "正在加载真实状态…", rows: [], actions: [] }; + const live = d.items.filter((it) => FLOW_PROVIDERS.includes(it.provider)); + const reserved = d.items.filter((it) => !FLOW_PROVIDERS.includes(it.provider)); + const rows = live.map((it) => { + const h = it.health || {}; + const okish = h.state === "ok" || h.state === "empty"; + return [FLOW_LABEL[it.provider] || it.provider, `${h.latency_ms ?? "-"} ms · ${esc(h.state || "-")}`, okish ? "ok" : "warn"]; + }); + rows.push([`预留源 ${reserved.length} 个`, "未接入 · 无真实调用", "warn"]); + return { + eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的", + sub: "点击任一端口双灯,详情从它的空间位置展开。灯闪代表真实探测或调用刚发生。", + rows, + actions: [{ label: "查看全部来源", cls: "pri", action: () => Detail.open("sources") }], + }; +} +function detailSources() { + const d = state.data.sources; + if (!d) return `

数据源

正在加载…
`; + const html = `

数据源

LINK 常亮表示健康;ACT 只在真实探测/调用发生时短闪
` + + table(["源", "角色", "状态", "凭据", "操作"], d.items.map((item) => { + const cred = item.credential || {}; + const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置"; + return [ + esc(item.provider), esc(item.role), + esc((item.health && (item.health.state || item.health.status)) || "-"), + credText, + ``, + ]; + })); + return html; +} +function bindSourcesDetail(root) { + root.querySelectorAll("[data-probe]").forEach((btn) => { + btn.addEventListener("click", async () => { + const provider = btn.dataset.probe; + btn.disabled = true; + try { + const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" }); + if (FLOW_PROVIDERS.includes(provider)) { + const ok = result.state === "ok" || result.state === "empty"; + fireSource(provider, 1, ok ? "ok" : "error"); + } + alert(JSON.stringify(result)); + await pollSources(); + Detail.reopen(); + } finally { btn.disabled = false; } + }); + }); +} + +/* ---- 2 调度任务 ---- */ +function cabinJobs() { + const d = state.data.jobs; + if (!d) return { eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力", sub: "正在加载真实状态…", rows: [], actions: [] }; + const runs = d.runs || []; + const latest = runs[0]; + const failedRecent = runs.slice(0, 20).filter((r) => r.state === "failed").length; + return { + eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力", + sub: "环上的每个节点对应一个真实定时任务;节点接力亮起来自最近一次真实运行。", + rows: [ + ["最近运行", latest ? `${esc(latest.job_id)} · ${esc(latest.state)}` : "暂无", latest && latest.state === "failed" ? "fail" : "ok"], + ["最近 20 次失败", `${failedRecent}`, failedRecent ? "fail" : "ok"], + ["任务总数", `${d.jobs.length}`, "ok"], + ], + actions: [{ label: "查看调度日志", cls: "pri", action: () => Detail.open("jobs") }], + }; +} +function detailJobs() { + const d = state.data.jobs; + if (!d) return `

调度任务

正在加载…
`; + return ` +

调度任务

+ ${table(["任务", "时刻", "操作"], d.jobs.map((job) => [ + `${esc(job.id)} · ${esc(job.title)}`, esc(job.at), + ``, + ]))} +

最近运行

+ ${table(["ID", "任务", "状态", "开始", "结束", "错误"], d.runs.map((row) => [ + row.id, esc(row.job_id), + `${esc(row.state)}`, + esc(timeShort(row.started_at)), esc(timeShort(row.finished_at)), esc(row.error || ""), + ]))} + `; +} +function bindJobsDetail(root) { + root.querySelectorAll("[data-run]").forEach((btn) => { + btn.addEventListener("click", async () => { + const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || ""; + btn.disabled = true; + try { + await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) }); + await pollJobs(); + Detail.reopen(); + } finally { btn.disabled = false; } + }); + }); +} + +/* ---- 3 盘后发布 ---- */ +function cabinRelease() { + const d = state.data.batches; + if (!d) return { eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布", sub: "正在加载真实状态…", rows: [], actions: [] }; + const pubs = d.publications || []; + const rollbacks = pubs.filter((p) => p.prev_batch).length; + const failedBatches = (d.batches || []).filter((b) => b.state === "failed").length; + return { + eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布", + sub: `${d.trade_date} · RX 批次穿过三层,TX 给出回执;校验失败走红色回滚分叉。`, + rows: [ + ["活跃发布", `${pubs.length} 个数据集`, "ok"], + ["可回滚", `${rollbacks}`, rollbacks ? "warn" : "ok"], + ["批次异常", `${failedBatches}`, failedBatches ? "fail" : "ok"], + ], + actions: [{ label: "查看批次与发布", cls: "pri", action: () => Detail.open("release") }], + }; +} +function detailRelease() { + const d = state.data.batches; + if (!d) return `

盘后发布

正在加载…
`; + return ` +

盘后发布 ${esc(d.trade_date)}

+
+ + + +
+

当前映射

+ ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], d.publications.map((row) => [ + esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), + row.prev_batch ? `` : "-", + ]))} +

批次

+ ${table(["batch_id", "数据集", "状态", "行数", "错误"], d.batches.map((row) => [ + esc(row.batch_id), esc(row.dataset), + `${esc(row.state)}`, + row.rows_out ?? "", esc(row.error || ""), + ]))} + `; +} +function bindReleaseDetail(root) { + root.querySelector("#rel-load").addEventListener("click", async () => { + state.releaseDate = root.querySelector("#rel-date").value.trim(); + await pollBatches(); + Detail.reopen(); + }); + root.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill")); + root.querySelectorAll("[data-rollback]").forEach((btn) => { + btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback)); + }); +} async function dangerous(kind, dataset) { - const date = ($("rel-date") && $("rel-date").value) || ""; + const date = state.releaseDate || ""; const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference)", "daily"); if (!ds) return; const password = prompt("二次确认:输入管理密码"); if (!password) return; const confirmWord = `${ds}:${date}`; const typed = prompt(`请输入确认词:${confirmWord}`); + if (!typed) return; const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill"; - await api(path, { - method: "POST", - body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }), + try { + await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) }); + if (kind === "rollback") Lamps.tx.push(1, "rollback"); + await pollBatches(); + await pollAudit(); + Detail.reopen(); + } catch (err) { + alert(err.message); + } +} + +/* ---- 4 数据集 ---- */ +function cabinDatasets() { + const d = state.data.datasets; + if (!d) return { eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间", sub: "正在加载真实状态…", rows: [], actions: [] }; + const pubs = d.publications || []; + return { + eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间", + sub: `${d.trade_date} · 核心面片翻开成数据集,稳定显示日期与状态。`, + rows: pubs.slice(0, 4).map((p) => [esc(p.dataset), `${esc(p.state)} · ${esc(timeShort(p.published_at))}`, p.state === "published" ? "ok" : "warn"]), + actions: [{ label: "查看差异报告", cls: "pri", action: () => Detail.open("datasets") }], + }; +} +function detailDatasets() { + const d = state.data.datasets; + if (!d) return `

数据集

正在加载…
`; + return ` +

数据集 / 质量 ${esc(d.trade_date)}

+ ${table(["数据集", "批次", "状态", "发布时间"], d.publications.map((row) => [ + esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at), + ]))} +

源间差异

+ ${table(["指标", "左", "右", "偏差", "样本"], d.diff_reports.map((row) => [ + esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "", + ]))} + `; +} + +/* ---- 5 审计 ---- */ +function cabinAudit() { + const d = state.data.audit; + if (!d) return { eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕", sub: "正在加载真实状态…", rows: [], actions: [] }; + const items = d.items || []; + const latest = items[0]; + return { + eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕", + sub: "输出流在核心后方留下轨迹,发布、操作、回滚沿轨迹展开。", + rows: latest ? [[esc(latest.actor), `${esc(latest.action)} · ${esc(timeShort(latest.created_at))}`, String(latest.action).includes("rollback") ? "warn" : "ok"]] : [], + actions: [{ label: `查看审计(共 ${items.length} 条)`, cls: "pri", action: () => Detail.open("audit") }], + }; +} +function detailAudit() { + const d = state.data.audit; + if (!d) return `

审计

正在加载…
`; + return `

审计

共 ${d.items.length} 条 · 最新在前
` + + table(["时间", "操作者", "动作", "对象", "详情"], d.items.map((row) => [ + esc(timeShort(row.created_at)), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail), + ])); +} + +const SCENE_CABIN = [cabinOverview, cabinSources, cabinJobs, cabinRelease, cabinDatasets, cabinAudit]; +const SCENE_DETAIL = [detailOverview, detailSources, detailJobs, detailRelease, detailDatasets, detailAudit]; +const SCENE_BIND = [null, bindSourcesDetail, bindJobsDetail, bindReleaseDetail, null, null]; + +const Detail = { + openKey: null, + open(key) { + const idx = SCENES.findIndex((s) => s.key === key); + if (idx < 0) return; + this.openKey = key; + if (state.reduced) return; // 静态版直接内嵌显示,无需抽屉 + Stage.renderDetail(idx); + }, + reopen() { if (this.openKey && !state.reduced) this.open(this.openKey); }, + close() { this.openKey = null; if (!state.reduced) Stage.closeDetail(); }, +}; + +const Nav = { + go(idx) { + if (state.reduced) { StaticShell.scrollTo(idx); return; } + Stage.scrollToScene(idx); + }, +}; + +/* ========================================================================== + canvas 3D 引擎(动效版)——移植自经白栖知确认的第七版原稿, + 静态数据换成真实拉取结果,随机模拟事件全部替换为真实事件驱动。 + ========================================================================== */ +const Stage = (() => { + let canvas, ctx, W = 0, H = 0, DPR = 1; + let running = false, rafId = null; + let PAL, C; + const CAM = { yaw: 0, pitch: .12, dist: 5.4, cx: 0, cy: 0, fov: 1.9 }; + const T0 = performance.now(); + const now = () => performance.now(); + + function initPalette() { + PAL = { + night: { bgA: "#05080F", bgB: "#0A1322", cyan: "#2FD8CE", blue: "#4A86FF", amber: "#FFB84D", red: "#FF6B6B", + ink: "#E8EEFC", dim: "#9AA6C4", face: "rgba(58,96,180,", edge: "rgba(140,190,255,", slab: "rgba(47,216,206,", ring: "rgba(120,170,255,", + chan: ["rgba(150,205,255,", "rgba(120,180,255,", "rgba(185,175,255,", "rgba(255,214,160,"] }, + day: { bgA: "#E8ECF5", bgB: "#F7F9FF", cyan: "#0A9C93", blue: "#2F63D6", amber: "#B97A0E", red: "#D64F4F", + ink: "#1B2547", dim: "#5B6785", face: "rgba(120,160,235,", edge: "rgba(47,99,214,", slab: "rgba(10,156,147,", ring: "rgba(47,99,214,", + chan: ["rgba(47,99,214,", "rgba(10,124,146,", "rgba(109,93,214,", "rgba(190,120,10,"] }, + }; + C = PAL[document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"]; + } + + const V = (x, y, z) => ({ x, y, z }); + function rotY(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x * c + p.z * s, p.y, -p.x * s + p.z * c); } + function rotX(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x, p.y * c - p.z * s, p.y * s + p.z * c); } + function bez(p0, p1, p2, p3, t) { + const u = 1 - t; + return V( + u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x, + u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y, + u * u * u * p0.z + 3 * u * u * t * p1.z + 3 * u * t * t * p2.z + t * t * t * p3.z, + ); + } + function project(p) { + let q = rotY(V(p.x - CAM.cx, p.y - CAM.cy, p.z), CAM.yaw); + q = rotX(q, -CAM.pitch); + const s = CAM.fov / Math.max(.3, CAM.dist - q.z); + return { x: W / 2 + q.x * s * W * .5, y: H / 2 - q.y * s * W * .5, z: q.z, s }; + } + function roundRect(x, y, w, h, r) { + ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); + } + const setAdd = () => { ctx.globalCompositeOperation = (C === PAL.night ? "lighter" : "source-over"); }; + function polyGlow(pts, color, alpha, width, dashPhase) { + ctx.save(); setAdd(); + if (C !== PAL.night) alpha = Math.min(1, alpha * 1.5); + for (let i = 0; i < pts.length - 1; i++) { + const a = project(pts[i]), b = project(pts[i + 1]); + const depth = Math.max(.15, Math.min(1, 1.35 - (a.z + 3) / 6)); + let al = alpha * depth; + if (dashPhase !== null) al *= .35 + .65 * Math.max(0, Math.sin((i / pts.length) * 14 - dashPhase)); + ctx.strokeStyle = color + al.toFixed(3) + ")"; + ctx.lineWidth = width * (a.s * 2.2); ctx.lineCap = "round"; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + } + ctx.restore(); + } + function dot3(p, r, color, alpha, glow) { + const q = project(p); + const depth = Math.max(.2, Math.min(1, 1.35 - (q.z + 3) / 6)); + const rr = r * q.s * 2.4; + ctx.save(); setAdd(); + if (C !== PAL.night) alpha = Math.min(1, alpha * 1.35); + if (glow) { + const g = ctx.createRadialGradient(q.x, q.y, 0, q.x, q.y, rr * 3.2); + g.addColorStop(0, color + (.5 * alpha * depth) + ")"); g.addColorStop(1, color + "0)"); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(q.x, q.y, rr * 3.2, 0, 7); ctx.fill(); + } + ctx.fillStyle = color + (alpha * depth) + ")"; + ctx.beginPath(); ctx.arc(q.x, q.y, rr, 0, 7); ctx.fill(); + ctx.restore(); + return q; + } + /* A2 双灯:LINK 反映真实连通状态(常亮)+ ACT 事件驱动短闪 */ + function lamp3(p, level, scale, kind, linkState) { + const q = project(p); + const u = Math.max(.7, q.s * 2.2) * (scale || 1); + const x = q.x, y = q.y, w = 13 * u, h = 5 * u, gap = 4 * u; + ctx.save(); + ctx.globalAlpha = .9; + ctx.fillStyle = C === PAL.night ? "rgba(6,12,24,.78)" : "rgba(255,255,255,.92)"; + ctx.strokeStyle = C.edge + ".5)"; ctx.lineWidth = 1; + roundRect(x - w / 2 - 4 * u, y - h - gap / 2 - 4 * u, w + 8 * u, h * 2 + gap + 8 * u, 5 * u); ctx.fill(); ctx.stroke(); + const linkColor = linkState === "ok" ? C.cyan : linkState === "error" ? C.red : C.dim; + const linkAlpha = linkState === "ok" ? .6 : linkState === "error" ? .55 : .25; + ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor; ctx.shadowBlur = linkState === "ok" || linkState === "error" ? 6 * u : 0; + roundRect(x - w / 2, y - h - gap / 2, w, h, h / 2); ctx.fill(); + ctx.shadowBlur = 0; + const actColor = kind === "error" || kind === "rollback" ? C.red : C.amber; + if (level > 0) { + ctx.globalAlpha = .3 + .7 * level; ctx.fillStyle = actColor; ctx.shadowColor = actColor; ctx.shadowBlur = 18 * u * level; + } else { ctx.globalAlpha = .18; ctx.fillStyle = C.amber; } + roundRect(x - w / 2, y + gap / 2, w, h, h / 2); ctx.fill(); + ctx.restore(); + return q; + } + + /* ---------- 六幕相机关键帧(纯几何/镜头语言,已由白栖知确认) ---------- */ + const CH = [ + { yaw: .00, pitch: .13, dist: 4.7, cx: 0, cy: 0, scale: 1.18, explode: .14, ring: 1.0, spread: 1.0, unfold: 0, trail: 0 }, + { yaw: .62, pitch: .17, dist: 3.7, cx: -.55, cy: .12, scale: .98, explode: .14, ring: .8, spread: 2.0, unfold: 0, trail: 0 }, + { yaw: 1.18, pitch: .30, dist: 4.2, cx: 0, cy: -.1, scale: .72, explode: .1, ring: .95, spread: .9, unfold: 0, trail: 0 }, + { yaw: 1.72, pitch: .06, dist: 4.0, cx: .1, cy: .24, scale: .9, explode: .7, ring: .6, spread: .75, unfold: 0, trail: 0 }, + { yaw: 2.38, pitch: .15, dist: 4.3, cx: 0, cy: 0, scale: 1.0, explode: .22, ring: 1.0, spread: .9, unfold: 1, trail: 0 }, + { yaw: 2.92, pitch: .10, dist: 4.6, cx: .4, cy: 0, scale: .95, explode: .16, ring: 1.0, spread: 1.0, unfold: 0, trail: 1 }, + ]; + function smooth(t) { return t * t * (3 - 2 * t); } + function lerpK(a, b, f) { const o = {}; for (const k in a) o[k] = a[k] + (b[k] - a[k]) * f; return o; } + function chapterAt(p) { + const i = clamp(Math.floor(p), 0, 4), f = smooth(clamp(p - i, 0, 1)); + return { ...lerpK(CH[i], CH[i + 1], f), idx: clamp(Math.round(p), 0, 5) }; + } + + /* ---------- 几何:核心 / 端口 / 流道 / 环 ---------- */ + const PORTS = [V(-1.02, .52, .18), V(-1.05, -.42, .38), V(-.5, .98, -.4), V(-.48, -.92, -.42)]; + const SRC_FAR = [V(-3.4, 1.9, -1.6), V(-3.6, -1.7, -.8), V(-2.3, 2.6, -2.4), V(-2.2, -2.7, -2.0)]; + const TX_PORT = V(1.02, .18, .05), RX_PORT = V(.98, -.4, .1); + const TX_FAR = V(5.6, 1.4, -1.6), AUD_FAR = V(4.6, -2.4, .6); + function chanPts(from, to, spread, lift) { + const a = V(from.x * spread, from.y * spread, from.z); + const c1 = V(a.x * .55, a.y * .8 + lift, a.z * .7), c2 = V(to.x * 2.0, to.y * 1.5, to.z * 1.6); + const pts = []; for (let i = 0; i <= 42; i++) pts.push(bez(a, c1, c2, to, i / 42)); + return pts; + } + function getChannels(spread) { + const chans = FLOW_PROVIDERS.map((_, i) => chanPts(SRC_FAR[i], PORTS[i], spread, i % 2 ? -.5 : .5)); + chans.tx = chanPts(TX_FAR, TX_PORT, 1, .2).reverse(); + chans.aud = chanPts(AUD_FAR, RX_PORT, 1, -.2).reverse(); + return chans; + } + + const CUBE_F = [ + { n: "来源", key: "sources", idx: [0, 1, 3, 2], nor: V(-1, 0, 0) }, + { n: "发布", key: "release", idx: [4, 6, 7, 5], nor: V(1, 0, 0) }, + { n: "调度", key: "jobs", idx: [2, 3, 7, 6], nor: V(0, 1, 0) }, + { n: "暂存", key: "release2", idx: [0, 4, 5, 1], nor: V(0, -1, 0) }, + { n: "数据集", key: "datasets", idx: [1, 5, 7, 3], nor: V(0, 0, 1) }, + { n: "审计", key: "audit", idx: [0, 2, 6, 4], nor: V(0, 0, -1) }, + ]; + const CUBE_V = []; for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1]) CUBE_V.push(V(x * .5, y * .5, z * .5)); + function facePoint(f, u, v, sc, explode) { + const nor = f.nor, ex = explode * .5; + let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0); + let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x); + let base = V(nor.x * (.5 + ex), nor.y * (.5 + ex), nor.z * (.5 + ex)); + return V( + (base.x + t1.x * u * .5 + t2.x * v * .5) * sc, + (base.y + t1.y * u * .5 + t2.y * v * .5) * sc, + (base.z + t1.z * u * .5 + t2.z * v * .5) * sc, + ); + } + function faceSummary(f) { + const s = state.data; + if (f.key === "sources") { + const items = (s.sources && s.sources.items || []).filter((it) => FLOW_PROVIDERS.includes(it.provider)); + const ok = items.filter((it) => it.health && (it.health.state === "ok" || it.health.state === "empty")).length; + return `${ok}/${items.length || 4} 已连接`; + } + if (f.key === "release" || f.key === "release2") { + const pubs = (s.batches && s.batches.publications) || []; + if (f.key === "release2") { const staged = ((s.batches && s.batches.batches) || []).filter((b) => b.state === "staged").length; return `队列 ${staged}`; } + const latest = pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop(); + return latest ? String(latest.active_batch) : "暂无发布"; + } + if (f.key === "jobs") { const runs = (s.jobs && s.jobs.runs) || []; const today = todayYmd(); const n = runs.filter((r) => String(r.started_at || "").replace(/-/g, "").startsWith(today.slice(0, 8))).length; return `今日运行 ${n}`; } + if (f.key === "datasets") { const pubs = (s.datasets && s.datasets.publications) || []; return `${pubs.length} 套`; } + if (f.key === "audit") { const items = (s.audit && s.audit.items) || []; return `留痕 ${items.length}`; } + return ""; + } + function drawCore(sc, explode, unfold, vt) { + const faces = CUBE_F.map((f, fi) => { + const corners = f.idx.map((i) => { + const vtx = CUBE_V[i]; const nor = f.nor; + let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0); + let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x); + const u = 2 * (vtx.x * t1.x + vtx.y * t1.y + vtx.z * t1.z), v = 2 * (vtx.x * t2.x + vtx.y * t2.y + vtx.z * t2.z); + return facePoint(f, u, v, sc, explode); + }); + const pr = corners.map(project); + const zc = pr.reduce((a, p) => a + p.z, 0) / 4; + const c3 = corners.reduce((a, p) => V(a.x + p.x / 4, a.y + p.y / 4, a.z + p.z / 4), V(0, 0, 0)); + const facing = rotY(V(f.nor.x, f.nor.y, f.nor.z), CAM.yaw).z > .12; + return { f, fi, pr, zc, c3, facing }; + }).sort((a, b) => b.zc - a.zc); + // 内部层片:校验 / 暂存 / 发布 + const slabNames = ["校验", "暂存", "发布"]; + for (let s = 0; s < 3; s++) { + const y = (s - 1) * (.3 + explode * .42) * sc, hw = .36 * sc, hh = .07 * sc; + const sv = [V(-hw, y - hh, -hw), V(hw, y - hh, -hw), V(hw, y - hh, hw), V(-hw, y - hh, hw), + V(-hw, y + hh, -hw), V(hw, y + hh, -hw), V(hw, y + hh, hw), V(-hw, y + hh, hw)]; + const edges = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]]; + ctx.save(); + const fillA = .10 + explode * .10; + const top = [4, 5, 6, 7].map((i) => project(sv[i])); + ctx.fillStyle = C.slab + fillA + ")"; + ctx.beginPath(); top.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = C.slab + ".5)"; ctx.lineWidth = 1; + edges.forEach((e) => { const a = project(sv[e[0]]), b = project(sv[e[1]]); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }); + if (explode > .45) { + const c = project(V(hw * 1.15, y, 0)); + ctx.fillStyle = C.ink; ctx.globalAlpha = .9; ctx.font = `600 ${Math.max(10, 11 * c.s * 2)}px "Noto Sans SC"`; + ctx.fillText(slabNames[s], c.x + 8, c.y + 4); ctx.globalAlpha = 1; + } + ctx.restore(); + } + faces.forEach((fc) => { + const { pr, f, facing } = fc; + const depth = Math.max(.15, Math.min(1, 1.2 - (fc.zc + 2.5) / 5)); + ctx.save(); + const g = ctx.createLinearGradient(pr[0].x, pr[0].y, pr[2].x, pr[2].y); + g.addColorStop(0, C.face + ((facing ? .20 : .06) * depth + .03) + ")"); + g.addColorStop(.55, C.face + ((facing ? .07 : .02) * depth + .015) + ")"); + g.addColorStop(1, C.face + ((facing ? .14 : .04) * depth + .02) + ")"); + ctx.fillStyle = g; + ctx.beginPath(); pr.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill(); + if (facing) { ctx.shadowColor = C.edge + ".9)"; ctx.shadowBlur = 9; } + ctx.strokeStyle = C.edge + ((facing ? .8 : .3) * depth + .12) + ")"; ctx.lineWidth = facing ? 1.4 : 1; ctx.stroke(); + ctx.shadowBlur = 0; + if (facing && unfold < .5) { + const c = project(fc.c3); const fs = Math.max(11, 15 * c.s * 2); + ctx.globalAlpha = .55 + .45 * depth; ctx.fillStyle = C.ink; ctx.font = `600 ${fs}px "Noto Sans SC"`; ctx.textAlign = "center"; + ctx.fillText(f.n, c.x, c.y - fs * .3); + ctx.globalAlpha *= .7; ctx.font = `${fs * .74}px "DejaVu Sans Mono"`; ctx.fillStyle = C.cyan; + ctx.fillText(faceSummary(f), c.x, c.y + fs * .75); + ctx.textAlign = "left"; ctx.globalAlpha = 1; + } + ctx.restore(); + }); + Stage.faceCenters = faces.filter((f) => f.facing).map((fc) => ({ q: project(fc.c3), fi: fc.fi })); + ctx.save(); setAdd(); + const b0 = project(V(0, -.8 * sc, 0)), b1 = project(V(0, .8 * sc, 0)); + const bg2 = ctx.createLinearGradient(b0.x, b0.y, b1.x, b1.y); + const beamA = C === PAL.night ? .5 : .3; + bg2.addColorStop(0, C.slab + "0)"); bg2.addColorStop(.5, C.slab + beamA + ")"); bg2.addColorStop(1, C.slab + "0)"); + ctx.strokeStyle = bg2; ctx.lineCap = "round"; ctx.lineWidth = Math.max(3, 9 * b0.s * 2); + ctx.beginPath(); ctx.moveTo(b0.x, b0.y); ctx.lineTo(b1.x, b1.y); ctx.stroke(); + dot3(V(0, 0, 0), 4, C === PAL.night ? "rgba(190,240,255," : C.slab, .8, true); + ctx.restore(); + } + function drawRing(radius, tilt, vt) { + const pts = []; + for (let i = 0; i <= 72; i++) { const a = i / 72 * Math.PI * 2; pts.push(rotX(V(Math.cos(a) * radius, 0, Math.sin(a) * radius), tilt)); } + polyGlow(pts, C.ring, .5, 1.1, vt * 1.2); + } + function drawTasksRing(radius, tilt) { + const jobsData = state.data.jobs; if (!jobsData) return; + const jobs = jobsData.jobs || [], runs = jobsData.runs || []; + const latestByJob = new Map(); + for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r); + jobs.forEach((job, i) => { + const a0 = i / jobs.length * Math.PI * 2 + Math.PI * .1; + let p = V(Math.cos(a0) * radius, 0, Math.sin(a0) * radius); p = rotX(p, tilt); + const run = latestByJob.get(job.id); + const st = run ? run.state : "never"; + const color = st === "failed" ? "rgba(255,107,107," : st === "ok" ? "rgba(47,216,206," : st === "running" ? "rgba(255,184,77," : "rgba(150,160,190,"; + const q = dot3(p, st === "never" ? 3 : 4.6, color, st === "never" ? .4 : .9, st !== "never"); + const isFresh = (Stage.freshJobRuns || []).some((r) => r.job_id === job.id); + if (isFresh) lamp3(p, Lamps.junction.level(now()), .78, st === "failed" ? "error" : "ok"); + ctx.save(); ctx.globalAlpha = .55; ctx.fillStyle = st === "failed" ? C.red : C.dim; + ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.textAlign = "center"; + ctx.fillText(job.title.slice(0, 8), q.x, q.y - 13 * q.s * 2); ctx.textAlign = "left"; ctx.restore(); + }); + } + const flowSeeds = FLOW_PROVIDERS.map(() => Array.from({ length: 24 }, (_, i) => ({ o: i / 24, w: .6 + (i % 5) * .12 }))); + function drawFlows(chans, vt, alpha) { + FLOW_PROVIDERS.forEach((provider, i) => { + const pts = chans[i]; + polyGlow(pts, C.chan[i], .5 * alpha, 2.2, vt * .0016 * (1.2 + i * .18)); + flowSeeds[i].forEach((p) => { + const u = (p.o + vt * .00006 * p.w) % 1; + const pt = pts[Math.floor(u * (pts.length - 1))]; + dot3(pt, 2.6 * p.w, C.chan[i], .95 * alpha, p.w > 1.05); + }); + }); + polyGlow(chans.tx, C.slab, .5 * alpha, 2.4, vt * .0022); + polyGlow(chans.aud, C.edge, .3 * alpha, 1.6, vt * .0016); + } + const TRAVEL_IN = 900, TRAVEL_OUT = 750; + function drawPackets(chans) { + const nowMs = performance.now(); + for (let k = PACKETS.length - 1; k >= 0; k--) { + const pk = PACKETS[k]; const dt = nowMs - pk.t0; + if (dt > TRAVEL_IN + TRAVEL_OUT + 400) { PACKETS.splice(k, 1); continue; } + const col = pk.rb ? "rgba(255,107,107," : "rgba(255,214,150,"; + if (dt < TRAVEL_IN) { + const u = dt / TRAVEL_IN, pts = chans[pk.src]; + for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.4 - j * .42, col, .9 - j * .13, j === 0); } + } else { + const u = (dt - TRAVEL_IN) / TRAVEL_OUT, pts = pk.rb ? chans.aud : chans.tx; + for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.2 - j * .4, col, .85 - j * .12, j === 0); } + } + } + } + function drawAuditTrail(amp) { + const items = ((state.data.audit && state.data.audit.items) || []).slice(0, 5); + if (amp < .05 || !items.length) return; + ctx.save(); ctx.globalAlpha = amp; + items.forEach((a, i) => { + const t = i / Math.max(1, items.length - 1); + const ang = Math.PI * (.25 + t * .85), r = 2.6 + t * .9; + const p = V(Math.cos(ang) * r, .9 - t * 1.9, -1.6 - Math.sin(ang) * 1.2); + const q = project(p); + const col = String(a.action).includes("rollback") ? C.red : C.cyan; + ctx.globalAlpha = amp * (1 - t * .55); + ctx.fillStyle = col; ctx.beginPath(); ctx.arc(q.x, q.y, 4 * q.s * 2, 0, 7); ctx.fill(); + ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.fillStyle = C.ink; + ctx.fillText(`${a.action} · ${a.target || ""}`.slice(0, 22), q.x + 12, q.y + 3); + ctx.font = `${Math.max(8, 9 * q.s * 2)}px "DejaVu Sans Mono"`; ctx.fillStyle = C.dim; + ctx.fillText(timeShort(a.created_at), q.x + 12, q.y + 15); + }); + ctx.restore(); + } + function drawSourceLabels(alpha) { + if (alpha < .05) return; + ctx.save(); ctx.globalAlpha = alpha; + Stage.portScreen.forEach((q, i) => { + if (!q || q.hide) return; + const provider = FLOW_PROVIDERS[i]; + const info = ((state.data.sources && state.data.sources.items) || []).find((it) => it.provider === provider) || {}; + const health = info.health || {}; + const OFF = [{ dx: 34, dy: -78 }, { dx: 34, dy: 34 }, { dx: -230, dy: -92 }, { dx: -230, dy: 48 }][i]; + let x = q.x + OFF.dx, y = q.y + OFF.dy; + x = clamp(x, 320, W - 216); y = clamp(y, 14, H - 96); + ctx.fillStyle = C.face + ".14)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1; + roundRect(x, y, 196, 62, 9); ctx.fill(); ctx.stroke(); + ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(FLOW_LABEL[provider] || provider, x + 11, y + 20); + ctx.fillStyle = C.dim; ctx.font = "10.5px \"DejaVu Sans Mono\""; + ctx.fillText(`延迟 ${health.latency_ms ?? "-"}ms · ${health.state || "-"}`, x + 11, y + 37); + ctx.fillStyle = C.cyan; ctx.fillText(info.role || "", x + 11, y + 53); + ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(q.x + (OFF.dx > 0 ? 10 : -10), q.y); ctx.lineTo(OFF.dx > 0 ? x : x + 196, y + 31); ctx.stroke(); + }); + ctx.restore(); + } + function drawPublishLabels(alpha) { + if (alpha < .05) return; + ctx.save(); ctx.globalAlpha = alpha; + const rx = project(RX_PORT), tx = project(TX_PORT); + ctx.font = "600 11px \"Noto Sans SC\""; + ctx.fillStyle = C.cyan; ctx.fillText("RX 批次入口", rx.x - 70, rx.y + 34); + ctx.fillStyle = C.ink; ctx.fillText("TX 发布回执", tx.x + 16, tx.y - 10); + ctx.fillStyle = C.red; ctx.fillText("回滚分叉", tx.x + 60, tx.y + 66); + ctx.restore(); + } + function drawDatasetCards(alpha, coreX, coreY) { + if (alpha < .05 || !Stage.faceCenters.length) return; + const datasets = (state.data.datasets && state.data.datasets.publications) || []; + ctx.save(); ctx.globalAlpha = alpha; + Stage.faceCenters.forEach((fc) => { + const ds = datasets[fc.fi]; if (!ds) return; + let dx = fc.q.x - coreX, dy = fc.q.y - coreY; const len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len; + let x = fc.q.x + dx * 110 - 75, y = fc.q.y + dy * 80 - 30; + x = clamp(x, 316, W - 170); y = clamp(y, 14, H - 84); + ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(fc.q.x, fc.q.y); ctx.lineTo(x + 75, y + 30); ctx.stroke(); + ctx.fillStyle = C.face + ".16)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1; + roundRect(x, y, 150, 60, 9); ctx.fill(); ctx.stroke(); + ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(ds.dataset, x + 11, y + 19); + ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\""; ctx.fillText(`${ds.active_batch} · ${ds.state}`, x + 11, y + 34); + }); + ctx.restore(); + } + + const publicApi = { + faceCenters: [], portScreen: [], freshJobRuns: [], + init() { + canvas = $("scene"); ctx = canvas.getContext("2d"); + initPalette(); + addEventListener("resize", resize); + resize(); + buildCabinShell(); + canvas.addEventListener("click", onCanvasClick); + $("detailClose").addEventListener("click", () => Detail.close()); + // 相机位置由 render() 每帧直接读取 scrollY 计算,天然免疫快速/反向滚动排队问题, + // 无需额外监听 scroll 事件。 + }, + onThemeChange(theme) { initPalette(); }, + start() { if (!running) { running = true; scheduleNav(); loop(); } }, + resume() { this.start(); }, + pause() { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = null; }, + stop() { this.pause(); }, + scrollToScene(idx) { + const track = $("track"); + const secH = track.offsetHeight / 6; + scrollTo({ top: track.offsetTop + idx * secH, behavior: "smooth" }); + }, + renderDetail(idx) { + const body = SCENE_DETAIL[idx](); + $("detailBody").innerHTML = body; + if (SCENE_BIND[idx]) SCENE_BIND[idx]($("detailBody")); + positionDetail(idx); + $("detail").classList.remove("closed"); + }, + closeDetail() { $("detail").classList.add("closed"); }, + }; + + function scheduleNav() { + document.querySelectorAll("#rail .stop").forEach((el) => { + el.onclick = () => Nav.go(+el.dataset.i); + }); + } + function buildCabinShell() { + $("rail").innerHTML = SCENES.map((s, i) => `
${esc(s.label)}
`).join('
'); + scheduleNav(); + } + function resize() { + if (!canvas) return; + DPR = Math.min(devicePixelRatio || 1, 2); + const r = canvas.parentElement.getBoundingClientRect(); + W = r.width; H = r.height; + canvas.width = W * DPR; canvas.height = H * DPR; + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + } + function positionDetail(idx) { + const el = $("detail"); + let anchor = null; + if (idx === 1 && Stage.portScreen.length) anchor = Stage.portScreen.find((q) => q && !q.hide); + if (anchor) { + el.style.left = clamp(anchor.x + 34, 20, W - 380) + "px"; + el.style.top = clamp(anchor.y - 40, 16, H - 260) + "px"; + } else { + el.style.left = Math.max(20, W / 2 - 180) + "px"; + el.style.top = "20px"; + } + } + function onCanvasClick(e) { + const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; + // 优先命中当前可见的来源端口(仅在数据源幕出现),否则命中画面中心的机芯本体—— + // 两种情况都应该打开“当前所在的那一幕”的详情,而不是硬编码成数据源。 + let best = -1, bd = 34 * 34; + Stage.portScreen.forEach((q, i) => { if (!q || q.hide) return; const d = (q.x - x) ** 2 + (q.y - y) ** 2; if (d < bd) { bd = d; best = i; } }); + const coreHit = Math.hypot(x - W / 2, y - H / 2) < Math.min(W, H) * 0.3; + if (best >= 0 || coreHit) Detail.open(SCENES[state.scene].key); + else Detail.close(); + } + + let cabinIdx = -1; + function renderCabin(idx) { + const c = SCENE_CABIN[idx](); + const el = $("cabin"); + el.innerHTML = `
${esc(c.eyebrow)}

${esc(c.title)}

${esc(c.sub)}
+
${c.rows.map((r) => `
${esc(r[0])}${esc(r[1])}
`).join("")}
+
${c.actions.map((a, i) => ``).join("")}
`; + el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; }); + } + function syncUI(idx, p) { + if (idx !== cabinIdx) { + const el = $("cabin"); + el.classList.add("hide"); + setTimeout(() => { renderCabin(idx); el.classList.remove("hide"); }, 180); + cabinIdx = idx; + document.querySelectorAll("#rail .stop").forEach((el2, i) => el2.classList.toggle("on", i === idx)); + $("hint").classList.toggle("off", p > .15); + $("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`; + $("phase").textContent = `${idx + 1} / ${SCENES.length}`; + state.scene = idx; + } else { + renderCabin(idx); // 刷新真实数据但不重触发进出动画 + } + if (Detail.openKey) Detail.reopen(); + } + Views.onChange(() => { + if (running && cabinIdx >= 0) renderCabin(cabinIdx); + if (Detail.openKey && !$("detail").classList.contains("closed")) Detail.reopen(); }); - render(); + + function render() { + if (!canvas) return; + const vt = performance.now() - T0; + const max = Math.max(1, document.body.scrollHeight - innerHeight); + const p = clamp((scrollY / max) * 5, 0, 5); + const K = chapterAt(p); + CAM.yaw = K.yaw + (vt / 1000) * .05; CAM.pitch = K.pitch; CAM.dist = K.dist; CAM.cx = K.cx; CAM.cy = K.cy; + + const bg = ctx.createRadialGradient(W * .5, H * .46, 60, W * .5, H * .5, Math.max(W, H) * .75); + bg.addColorStop(0, C.bgB); bg.addColorStop(1, C.bgA); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + ctx.save(); setAdd(); + const fl = ctx.createRadialGradient(W * .5, H * .8, 10, W * .5, H * .8, W * .24); + fl.addColorStop(0, C.face + ".13)"); fl.addColorStop(1, C.face + "0)"); + ctx.fillStyle = fl; ctx.beginPath(); ctx.ellipse(W * .5, H * .8, W * .24, H * .09, 0, 0, 7); ctx.fill(); + ctx.restore(); + + const chans = getChannels(K.spread); + drawFlows(chans, vt, 1); + drawRing(1.9 * K.ring, .5, vt); + if (K.idx === 2) drawTasksRing(1.9 * K.ring, .5); + drawCore(K.scale, K.explode, K.unfold, vt); + drawRing(2.05 * K.ring, -.5, vt); + drawPackets(chans); + + const nowMs = performance.now(); + Stage.portScreen = PORTS.map((pt, i) => { + const wp = V(pt.x * K.scale, pt.y * K.scale, pt.z * K.scale); + const behind = rotY(wp, CAM.yaw).z < -.25; + const provider = FLOW_PROVIDERS[i]; + const lv = Lamps[provider].level(nowMs) * (behind ? .25 : 1); + const q = lamp3(wp, lv, 1, Lamps[provider].activeKind(nowMs), Lamps[provider].link); + q.hide = behind; return q; + }); + lamp3(V(.35 * K.scale, -.15 * K.scale, .5 * K.scale), Lamps.junction.level(nowMs), .85, Lamps.junction.activeKind(nowMs), "ok"); + lamp3(V(TX_PORT.x * K.scale, TX_PORT.y * K.scale, TX_PORT.z * K.scale), Lamps.tx.level(nowMs), .9, Lamps.tx.activeKind(nowMs), "ok"); + + drawSourceLabels(K.idx === 1 ? Math.min(1, K.spread - 1) : 0); + drawPublishLabels(K.idx === 3 ? K.explode : 0); + drawDatasetCards(K.unfold > .4 ? Math.min(1, (K.unfold - .4) * 2.5) : 0, W / 2, H * .46); + drawAuditTrail(K.trail); + + Object.values(Lamps).forEach((ch) => { if (ch instanceof LampChannel) ch.prune(nowMs); }); + syncUI(K.idx, p); + } + function loop() { + if (!running) return; + render(); + rafId = requestAnimationFrame(loop); + } + + return publicApi; +})(); + +/* ========================================================================== + 减少动态效果版:六幕静态区块,正常滚动,无位移/旋转/闪烁,信息与功能等价。 + ========================================================================== */ +const StaticShell = (() => { + let mounted = false; + function drawStaticDiagram(canvasEl, seedAngle) { + const ctx = canvasEl.getContext("2d"); + const W = canvasEl.width = canvasEl.clientWidth * 2; + const H = canvasEl.height = canvasEl.clientHeight * 2; + const night = document.documentElement.getAttribute("data-theme") === "night"; + ctx.fillStyle = night ? "#0A1020" : "#F1F4FC"; + ctx.fillRect(0, 0, W, H); + ctx.save(); + ctx.translate(W / 2, H / 2); + ctx.rotate(seedAngle); + ctx.strokeStyle = night ? "rgba(140,190,255,.55)" : "rgba(47,99,214,.5)"; + ctx.lineWidth = 2; + const s = Math.min(W, H) * .16; + ctx.strokeRect(-s, -s, s * 2, s * 2); + ctx.beginPath(); ctx.moveTo(-s, -s); ctx.lineTo(s * .4, -s * .5); ctx.lineTo(s * .4, s * .5); ctx.lineTo(-s, s); ctx.closePath(); ctx.stroke(); + ctx.restore(); + // 四条固定流向的直线(静态,不闪烁不移动) + const colors = night ? ["#96CDFF", "#78B4FF", "#B9AFFF", "#FFD6A0"] : ["#2F63D6", "#0A7C92", "#6D5DD6", "#BE780A"]; + for (let i = 0; i < 4; i++) { + const y = H * (.2 + i * .18); + ctx.strokeStyle = colors[i]; ctx.globalAlpha = .55; ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(6, y); ctx.lineTo(W * .42, H / 2); ctx.stroke(); + ctx.globalAlpha = 1; + } + ctx.globalAlpha = 1; + } + function lampChipHTML(provider) { + const lamp = Lamps[provider]; + const linkOn = lamp.link === "ok"; + const recent = Date.now() - lamp.lastAt < 8000; + const label = FLOW_LABEL[provider] || provider; + return `${esc(label)} + ${lamp.lastAt ? `· ACT ${Math.max(0, Math.round((Date.now() - lamp.lastAt) / 1000))}s 前` : '· 无最近事件'}`; + } + function sceneBlockHTML(idx) { + const s = SCENES[idx]; + const cab = SCENE_CABIN[idx](); + return ` +
+
${esc(cab.eyebrow)}

${esc(cab.title)}

+ +
${esc(cab.sub)}
+ ${idx === 1 ? `
${FLOW_PROVIDERS.map(lampChipHTML).join("")}
` : ""} +
${cab.rows.map((r) => `
${esc(r[0])}
${esc(r[1])}
`).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) => `
${esc(r[0])}
${esc(r[1])}
`).join(""); + if (idx === 1) { const lr = section.querySelector(".lamprow"); if (lr) lr.innerHTML = FLOW_PROVIDERS.map(lampChipHTML).join(""); } + const detailEl = $(`static-detail-${idx}`); + if (detailEl) { detailEl.innerHTML = SCENE_DETAIL[idx](); rebindScene(idx); } + } + function mount() { + if (mounted) return; + mounted = true; + $("staticNav").innerHTML = SCENES.map((s, i) => ``).join(""); + $("staticNav").querySelectorAll("button").forEach((btn, i) => { + btn.addEventListener("click", () => { setActive(i); scrollToInternal(i); }); + }); + $("staticScenes").innerHTML = SCENES.map((_, i) => sceneBlockHTML(i)).join(""); + SCENES.forEach((_, i) => { drawStaticDiagram($(`diagram-${i}`), i * .5); rebindScene(i); }); + setActive(0); + Views.onChange(refreshAll); + } + function refreshAll() { if (mounted) SCENES.forEach((_, i) => rerenderScene(i)); } + function setActive(i) { + $("staticNav").querySelectorAll("button").forEach((b, j) => b.classList.toggle("active", j === i)); + state.scene = i; + $("crumb").textContent = `8766 · ${SCENES[i].label} · 持续运转`; + $("phase").textContent = `${i + 1} / ${SCENES.length}`; + } + function scrollToInternal(i) { + const el = document.querySelector(`.scene-static[data-scene="${i}"]`); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + function unmount() { /* CSS 控制显隐,无需清空内容,避免频繁重建 DOM */ } + return { mount, unmount, scrollTo: scrollToInternal }; +})(); + +/* ========================================================================== + 启动 + ========================================================================== */ +function enterShell() { + Stage.init(); + applyReduced(REDUCE_MQ.matches); + const savedTheme = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })(); + applyTheme(savedTheme === "night" ? "night" : "day"); + Poller.startAll(); + updateCrumbStatus(); } boot(); diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html index b7eb893..5d6a164 100644 --- a/xiaobai-datahub/admin/index.html +++ b/xiaobai-datahub/admin/index.html @@ -3,14 +3,15 @@ - xiaobai-datahub 管理后台 + xiaobai-datahub 管理后台 · 数据中枢
+ 内网 · 8766

数据中枢

-

内网管理后台,用于查看源状态、调度和盘后发布批次。

+

四路来源持续汇流、调度、发布与审计的运转空间。

@@ -30,22 +31,46 @@
diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css index 1631ac1..e62ead7 100644 --- a/xiaobai-datahub/admin/styles.css +++ b/xiaobai-datahub/admin/styles.css @@ -1,51 +1,286 @@ +/* xiaobai-datahub 数据中枢后台 —— 第七版「轨道机芯」 + 统一动效 Token:进入/退出用强 ease-out,屏内移动用强 ease-in-out,持续流动用 linear。 + 禁止 ease-in、禁止 transition:all —— 全文件遵守。 */ :root { color-scheme: light; - --bg: #f4f5f7; - --surface: #ffffff; - --text: #1f2329; - --muted: #646a73; - --line: #dee0e3; - --action: #3370ff; - --danger: #e04536; - --ok: #16a34a; - --warn: #b45309; - --radius: 8px; + --bar-h: 52px; + --radius: 14px; --pad: 16px; - font-family: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif; + --font-cn: "Segoe UI", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; + --font-mono: "JetBrains Mono", "DejaVu Sans Mono", Consolas, monospace; + --ease-out: cubic-bezier(.22, 1, .36, 1); + --ease-in-out: cubic-bezier(.65, 0, .35, 1); + --ease-linear: linear; + + /* day (default) */ + --bg0: #EDF0F7; + --bg1: #F8FAFF; + --surface: #ffffff; + --stage-edge: rgba(47, 99, 214, .16); + --text: #1B2547; + --muted: #5B6785; + --faint: #93A0BD; + --line: rgba(47, 99, 214, .18); + --action: #2F63D6; + --cyan: #0A9C93; + --amber: #B97A0E; + --danger: #D64F4F; + --ok: #1E9E6A; + --warn: #B97A0E; + --glass: rgba(255, 255, 255, .82); + --chip: rgba(47, 99, 214, .08); + --face: rgba(120, 160, 235,); + --edge: rgba(47, 99, 214,); + --slab: rgba(10, 156, 147,); + --ring: rgba(47, 99, 214,); + --chan1: rgba(47, 99, 214,); + --chan2: rgba(10, 124, 146,); + --chan3: rgba(109, 93, 214,); + --chan4: rgba(190, 120, 10,); } :root[data-theme="night"] { color-scheme: dark; - --bg: #111318; - --surface: #1b1e24; - --text: #e8eaed; - --muted: #9aa0a6; - --line: #2a2f38; - --action: #5b8cff; + --bg0: #05080F; + --bg1: #0A1020; + --surface: #12172A; + --stage-edge: rgba(90, 140, 255, .14); + --text: #E8EEFC; + --muted: #8B97B8; + --faint: #59637F; + --line: rgba(120, 160, 255, .16); + --action: #4A86FF; + --cyan: #2FD8CE; + --amber: #FFB84D; + --danger: #FF6B6B; + --ok: #3ED598; + --warn: #FFB84D; + --glass: rgba(13, 20, 38, .72); + --chip: rgba(74, 134, 255, .10); + --face: rgba(58, 96, 180,); + --edge: rgba(140, 190, 255,); + --slab: rgba(47, 216, 206,); + --ring: rgba(120, 170, 255,); + --chan1: rgba(150, 205, 255,); + --chan2: rgba(120, 180, 255,); + --chan3: rgba(185, 175, 255,); + --chan4: rgba(255, 214, 160,); } + * { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--text); } -.panel, header.top, nav, main { background: var(--surface); } -.auth-panel { max-width: 420px; margin: 12vh auto; padding: 28px; border-radius: var(--radius); border: 1px solid var(--line); } -label { display: block; margin: 12px 0; } -input, select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--text); } -button { background: var(--action); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; } -button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); } -button.danger { background: var(--danger); } +html, body { margin: 0; background: var(--bg0); color: var(--text); font-family: var(--font-cn); } +body { overflow-x: hidden; } +.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .muted { color: var(--muted); } .error { color: var(--danger); } -.top { display: flex; gap: 12px; align-items: center; padding: 10px var(--pad); border-bottom: 1px solid var(--line); } -nav { display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); } -nav button { background: transparent; color: var(--muted); } -nav button.active { color: var(--action); background: transparent; font-weight: 600; } -main { padding: var(--pad); min-height: calc(100vh - 96px); } -.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; } -.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; } -table { width: 100%; border-collapse: collapse; font-size: 13px; } -th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; } -.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); } .ok { color: var(--ok); } .warn { color: var(--warn); } .fail { color: var(--danger); } -.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: end; } -.toolbar label { margin: 0; } + +/* ---------- 登录 / 改密(未进入舞台的前置流程) ---------- */ +.auth-panel { + max-width: 420px; margin: 12vh auto; padding: 30px 32px; border-radius: var(--radius); + background: var(--glass); border: 1px solid var(--line); backdrop-filter: blur(14px); + box-shadow: 0 30px 70px -32px rgba(20, 30, 60, .35); +} +.auth-panel h1 { margin: 0 0 6px; font-size: 21px; } +.auth-panel label { display: block; margin: 12px 0; font-size: 13px; color: var(--muted); } +.auth-panel input { + width: 100%; margin-top: 6px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; + background: var(--chip); color: var(--text); font-family: var(--font-cn); +} +.auth-panel input:focus { outline: 2px solid var(--action); outline-offset: 1px; } +.auth-panel .badge-sim { display: inline-block; margin-bottom: 10px; } + +button { font-family: var(--font-cn); cursor: pointer; } +.btn, button.primary, .auth-panel button[type="submit"] { + background: linear-gradient(135deg, var(--action), var(--cyan)); color: #fff; border: 0; border-radius: 8px; + padding: 8px 16px; font-size: 13px; transition: transform .12s var(--ease-out), opacity .12s var(--ease-out); +} +.btn:active, button:active { transform: translateY(1px); } +.btn.ghost, button.ghost { + background: var(--chip); color: var(--text); border: 1px solid var(--line); +} +.btn.warn { background: transparent; color: var(--warn); border: 1px solid var(--warn); } +.btn.danger, button.danger { background: var(--danger); color: #fff; border: 0; } +.btn.pri { background: linear-gradient(135deg, var(--action), var(--cyan)); color: #fff; border: 0; } +.btn { padding: 6px 13px; border-radius: 8px; font-size: 12px; } + +.badge-sim { + font-size: 11px; padding: 3px 10px; border-radius: 99px; letter-spacing: .1em; + color: var(--amber); border: 1px solid var(--amber); opacity: .85; +} + +/* ---------- 通用表格 / 卡片(cabin、detail、reduced 三处共用同一来源) ---------- */ +table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +th, td { text-align: left; padding: 7px 6px; border-bottom: 1px dashed var(--line); vertical-align: top; } +th { color: var(--faint); font-weight: 500; font-size: 11px; letter-spacing: .04em; } +.pill { font-size: 12px; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 14px; } +.card { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: var(--chip); } +.card strong { font-size: 18px; } +.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0; align-items: center; } +.toolbar label { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; } +.toolbar input { padding: 6px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--chip); color: var(--text); } +h2 { font-size: 16px; margin: 4px 0 10px; } +h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); } + +/* ---------- 顶部总控条 ---------- */ +#topbar { + height: var(--bar-h); display: flex; align-items: center; gap: 14px; padding: 0 22px; + border-bottom: 1px solid var(--line); background: linear-gradient(180deg, var(--bg1), transparent); + backdrop-filter: blur(10px); + position: sticky; top: 0; z-index: 4; +} +#topbar .logo { font-weight: 700; font-size: 15px; letter-spacing: .04em; } +#topbar .logo em { font-style: normal; color: var(--cyan); } +#topbar .crumb { font-size: 12px; color: var(--faint); } +#topbar .spacer { flex: 1; } +#topbar .who { font-size: 12px; } + +/* ---------- 固定空间舞台(76vh,六幕连续场景) ---------- + #track 的六段高幕(每段 130vh)在它身后正常参与文档流把页面撑高, + #stageWrap 用 sticky 钉在视口里,滚动时只有 #track 的高度被“划过”, + 舞台本体在这段距离内始终可见、位置不变,直到 #track 撑出的空间耗尽才随之离场。 */ +#stageWrap { + flex: none; height: 76vh; min-height: 480px; margin: 0 18px; border-radius: 18px; + overflow: hidden; border: 1px solid var(--stage-edge); + box-shadow: 0 30px 80px -30px rgba(0, 0, 0, .45), inset 0 0 120px rgba(0, 0, 0, .08); + position: sticky; top: var(--bar-h); z-index: 2; +} +#scene { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; } + +/* ---------- 控制舱:当前场景的关键数据与操作 ---------- */ +#cabin { + position: absolute; left: 22px; top: 22px; width: 300px; z-index: 5; max-height: calc(100% - 44px); + overflow: auto; background: var(--glass); border: 1px solid var(--line); border-radius: 14px; + backdrop-filter: blur(14px); padding: 16px 18px; + transition: opacity .45s var(--ease-out), transform .45s var(--ease-out); +} +#cabin.hide { opacity: 0; transform: translateX(-14px); pointer-events: none; } +#cabin .eyebrow { font-size: 10px; letter-spacing: .22em; color: var(--cyan); margin-bottom: 6px; } +#cabin h2 { font-size: 18px; margin: 0 0 4px; } +#cabin .sub { font-size: 11.5px; color: var(--muted); line-height: 1.6; margin-bottom: 10px; } +#cabin .rows { display: flex; flex-direction: column; gap: 7px; margin-bottom: 4px; } +#cabin .row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } +#cabin .row b { color: var(--text); font-weight: 600; } +#cabin .row .fill { flex: 1; } +#cabin .dotlamp { width: 7px; height: 7px; border-radius: 99px; background: var(--ok); box-shadow: 0 0 8px var(--ok); flex: none; } +#cabin .dotlamp.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn); } +#cabin .dotlamp.fail { background: var(--danger); box-shadow: 0 0 8px var(--danger); } +#cabin .actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } + +/* ---------- 底部章节轨(导航等同滚动跳转) ---------- */ +#rail { + position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 5; + display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-radius: 99px; + background: var(--glass); border: 1px solid var(--line); backdrop-filter: blur(14px); +} +#rail .stop { + display: flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 99px; cursor: pointer; + font-size: 11.5px; color: var(--faint); background: transparent; border: 0; + transition: color .25s var(--ease-in-out), background .25s var(--ease-in-out); + font-family: var(--font-cn); white-space: nowrap; +} +#rail .stop i { width: 6px; height: 6px; border-radius: 99px; background: var(--faint); transition: background .25s var(--ease-in-out), box-shadow .25s var(--ease-in-out); display: inline-block; } +#rail .stop.on { color: var(--text); background: var(--chip); } +#rail .stop.on i { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); } +#rail .sep { width: 12px; height: 1px; background: var(--line); } + +/* ---------- 图例 ---------- */ +#legend { + position: absolute; right: 22px; top: 22px; z-index: 5; font-size: 10.5px; color: var(--muted); + background: var(--glass); border: 1px solid var(--line); border-radius: 12px; padding: 10px 13px; + backdrop-filter: blur(14px); line-height: 1.9; +} +#legend .lampico { display: inline-block; width: 14px; height: 5px; border-radius: 99px; vertical-align: middle; margin-right: 6px; } +#legend .li-link { background: rgba(47, 216, 206, .5); box-shadow: 0 0 6px rgba(47, 216, 206, .6); } +#legend .li-act { background: var(--amber); box-shadow: 0 0 8px var(--amber); } + +/* ---------- 详情面板:从端口/来源空间位置展开的功能抽屉 ---------- */ +#detail { + position: absolute; z-index: 8; width: 360px; max-width: calc(100% - 40px); max-height: calc(100% - 40px); + overflow: auto; pointer-events: auto; background: var(--glass); border: 1px solid var(--line); + border-radius: 14px; backdrop-filter: blur(16px); padding: 16px 18px; + transition: transform .4s var(--ease-out), opacity .3s var(--ease-out); + box-shadow: 0 24px 60px -20px rgba(0, 0, 0, .45); +} +#detail.closed { transform: scale(.14); opacity: 0; pointer-events: none; } +#detail h3 { font-size: 14px; margin: 0 0 2px; color: var(--text); } +#detail .dsub { font-size: 10.5px; color: var(--faint); margin-bottom: 10px; letter-spacing: .04em; } +#detailClose { + position: absolute; right: 10px; top: 10px; width: 24px; height: 24px; border-radius: 8px; + border: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; +} +#detail .dtag { + position: absolute; left: -7px; top: 26px; width: 14px; height: 14px; transform: rotate(45deg); + background: var(--glass); border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); +} + +/* ---------- 滚动提示 ---------- */ +#hint { + position: absolute; left: 50%; bottom: 100px; transform: translateX(-50%); z-index: 5; + font-size: 11px; letter-spacing: .3em; color: var(--faint); transition: opacity .5s var(--ease-out); +} +#hint.off { opacity: 0; } + +/* 滚动章节占位:仅提供滚动行程,无可见内容 */ +#track { position: relative; z-index: 1; pointer-events: none; } +#track section { height: 130vh; } + +/* ---------- 危险操作 / 确认弹层,复用 dialog ---------- */ dialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 20px; } + +/* ================================================================ + 减少动态效果(prefers-reduced-motion):舞台切换为六幕静态空间图 + 固定文字状态 + 信息与功能与动效版完全一致,只是去掉滚动镜头、旋转与闪烁。 + ================================================================ */ +#staticShell { display: none; } +html.reduced #stageWrap, html.reduced #track, html.reduced #hint { display: none !important; } +html.reduced #staticShell { display: block; } + +#staticNav { + display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); + background: var(--surface); flex-wrap: wrap; position: sticky; top: 0; z-index: 3; +} +#staticNav button { + background: transparent; color: var(--muted); border: 0; padding: 7px 12px; border-radius: 8px; font-size: 13px; +} +#staticNav button.active { color: var(--action); font-weight: 600; background: var(--chip); } + +.scene-static { padding: var(--pad); border-bottom: 1px solid var(--line); } +.scene-static .scene-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; } +.scene-static .scene-head .eyebrow { font-size: 11px; letter-spacing: .18em; color: var(--cyan); } +.scene-static .diagram { + width: 100%; max-width: 620px; height: 190px; border-radius: 12px; border: 1px solid var(--line); + background: var(--chip); display: block; margin-bottom: 12px; +} +.scene-static .lamprow { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; } +.lampchip { + display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted); + border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; background: var(--chip); +} +.lampchip .lk, .lampchip .ac { width: 7px; height: 7px; border-radius: 99px; background: var(--faint); display: inline-block; } +.lampchip .lk.on { background: var(--cyan); box-shadow: 0 0 5px var(--cyan); } +.lampchip .ac.on { background: var(--amber); box-shadow: 0 0 6px var(--amber); } +.lampchip .ac.recent { outline: 1px solid var(--amber); transition: outline-color .8s var(--ease-out); } + +/* ---------- 主内容区(main/reduced 内容容器统一样式,供动效版 detail 与静态版共用) ---------- */ +main#page { padding: var(--pad); min-height: calc(100vh - 96px); background: var(--surface); } + +/* ---------- 响应式:1440 / 1280 / 1024 常见桌面宽 ---------- */ +@media (max-width: 1280px) { + #cabin { width: 260px; } + #detail { width: 320px; } +} +@media (max-width: 1100px) { + #stageWrap { margin: 0 10px; } + #cabin { width: 230px; padding: 13px 14px; } + #legend { display: none; } +} + +/* ---------- 系统级兜底:即便 JS 未及时接管,也不留半成品动效 ---------- */ +@media (prefers-reduced-motion: reduce) { + #stageWrap, #track, #hint { display: none !important; } + #staticShell { display: block !important; } + * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } +}