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 `${thead}${body}
`; -} - -/* ---------------------------------------------------------------- 登录 / 会话 */ -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(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}
-
-

最近调用(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) => `
${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/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 `${thead}${body}
`; + } + + 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 @@ -
- + + + + + + + diff --git a/xiaobai-datahub/admin/layouts/flowline.css b/xiaobai-datahub/admin/layouts/flowline.css new file mode 100644 index 0000000..683392f --- /dev/null +++ b/xiaobai-datahub/admin/layouts/flowline.css @@ -0,0 +1,97 @@ +/* A · 装配线 Flowline —— 来源→加工→发布→审计 四工位水平因果链 + 只写这个布局独有的排布;颜色/组件规则一律来自 tokens.css + shared.css。 */ +#fl-root { padding: var(--sp4); max-width: 1584px; margin: 0 auto; } + +#fl-band { position: relative; padding: 14px 16px 18px; margin-bottom: 14px; overflow: hidden; } +#fl-band .lane-labels { display: flex; } +#fl-band .lane-labels span { flex: 1; } +#fl-wire { position: relative; height: 1px; background: var(--line); margin: 22px 0 18px; } +#fl-wire .chev { position: absolute; top: -8px; font-size: 13px; color: var(--t3); font-family: var(--font-mono); transform: translateX(-50%); } + +#fl-lanes { display: grid; grid-template-columns: 2fr 1.15fr 1.05fr 0.85fr; gap: 14px; align-items: start; } +/* 防止内部宽表格用 min-width:auto 撑爆 1fr 轨道,参见 ledger.css 同类注释。 */ +#fl-lanes > *, #fl-cols > * { min-width: 0; } +#fl-srcCards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.fl-src-card { position: relative; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; overflow: hidden; } +.fl-src-card .row { display: flex; align-items: center; gap: 8px; } +.fl-src-card .name { font-size: 14px; font-weight: 600; color: var(--t1); } +.fl-src-card .role { font-size: 11px; color: var(--t3); } +.fl-src-card .grow { flex: 1; } +.fl-src-card .lat { font-size: 12px; color: var(--t2); } +.fl-src-card .cred { font-size: 11px; color: var(--t3); } +.fl-src-card .lamps { display: flex; align-items: center; gap: 4px; } +.fl-src-card .edge { + position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px; background: var(--act); + border-radius: 2px; opacity: 0; transition: opacity var(--dur-ui) var(--ease-out); +} +.fl-src-card .edge.err { background: var(--error); } + +.fl-station { padding: 12px 14px; min-height: 150px; position: relative; transition: box-shadow var(--dur-ui) var(--ease-out); } +.fl-station.pulse { box-shadow: 0 0 0 2px var(--action-soft) inset; } +.fl-station .hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.fl-station .hd .nm { font-size: 13px; font-weight: 600; color: var(--t1); } +.fl-station .hd .grow { flex: 1; } +.fl-station .task { font-size: 12px; color: var(--t2); margin-bottom: 8px; } +.fl-station .task b { color: var(--t1); font-weight: 600; } +.fl-pbar { position: relative; height: 4px; border-radius: 2px; background: var(--bg2); overflow: hidden; margin-bottom: 6px; } +.fl-pbar > i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--action); border-radius: 2px; transition: width var(--dur-ui) var(--ease-out); } +.fl-station .queue { font-size: 12px; color: var(--t2); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; } +.fl-miniruns { border-top: 1px solid var(--line-soft); padding-top: 6px; } +.fl-miniruns .mr { display: flex; gap: 8px; font-size: 11px; line-height: 1.7; } +.fl-miniruns .mr .id { color: var(--t3); width: 34px; } +.fl-miniruns .mr .why { color: var(--error); } + +.fl-gate .bignum { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; } +.fl-gate .bignum .n { font-size: var(--fs-hero); font-weight: 600; color: var(--t1); display: inline-block; transition: transform var(--dur-ui) var(--ease-out); } +.fl-gate .bignum .cap { font-size: 12px; color: var(--t3); } +.fl-gate .latest { font-size: 12px; color: var(--t2); margin-bottom: 10px; } +.fl-gate .valrow { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--t2); } + +.fl-audit .al { font-size: 11px; line-height: 1.75; color: var(--t2); white-space: normal; word-break: break-all; } +.fl-audit .al .dim { color: var(--t3); } +.fl-audit .adiv { border-top: 1px solid var(--line-soft); margin: 6px 0; } + +#fl-packet { + position: absolute; width: 34px; height: 3px; border-radius: 2px; background: var(--packet); + opacity: 0; pointer-events: none; z-index: 5; top: 0; left: 0; +} + +#fl-cols { display: grid; grid-template-columns: 1.15fr 1.1fr 1fr; gap: 14px; } +.fl-col { padding: 12px 14px; } +.fl-col .sec-label { display: block; margin-bottom: 8px; } +.fl-col .sub-label { display: block; margin: 12px 0 4px; } +#fl-callsTable td.res-ok { color: var(--t2); } +#fl-callsTable td.res-err { color: var(--error); } +#fl-callsFoot { margin-top: 8px; font-size: 11px; color: var(--t3); border-top: 1px solid var(--line-soft); padding-top: 8px; } +.fl-newrow { transition: background var(--dur-ui) var(--ease-out); } +.fl-newrow.flashin { background: var(--alive-soft); } + +.fl-jobrow { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line-soft); } +.fl-jobrow .jid { font-size: 12px; color: var(--t1); } +.fl-jobrow .jti { font-size: 12px; color: var(--t2); } +.fl-jobrow .jat { font-size: 11px; color: var(--t3); } +.fl-jobrow .grow { flex: 1; } + +#fl-col3 .toolrow { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +#fl-col3 .toolrow input { + font-family: var(--font-mono); font-size: 12px; color: var(--t1); background: var(--bg2); + border: 1px solid var(--line); border-radius: var(--r-chip); padding: 5px 8px; width: 96px; outline: none; +} +.fl-anom { margin-top: 10px; } +tr.fl-rowflash td { transition: background var(--dur-ui) var(--ease-out); } +tr.fl-rowflash.on td { background: var(--alive-soft); } + +/* ---------------- 1280 ---------------- */ +@media (max-width: 1280px) { + #fl-lanes { grid-template-columns: 1fr; } + #fl-srcCards { grid-template-columns: 1fr 1fr; } + #fl-cols { grid-template-columns: 1fr 1fr; } + #fl-col3 { grid-column: 1 / -1; } +} +/* ---------------- 1024 ---------------- */ +@media (max-width: 1100px) { + #fl-root { padding: var(--sp3); } + #fl-srcCards { grid-template-columns: 1fr; } + #fl-cols { grid-template-columns: 1fr; } + .fl-src-card .role, .fl-src-card .cred { display: none; } +} diff --git a/xiaobai-datahub/admin/layouts/flowline.js b/xiaobai-datahub/admin/layouts/flowline.js new file mode 100644 index 0000000..4ace376 --- /dev/null +++ b/xiaobai-datahub/admin/layouts/flowline.js @@ -0,0 +1,300 @@ +"use strict"; +/* ========================================================================== + A · 装配线 Flowline —— 来源 → 加工 → 发布 → 审计 四工位水平因果链。 + 数据卡是线上工件,真实事件沿发丝导线从触发的那一工位接力到下一工位。 + ========================================================================== */ +(function () { + const C = window.Core, S = window.HubShared; + const { $, esc, timeShort } = C; + let root = null; + let unsubs = []; + let stationFlashTimers = {}; + let mounted = false; + let pendingTimers = []; + /* 布局随时可能被切走:任何跨阶段延时回调都必须先确认还挂载着, + 否则 unmount 之后残留的 setTimeout 会在 root=null 时报错。 */ + function safeTimeout(fn, ms) { + const id = setTimeout(() => { if (mounted) fn(); }, ms); + pendingTimers.push(id); + return id; + } + + const SKELETON = ` +
+
+
+ 来源 SOURCES + 加工 PROCESS + 发布 PUBLISH + 审计 AUDIT +
+
+
+
+
+
中枢处理-
+
+
+
+
发布闸口GATE
+
+
+
+
审计末端
+
+
+
+
+
+ +
+
+ 最近调用 RECENT CALLS +
+
+
+
+ 调度任务 JOBS +
+ 最近运行 RUNS +
+
+
+ 盘后发布 RELEASE +
+ + + +
+ 当前映射 MAPPING +
+
+
+
+
+ `; + + function srcCardHtml(item) { + const health = item.health || {}; + const link = item._link || C.linkState(health); + const cred = item.credential || {}; + const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置"; + return ` +
+
+
+ ${esc(C.FLOW_LABEL[item.provider] || item.provider)} + ${esc(C.FLOW_ROLE[item.provider] || item.role)} + + ${C.chipHtml(link, health.state || link)} +
+
+ LINK + ACT + + ${health.latency_ms == null ? "-" : health.latency_ms + "ms"} +
+
+ ${credText} + +
+
`; + } + + function renderSources() { + const v = S.sourcesView(); + const host = $("fl-srcCards"); + if (!v) { host.innerHTML = `
正在加载…
`; return; } + host.innerHTML = v.live.map(srcCardHtml).join("") + + (v.reserved.length ? `
预留源 ${v.reserved.length} 个 · 未接入 · 无真实调用
` : ""); + S.bindProbeButtons(host); + } + + function renderProcess() { + const jv = S.jobsView(); + const body = $("fl-processBody"); + const pctEl = root.querySelector('[data-pct]'); + if (!jv || !jv.latest) { body.innerHTML = `
正在加载…
`; return; } + const latest = jv.latest; + const running = jv.runs.filter((r) => r.state === "running").length; + const pct = latest.state === "running" ? 62 : latest.state === "success" ? 100 : latest.state === "failed" ? 100 : 0; + pctEl.textContent = latest.state === "running" ? `${pct}%` : "空闲"; + body.innerHTML = ` +
${esc(latest.job_id)} · ${esc((jv.jobs.find((j) => j.id === latest.job_id) || {}).title || "")}
+
+
队列 ${running} · 最近 20 次失败 ${jv.failedRecent}
+
+ ${jv.runs.slice(0, 3).map((r) => `
${r.id}${esc(r.state)}${esc(r.error || (r.finished_at !== "-" && r.finished_at ? timeShort(r.finished_at) : "—"))}
`).join("")} +
`; + } + + function renderPublish() { + const rv = S.releaseView(); + const body = $("fl-publishBody"); + if (!rv) { body.innerHTML = `
正在加载…
`; return; } + const valuation = rv.pubs.find((p) => p.dataset === "valuation"); + const latestPub = rv.pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop(); + body.innerHTML = ` +
${rv.pubs.length}今日发布
+
最新批次 ${esc(latestPub ? `${latestPub.active_batch} · ${latestPub.published_at}` : "暂无")}
+ ${valuation ? `
valuation${C.chipHtml(valuation.state)}
` : ""}`; + } + + function renderAudit() { + const av = S.auditView(); + const body = $("fl-auditBody"); + if (!av || !av.items.length) { body.innerHTML = `
正在加载…
`; return; } + const a0 = av.items[0]; + body.innerHTML = ` +
${esc(timeShort(a0.created_at))} ${esc(a0.actor)} · ${esc(a0.action)}
+
${esc(a0.target)}${a0.detail ? " · " + esc(a0.detail) : ""}
+
+ ${av.items.slice(1, 3).map((a) => `
${esc(timeShort(a.created_at))} ${esc(a.actor)} · ${esc(a.action)}
`).join("")}`; + } + + function renderCalls() { + const ov = S.overviewView(); + const sv = S.sourcesView(); + if (!ov) { $("fl-callsTable").innerHTML = `
正在加载…
`; return; } + $("fl-callsTable").innerHTML = C.table(["时间", "源", "端点", "结果", "耗时"], ov.recentCalls.map((row) => [ + `${esc(timeShort(row.created_at))}`, + `${esc(row.provider)}`, + `${esc(row.endpoint)}`, + row.ok ? '成功' : `${esc(row.error)}`, + `${row.latency_ms ?? "-"} ms`, + ])); + if (sv) { + $("fl-callsFoot").textContent = "今日调用 · " + sv.live.map((s) => `${s.provider} ${s.calls_today ?? 0}`).join(" · "); + } + } + + function renderJobsCol() { + const jv = S.jobsView(); + const jobRows = $("fl-jobRows"); + if (!jv) { jobRows.innerHTML = `
正在加载…
`; return; } + jobRows.innerHTML = jv.jobs.map((job) => ` +
+ ${esc(job.id)}${esc(job.title)} + ${esc(job.at)}${C.chipHtml(jv.latestByJob.get(job.id) ? jv.latestByJob.get(job.id).state : "idle")} + +
`).join(""); + S.bindRunButtons(jobRows); + $("fl-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], jv.runs.map((r) => [ + `${r.id}`, `${esc(r.job_id)}`, + `${esc(r.state)}`, + `${esc(timeShort(r.started_at))}`, `${esc(timeShort(r.finished_at))}`, + `${esc(r.error || "")}`, + ])); + } + + function renderReleaseCol() { + const rv = S.releaseView(); + const dateInput = $("fl-rel-date"); + if (!dateInput.value) dateInput.value = C.state.releaseDate; + if (!rv) { $("fl-pubTable").innerHTML = `
正在加载…
`; return; } + $("fl-pubTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], rv.pubs.map((p) => [ + `${esc(p.dataset)}`, `${esc(p.active_batch)}`, + `${esc(p.prev_batch || "-")}`, C.chipHtml(p.state), + `${esc(p.published_at)}`, + p.prev_batch ? `` : "-", + ])); + S.bindRollbackButtons($("fl-pubTable"), () => renderReleaseCol()); + const failed = rv.batches.filter((b) => b.state === "failed" || b.state === "error"); + $("fl-anom").innerHTML = failed.length + ? failed.map((b) => `${esc(b.batch_id)} · ${esc(b.dataset)} · ${esc(b.error || "异常")}`).join(" ") + : `批次全部正常`; + } + + function refresh() { + if (!root) return; + renderSources(); renderProcess(); renderPublish(); renderAudit(); + renderCalls(); renderJobsCol(); renderReleaseCol(); + } + + /* ---------------------------------------------------------------- 真实事件动效 + 水平接力:source ACT 闪 → 沿导线光梭滑到 process → process 高亮 → 光梭滑到 + publish → publish 高亮 → 光梭滑到 audit → audit 高亮。reduced 时只做状态高亮, + 不做位移。 */ + function flashSource(provider, kind) { + const card = root.querySelector(`.fl-src-card[data-provider="${provider}"]`); + if (!card) return; + const act = card.querySelector("[data-act]"); + act.classList.remove("on", "err"); + act.classList.add("on"); if (kind === "error" || kind === "rollback") act.classList.add("err"); + const edge = card.querySelector(".edge"); + edge.classList.toggle("err", kind === "error"); + edge.style.opacity = "1"; + clearTimeout(card._flashTimer); + card._flashTimer = setTimeout(() => { act.classList.remove("on", "err"); edge.style.opacity = "0"; }, 820); + return card; + } + + function pulseStation(key, ms) { + const el = root.querySelector(`.fl-station[data-station="${key}"]`); + if (!el) return; + el.classList.add("pulse"); + clearTimeout(stationFlashTimers[key]); + stationFlashTimers[key] = setTimeout(() => el.classList.remove("pulse"), ms || 640); + } + + function travelPacket(fromEl, toEl, color) { + if (!fromEl || !toEl || C.state.reduced) return; + const pk = $("fl-packet"); + const band = $("fl-band").getBoundingClientRect(); + const a = fromEl.getBoundingClientRect(), b = toEl.getBoundingClientRect(); + const x0 = a.left + a.width / 2 - band.left, y0 = a.top + a.height / 2 - band.top; + const x1 = b.left + b.width / 2 - band.left, y1 = b.top + b.height / 2 - band.top; + pk.style.background = color || "var(--packet)"; + pk.style.top = y0 - 1.5 + "px"; pk.style.left = x0 - 17 + "px"; pk.style.opacity = "0"; + if (pk._anim) pk._anim.cancel(); + pk._anim = pk.animate([ + { transform: "translate(0,0)", opacity: 0 }, + { transform: "translate(0,0)", opacity: 1, offset: .08 }, + { transform: `translate(${x1 - x0}px, ${y1 - y0}px)`, opacity: 1, offset: .92 }, + { transform: `translate(${x1 - x0}px, ${y1 - y0}px)`, opacity: 0 }, + ], { duration: 640, easing: "linear" }); + pk._anim.onfinish = () => { pk.style.opacity = "0"; }; + } + + function onEvent(evt) { + if (!root || !mounted) return; + const process = root.querySelector('.fl-station[data-station="process"]'); + const publish = root.querySelector('.fl-station[data-station="publish"]'); + const audit = root.querySelector('.fl-station[data-station="audit"]'); + if (C.FLOW_PROVIDERS.includes(evt.channel)) { + const card = flashSource(evt.channel, evt.kind); + safeTimeout(() => { travelPacket(card, process, evt.kind === "error" ? "var(--error)" : "var(--packet)"); pulseStation("process", 700); }, 260); + renderCalls(); + } else if (evt.channel === "junction") { + pulseStation("process", 700); + safeTimeout(() => { travelPacket(process, publish, "var(--packet)"); pulseStation("publish", 700); renderProcess(); }, 260); + } else if (evt.channel === "tx") { + pulseStation("publish", 700); + safeTimeout(() => { travelPacket(publish, audit, evt.kind === "rollback" ? "var(--error)" : "var(--packet)"); pulseStation("audit", 700); renderPublish(); renderReleaseCol(); }, 260); + } else if (evt.channel === "audit") { + pulseStation("audit", 700); + renderAudit(); + } + } + + function mount(el) { + root = el; + mounted = true; + root.innerHTML = SKELETON; + refresh(); + S.bindReleaseDateReload(root, "#fl-rel-date", "#fl-rel-load", () => renderReleaseCol()); + S.bindBackfillButton(root, "#fl-rel-backfill", () => renderReleaseCol()); + unsubs.push(C.Bus.on("data", refresh)); + unsubs.push(C.Bus.on("event", onEvent)); + } + function unmount() { + mounted = false; + unsubs.forEach((fn) => fn()); unsubs = []; + Object.values(stationFlashTimers).forEach(clearTimeout); stationFlashTimers = {}; + pendingTimers.forEach(clearTimeout); pendingTimers = []; + root = null; + } + + window.HUB_LAYOUTS = window.HUB_LAYOUTS || {}; + window.HUB_LAYOUTS.flowline = { mount, unmount }; +})(); diff --git a/xiaobai-datahub/admin/layouts/ledger.css b/xiaobai-datahub/admin/layouts/ledger.css new file mode 100644 index 0000000..eec0f2d --- /dev/null +++ b/xiaobai-datahub/admin/layouts/ledger.css @@ -0,0 +1,81 @@ +/* B · 值班台账 Ledger Desk —— 表格本身就是主舞台,每条真实事件一行。 + 高密度、精密仪器感;按钮/状态/异常一眼可读。 */ +#lg-root { padding: var(--sp3) var(--sp4); max-width: 1680px; margin: 0 auto; } +#lg-grid { display: grid; grid-template-columns: 250px 1fr 300px; gap: 12px; align-items: start; } +/* CSS Grid 子项默认 min-width:auto,会被内部宽表格的内在尺寸撑爆整条 1fr 轨道; + 显式清零,让表格自己用 overflow 滚动,而不是撑破布局。 */ +#lg-grid > * { min-width: 0; } + +#lg-side, #lg-side-r { display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.lg-panel { padding: 10px 12px; min-width: 0; } +.lg-panel .sec-label { display: block; margin-bottom: 8px; } + +.lg-src-row { display: flex; align-items: center; gap: 7px; padding: 5px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; } +.lg-src-row:last-child { border-bottom: 0; } +.lg-src-row .nm { flex: 1; color: var(--t1); } +.lg-src-row .lat { color: var(--t3); font-size: 11px; } +.lg-src-row .lamps { display: flex; gap: 3px; } + +.lg-jobline { display: flex; align-items: center; gap: 6px; padding: 5px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; } +.lg-jobline:last-child { border-bottom: 0; } +.lg-jobline .nm { flex: 1; color: var(--t1); } +.lg-jobline .at { color: var(--t3); font-size: 11px; } +.lg-jobline button { margin-left: 4px; } + +/* -------- 主舞台:事件台账表 -------- */ +#lg-ledger { padding: 10px 12px; } +#lg-ledger .lg-ledger-hd { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; } +#lg-ledger .lg-ledger-hd .hint { font-size: 11px; color: var(--t3); } +table.lg-ledger { width: 100%; border-collapse: collapse; font-size: 12px; } +table.lg-ledger th { + position: sticky; top: 0; background: var(--bg1); text-align: left; font-weight: 500; color: var(--t3); + font-size: 11px; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; z-index: 1; +} +table.lg-ledger th.stage { text-align: center; } +table.lg-ledger td { padding: 7px 8px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; } +table.lg-ledger td.ev { min-width: 150px; } +table.lg-ledger td.ev .nm { color: var(--t1); font-weight: 600; font-size: 12.5px; } +table.lg-ledger td.ev .sub { color: var(--t3); font-size: 10.5px; } +table.lg-ledger td.src { color: var(--t2); font-size: 11.5px; white-space: nowrap; } +table.lg-ledger td.stage { text-align: center; white-space: nowrap; } +.lg-cell { + display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 58px; + padding: 3px 7px; border-radius: var(--r-chip); font-family: var(--font-mono); font-size: 11px; + background: var(--bg2); color: var(--t3); transition: background var(--dur-ui) var(--ease-out), color var(--dur-ui) var(--ease-out); +} +.lg-cell.ok { background: var(--alive-soft); color: var(--alive); } +.lg-cell.err { background: var(--error-soft); color: var(--error); } +.lg-cell.warn { background: var(--act-soft); color: var(--act); } +.lg-cell.dash { opacity: .4; } +tr.lg-row { transition: background var(--dur-ui) var(--ease-out); } +tr.lg-row.flashnew td { background: var(--action-soft); } +tr.lg-row.flashnew.err td { background: var(--error-soft); } + +/* -------- 调用带(持续,只滚真实数据) -------- */ +#lg-tape { margin-top: 10px; padding: 8px 0 0; border-top: 1px solid var(--line-soft); } +#lg-tape .sec-label { display: block; margin-bottom: 6px; } +#lg-tape-track { overflow: hidden; white-space: nowrap; position: relative; height: 24px; } +#lg-tape-inner { display: inline-flex; gap: 22px; will-change: transform; } +.lg-tape-item { font-family: var(--font-mono); font-size: 11.5px; color: var(--t2); white-space: nowrap; } +.lg-tape-item b { color: var(--t1); } +.lg-tape-item.err { color: var(--error); } +#lg-tape.paused #lg-tape-inner { animation-play-state: paused; } + +/* -------- 右侧发布/审计 -------- */ +#lg-release table.grid td, #lg-release table.grid th { padding: 5px 6px; font-size: 11px; } +#lg-release .toolrow { display: flex; gap: 6px; margin-bottom: 8px; } +#lg-release .toolrow input { width: 84px; font-family: var(--font-mono); font-size: 11px; background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r-chip); padding: 4px 6px; color: var(--t1); } +.lg-auditline { font-size: 11px; color: var(--t2); padding: 5px 0; border-bottom: 1px solid var(--line-soft); line-height: 1.5; } +.lg-auditline:last-child { border-bottom: 0; } +.lg-auditline .dim { color: var(--t3); } + +@media (max-width: 1280px) { + #lg-grid { grid-template-columns: 1fr; } + #lg-side, #lg-side-r { flex-direction: row; flex-wrap: wrap; } + .lg-panel { flex: 1 1 260px; } +} +@media (max-width: 1100px) { + #lg-root { padding: var(--sp2) var(--sp3); } + table.lg-ledger { font-size: 11px; } + .lg-cell { min-width: 46px; font-size: 10px; padding: 2px 5px; } +} diff --git a/xiaobai-datahub/admin/layouts/ledger.js b/xiaobai-datahub/admin/layouts/ledger.js new file mode 100644 index 0000000..65aa223 --- /dev/null +++ b/xiaobai-datahub/admin/layouts/ledger.js @@ -0,0 +1,291 @@ +"use strict"; +/* ========================================================================== + B · 值班台账 Ledger Desk —— 表格本身就是主舞台,每条真实事件一行。 + 响应/汇入/处理/发布/审计五列只填该行真实具备的阶段,没有发生的阶段留空, + 不做跨行编造关联。底部调用带只滚真实 recent_calls。 + ========================================================================== */ +(function () { + const C = window.Core, S = window.HubShared; + const { $, esc, timeShort } = C; + let root = null; + let unsubs = []; + let seenRowKeys = new Set(); + let mounted = false; + let pendingTimers = []; + function safeTimeout(fn, ms) { + const id = setTimeout(() => { if (mounted) fn(); }, ms); + pendingTimers.push(id); + return id; + } + + const SKELETON = ` +
+
+
+
+ 来源健康 SOURCES +
+
+
+ 调度任务 JOBS +
+
+
+ +
+
+ 事件台账 EVENT LEDGER + 每条事件依次经过 响应→汇入→处理→发布→审计 +
+
+ + + + + + + + + + +
时间事件来源响应
RESP
汇入
INGEST
处理
PROC
发布
PUB
审计
AUDIT
+
+
+ 调用带 CALL TAPE +
+
+
+ +
+
+ 盘后发布 RELEASE +
+ + + +
+
+
+
+ 审计 AUDIT +
+
+
+
+
+ `; + + function renderSources() { + const v = S.sourcesView(); + const host = $("lg-srcList"); + if (!v) { host.innerHTML = `
正在加载…
`; return; } + host.innerHTML = v.live.map((it) => { + const health = it.health || {}; + const link = it._link || C.linkState(health); + return `
+ + ${esc(C.FLOW_LABEL[it.provider] || it.provider)} + ${C.chipHtml(link, health.state || link)} + ${health.latency_ms == null ? "-" : health.latency_ms + "ms"} + +
`; + }).join(""); + S.bindProbeButtons(host, () => renderSources()); + } + + function renderJobs() { + const v = S.jobsView(); + const host = $("lg-jobList"); + if (!v) { host.innerHTML = `
正在加载…
`; return; } + host.innerHTML = v.jobs.map((job) => { + const latest = v.latestByJob.get(job.id); + return `
+ ${C.chipHtml(latest ? latest.state : "idle")} + ${esc(job.id)} + ${esc(job.at)} + +
`; + }).join(""); + S.bindRunButtons(host, () => renderJobs()); + } + + function renderRelease() { + const v = S.releaseView(); + const dateInput = $("lg-rel-date"); + if (!dateInput.value) dateInput.value = C.state.releaseDate; + if (!v) { $("lg-pubTable").innerHTML = `
正在加载…
`; return; } + $("lg-pubTable").innerHTML = C.table(["数据集", "批次", "状态", "时间", ""], v.pubs.map((p) => [ + `${esc(p.dataset)}`, `${esc(p.active_batch)}`, + C.chipHtml(p.state), `${esc(p.published_at)}`, + p.prev_batch ? `` : "-", + ])); + S.bindRollbackButtons($("lg-pubTable"), () => renderRelease()); + } + + function renderAuditList() { + const v = S.auditView(); + const host = $("lg-auditList"); + if (!v) { host.innerHTML = `
正在加载…
`; return; } + host.innerHTML = v.items.slice(0, 10).map((a) => ` +
+
${esc(timeShort(a.created_at))} ${esc(a.actor)} ${esc(a.action)}
+
${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}
+
`).join(""); + } + + /* ---------------------------------------------------------------- 台账行合成 + 每行只填该事件类型真实具备的阶段;不同类型互不编造对方的字段。 */ + function cell(text, kind) { + if (text == null || text === "") return ``; + return `${esc(text)}`; + } + + function buildLedgerRows() { + const ov = S.overviewView(), jv = S.jobsView(), rv = S.releaseView(), av = S.auditView(); + const rows = []; + if (ov) { + for (const c of ov.recentCalls) { + rows.push({ + key: `call:${c.id}`, t: c.created_at, name: c.endpoint, sub: "来源响应", source: c.provider, + resp: cell(timeShort(c.created_at), c.ok ? "ok" : "err"), + ingest: c.ok ? cell(timeShort(c.created_at), "ok") : cell(), + proc: cell(), pub: cell(), audit: cell(), + err: !c.ok, + }); + } + } + if (jv) { + for (const r of jv.runs.slice(0, 30)) { + const job = jv.jobs.find((j) => j.id === r.job_id); + rows.push({ + key: `run:${r.id}`, t: r.started_at, name: (job && job.title) || r.job_id, sub: r.job_id, source: "system", + resp: cell(), ingest: cell(timeShort(r.started_at), "ok"), + proc: r.state === "running" ? cell("running", "warn") : r.state === "failed" ? cell(r.error || "failed", "err") : cell(timeShort(r.finished_at), "ok"), + pub: cell(), audit: cell(), + err: r.state === "failed", + }); + } + } + if (rv) { + for (const p of rv.pubs) { + if (!p.published_at || p.published_at === "-") continue; + rows.push({ + key: `pub:${p.dataset}:${p.active_batch}`, t: p.published_at, name: `批次发布 · ${p.dataset}`, sub: p.active_batch, source: "system", + resp: cell(), ingest: cell(), proc: cell(), + pub: cell(timeShort(p.published_at), p.state === "published" ? "ok" : "warn"), + audit: cell(), + err: p.state === "missing" || p.state === "error", + }); + } + } + if (av) { + for (const a of av.items.slice(0, 30)) { + const isRollback = String(a.action).includes("rollback"); + const isProbe = a.action === "probe"; + rows.push({ + key: `audit:${a.id}`, t: a.created_at, name: a.action, sub: a.target, source: a.actor, + resp: isProbe ? cell(timeShort(a.created_at), String(a.detail).includes("失败") ? "err" : "ok") : cell(), + ingest: cell(), proc: cell(), + pub: isRollback ? cell(timeShort(a.created_at), "warn") : cell(), + audit: cell(timeShort(a.created_at), isRollback ? "warn" : "ok"), + err: isRollback, + }); + } + } + rows.sort((a, b) => String(b.t).localeCompare(String(a.t))); + return rows.slice(0, 60); + } + + function renderLedger() { + const rows = buildLedgerRows(); + const body = $("lg-ledgerBody"); + body.innerHTML = rows.map((r) => ` + + ${esc(timeShort(r.t))} +
${esc(r.name)}
${esc(r.sub || "")}
+ ${esc(r.source)} + ${r.resp} + ${r.ingest} + ${r.proc} + ${r.pub} + ${r.audit} + `).join(""); + rows.forEach((r) => seenRowKeys.add(r.key)); + if (!C.state.reduced) { + body.querySelectorAll("tr.flashnew").forEach((tr) => { + setTimeout(() => tr.classList.remove("flashnew"), 1000); + }); + } else { + body.querySelectorAll("tr.flashnew").forEach((tr) => tr.classList.remove("flashnew")); + } + } + + /* ---------------------------------------------------------------- 调用带:只滚真实 recent_calls */ + function renderTape() { + const ov = S.overviewView(); + const inner = $("lg-tape-inner"); + if (!ov || !ov.recentCalls.length) { inner.innerHTML = `暂无真实调用`; inner.style.animation = "none"; return; } + const items = ov.recentCalls.slice(0, 16).map((c) => `${esc(timeShort(c.created_at))} ${esc(c.provider)} ${esc(c.endpoint)} ${c.ok ? "✓" : "× " + esc(c.error)}`).join(""); + inner.innerHTML = items + items; // 首尾拼接形成无缝循环,内容仍全部来自真实调用 + if (C.state.reduced) { inner.style.animation = "none"; return; } + const width = inner.scrollWidth / 2; + inner.style.animation = "none"; + void inner.offsetWidth; + inner.style.setProperty("--tape-w", `-${width}px`); + inner.style.animation = `lg-tape-scroll ${Math.max(12, width / 40)}s linear infinite`; + } + if (!document.getElementById("lg-tape-keyframes")) { + const style = document.createElement("style"); + style.id = "lg-tape-keyframes"; + style.textContent = `@keyframes lg-tape-scroll { from { transform: translateX(0); } to { transform: translateX(var(--tape-w, -800px)); } }`; + document.head.appendChild(style); + } + + function updateTapePauseState() { + const el = $("lg-tape"); + if (!el) return; + el.classList.toggle("paused", !C.Poller.runtimeAvailable() || C.state.reduced); + } + + function refresh() { + if (!root) return; + renderSources(); renderJobs(); renderRelease(); renderAuditList(); renderLedger(); renderTape(); updateTapePauseState(); + } + + function onEvent(evt) { + if (!root || !mounted) return; + if (C.FLOW_PROVIDERS.includes(evt.channel)) { + const row = root.querySelector(`.lg-src-row[data-provider="${evt.channel}"] [data-act]`); + if (row) { + row.classList.remove("on", "err"); + row.classList.add("on"); if (evt.kind === "error") row.classList.add("err"); + setTimeout(() => row.classList.remove("on", "err"), 800); + } + } + renderLedger(); + renderTape(); + } + + function mount(el) { + root = el; + mounted = true; + seenRowKeys = new Set(); + root.innerHTML = SKELETON; + refresh(); + S.bindReleaseDateReload(root, "#lg-rel-date", "#lg-rel-load", () => renderRelease()); + S.bindBackfillButton(root, "#lg-rel-backfill", () => renderRelease()); + unsubs.push(C.Bus.on("data", refresh)); + unsubs.push(C.Bus.on("event", onEvent)); + unsubs.push(C.Bus.on("runtime", updateTapePauseState)); + unsubs.push(C.Bus.on("reduced", () => { renderTape(); updateTapePauseState(); })); + } + function unmount() { + mounted = false; + unsubs.forEach((fn) => fn()); unsubs = []; + pendingTimers.forEach(clearTimeout); pendingTimers = []; + root = null; + } + + window.HUB_LAYOUTS = window.HUB_LAYOUTS || {}; + window.HUB_LAYOUTS.ledger = { mount, unmount }; +})(); diff --git a/xiaobai-datahub/admin/layouts/shared.js b/xiaobai-datahub/admin/layouts/shared.js new file mode 100644 index 0000000..cefd742 --- /dev/null +++ b/xiaobai-datahub/admin/layouts/shared.js @@ -0,0 +1,136 @@ +"use strict"; +/* ========================================================================== + 三方向共用的数据整形 + 操作绑定。A/B/C 各自的 mount() 只管 DOM 结构和动效编排, + "字段从哪来、按钮点了调什么接口" 统一走这里,禁止在布局文件里重写业务判断。 + ========================================================================== */ +window.HubShared = (function () { + const C = window.Core; + const { esc, timeShort } = C; + + function overviewView() { + const d = C.state.data.overview; + if (!d) return null; + const eod = d.eod_status || {}, rev = d.revision_status || {}; + return { + tradeDate: d.trade_date, + phase: C.PHASE_LABELS[d.session_phase] || d.session_phase, + pubCount: d.publications.length, + eodState: eod.state, eodLabel: C.EOD_LABELS[eod.state] || eod.state || "-", eod, + revState: rev.state, revLabel: C.REV_LABELS[rev.state] || rev.state || "-", rev, + anomalies: d.anomalies.length, + recentCalls: d.recent_calls || [], + sourceCount: d.source_count, + }; + } + + function sourcesView() { + const d = C.state.data.sources; + if (!d) return null; + const live = d.items.filter((it) => C.FLOW_PROVIDERS.includes(it.provider)); + const reserved = d.items.filter((it) => !C.FLOW_PROVIDERS.includes(it.provider)); + return { live, reserved, all: d.items }; + } + + function jobsView() { + const d = C.state.data.jobs; + if (!d) return null; + const runs = d.runs || []; + const latestByJob = new Map(); + for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r); + return { + jobs: d.jobs || [], runs, latest: runs[0], latestByJob, + failedRecent: runs.slice(0, 20).filter((r) => r.state === "failed").length, + fresh: C.state.freshJobRuns || [], + }; + } + + function releaseView() { + const d = C.state.data.batches; + if (!d) return null; + const pubs = d.publications || []; + const batches = d.batches || []; + return { + tradeDate: d.trade_date, pubs, batches, + rollbacks: pubs.filter((p) => p.prev_batch).length, + failedBatches: batches.filter((b) => b.state === "failed").length, + }; + } + + function datasetsView() { + const d = C.state.data.datasets; + if (!d) return null; + return { tradeDate: d.trade_date, pubs: d.publications || [], diffs: d.diff_reports || [] }; + } + + function auditView() { + const d = C.state.data.audit; + if (!d) return null; + return { items: d.items || [] }; + } + + /* ---------------------------------------------------------------- 操作绑定 */ + function bindProbeButtons(root, onDone) { + root.querySelectorAll("[data-probe]").forEach((btn) => { + btn.addEventListener("click", async () => { + const provider = btn.dataset.probe; + btn.disabled = true; + const original = btn.textContent; + btn.textContent = "探测中…"; + try { + const result = await C.probeSource(provider); + btn.textContent = "已探测"; + setTimeout(() => { btn.textContent = original; }, 1200); + if (onDone) onDone(result, provider); + } catch (err) { + alert(err.message); + btn.textContent = original; + } finally { + setTimeout(() => { btn.disabled = false; }, 400); + } + }); + }); + } + + function bindRunButtons(root, onDone) { + root.querySelectorAll("[data-run]").forEach((btn) => { + btn.addEventListener("click", async () => { + const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || ""; + btn.disabled = true; + try { + const result = await C.runJob(btn.dataset.run, date); + if (onDone) onDone(result); + } catch (err) { + alert(err.message); + } finally { + btn.disabled = false; + } + }); + }); + } + + function bindRollbackButtons(root, onDone) { + root.querySelectorAll("[data-rollback]").forEach((btn) => { + btn.addEventListener("click", () => C.dangerous("rollback", btn.dataset.rollback, onDone)); + }); + } + + function bindBackfillButton(root, selector, onDone) { + const el = root.querySelector(selector); + if (el) el.addEventListener("click", () => C.dangerous("backfill", null, onDone)); + } + + function bindReleaseDateReload(root, inputSel, btnSel, onDone) { + const btn = root.querySelector(btnSel); + if (!btn) return; + btn.addEventListener("click", async () => { + C.state.releaseDate = root.querySelector(inputSel).value.trim(); + await C.pollBatches(); + if (onDone) onDone(); + }); + } + + return { + overviewView, sourcesView, jobsView, releaseView, datasetsView, auditView, + bindProbeButtons, bindRunButtons, bindRollbackButtons, bindBackfillButton, bindReleaseDateReload, + }; +})(); diff --git a/xiaobai-datahub/admin/layouts/strata.css b/xiaobai-datahub/admin/layouts/strata.css new file mode 100644 index 0000000..d648888 --- /dev/null +++ b/xiaobai-datahub/admin/layouts/strata.css @@ -0,0 +1,60 @@ +/* C · 地层剖面 Strata —— 来源层→加工层→发布层→审计沉积层 四条全宽横带。 + 真实事件自上而下贯穿;整齐、纵向追踪;选中/关联状态解决跨层追踪费眼问题。 */ +#st-root { padding: var(--sp3) var(--sp4); max-width: 1680px; margin: 0 auto; position: relative; } +#st-spine { position: absolute; left: 46px; top: 56px; bottom: 12px; width: 2px; background: var(--line); z-index: 0; } +#st-dot { position: absolute; left: 41px; width: 12px; height: 12px; border-radius: 50%; background: var(--packet); opacity: 0; z-index: 3; pointer-events: none; box-shadow: 0 0 0 4px var(--bg0); } + +.st-band { position: relative; display: flex; gap: 16px; padding: 14px 16px 16px 62px; margin-bottom: 10px; z-index: 1; border-left: 3px solid transparent; transition: border-color var(--dur-ui) var(--ease-out), background var(--dur-ui) var(--ease-out); } +.st-band > div { min-width: 0; } +.st-band.pulse { border-left-color: var(--action); background: var(--action-soft); } +.st-band.pulse.err { border-left-color: var(--error); background: var(--error-soft); } +.st-band .st-idx { position: absolute; left: 6px; top: 14px; width: 36px; text-align: center; } +.st-band .st-idx .n { font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--t3); display: block; } +.st-band .st-idx .lb { font-size: 9.5px; color: var(--t3); letter-spacing: .12em; display: block; margin-top: 2px; } + +#st-sources { display: grid; grid-template-columns: repeat(4, 1fr) auto; gap: 12px; flex: 1; min-width: 0; } +/* 防止内部宽表格用 min-width:auto 撑爆 1fr 轨道,参见 ledger.css 同类注释。 */ +#st-sources > *, #st-process > *, #st-publish > * { min-width: 0; } +.st-src { padding: 8px 10px; cursor: pointer; border: 1px solid var(--line); border-radius: var(--r-card); transition: border-color var(--dur-ui) var(--ease-out); } +.st-src.sel { border-color: var(--action); box-shadow: 0 0 0 1px var(--action) inset; } +.st-src .row { display: flex; align-items: center; gap: 6px; } +.st-src .nm { font-size: 13px; font-weight: 600; color: var(--t1); flex: 1; } +.st-src .sub { font-size: 10.5px; color: var(--t3); margin-top: 2px; } +.st-src .lat { font-size: 11px; color: var(--t2); } +.st-reserved { align-self: center; font-size: 11px; color: var(--t3); white-space: nowrap; padding: 0 8px; } + +#st-process { flex: 1; display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; } +.st-jobs .st-job { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; cursor: pointer; border-radius: 4px; } +.st-jobs .st-job:last-child { border-bottom: 0; } +.st-jobs .st-job.sel { background: var(--action-soft); } +.st-jobs .st-job .nm { flex: 1; color: var(--t1); } +.st-jobs .st-job .at { color: var(--t3); font-size: 11px; } +.st-runs table.grid td, .st-runs table.grid th { padding: 4px 6px; font-size: 11px; } +.st-runs tr.hi td { background: var(--action-soft); } + +#st-publish { flex: 1; display: grid; grid-template-columns: 220px 1fr; gap: 16px; } +.st-gate .bignum { display: flex; align-items: baseline; gap: 8px; } +.st-gate .bignum .n { font-size: var(--fs-hero); font-weight: 700; color: var(--t1); } +.st-gate .bignum .cap { font-size: 11px; color: var(--t3); } +.st-gate .win { font-size: 11px; color: var(--t2); margin-top: 8px; line-height: 1.7; } +.st-datasets table.grid td, .st-datasets table.grid th { padding: 4px 6px; font-size: 11px; } +.st-datasets tr.hi td { background: var(--action-soft); } + +#st-audit { flex: 1; display: flex; flex-direction: column; gap: 6px; } +.st-audit-line { font-size: 11.5px; color: var(--t2); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.st-audit-line b { color: var(--t1); } +.st-audit-line.warn { color: var(--act); } + +@media (max-width: 1280px) { + #st-sources { grid-template-columns: repeat(2, 1fr); } + .st-reserved { grid-column: 1 / -1; } + #st-process, #st-publish { grid-template-columns: 1fr; } +} +@media (max-width: 1100px) { + #st-root { padding: var(--sp2) var(--sp3); } + #st-spine { left: 30px; } + #st-dot { left: 25px; } + .st-band { padding-left: 46px; gap: 10px; } + .st-band .st-idx { width: 26px; } + .st-band .st-idx .n { font-size: 14px; } +} diff --git a/xiaobai-datahub/admin/layouts/strata.js b/xiaobai-datahub/admin/layouts/strata.js new file mode 100644 index 0000000..be1bf73 --- /dev/null +++ b/xiaobai-datahub/admin/layouts/strata.js @@ -0,0 +1,248 @@ +"use strict"; +/* ========================================================================== + C · 地层剖面 Strata —— 来源层 → 加工层 → 发布层 → 审计沉积层 四条全宽横带。 + 真实事件自上而下贯穿(脊线上的光点从上一层滑到下一层);点击任一层的项目 + 进入"选中"态,用同一颜色在其它层高亮可关联的真实行,解决跨层追踪费眼问题。 + ========================================================================== */ +(function () { + const C = window.Core, S = window.HubShared; + const { $, esc, timeShort } = C; + let root = null; + let mounted = false; + let pendingTimers = []; + function safeTimeout(fn, ms) { + const id = setTimeout(() => { if (mounted) fn(); }, ms); + pendingTimers.push(id); + return id; + } + let unsubs = []; + let selection = null; // { type: 'provider'|'job'|'dataset', value } + + const SKELETON = ` +
+
+
+ +
+
01SOURCES
来源层
+
+
+ +
+
02PROCESS
加工层
+
+
+ 调度任务 JOBS +
+
+
+ 最近运行 RUNS +
+
+
+
+ +
+
03PUBLISH
发布层
+
+
+ 盘后发布 RELEASE +
-今日发布
+
+
+ + + +
+
+
+ 数据集 DATASETS +
+
+
+
+ +
+
04AUDIT
审计层
+
+
+
+ `; + + function applySelectionClasses() { + root.querySelectorAll("[data-sel-provider]").forEach((el) => el.classList.toggle("sel", selection && selection.type === "provider" && el.dataset.selProvider === selection.value)); + root.querySelectorAll("[data-sel-job]").forEach((el) => el.classList.toggle("sel", selection && selection.type === "job" && el.dataset.selJob === selection.value)); + root.querySelectorAll("[data-rel-job]").forEach((el) => el.classList.toggle("hi", selection && selection.type === "job" && el.dataset.relJob === selection.value)); + root.querySelectorAll("[data-rel-dataset]").forEach((el) => el.classList.toggle("hi", selection && selection.type === "dataset" && el.dataset.relDataset === selection.value)); + } + function setSelection(type, value) { + selection = (selection && selection.type === type && selection.value === value) ? null : { type, value }; + applySelectionClasses(); + } + + function renderSources() { + const v = S.sourcesView(); + const host = $("st-sources"); + if (!v) { host.innerHTML = `
正在加载…
`; return; } + host.innerHTML = v.live.map((it) => { + const health = it.health || {}; + const link = it._link || C.linkState(health); + return `
+
+ + + ${esc(C.FLOW_LABEL[it.provider] || it.provider)} + ${C.chipHtml(link, health.state || link)} +
+
${esc(C.FLOW_ROLE[it.provider] || it.role)} · 配置 ${(it.credential || {}).configured ? "已配置" : "未配置"} · ${it.calls_today ?? 0} 次
+
+ ${health.latency_ms == null ? "-" : health.latency_ms + "ms"} + +
+
`; + }).join("") + (v.reserved.length ? `
预留 ${v.reserved.length} 个
未接入
` : ""); + S.bindProbeButtons(host, () => renderSources()); + host.querySelectorAll(".st-src").forEach((el) => el.addEventListener("click", (e) => { + if (e.target.closest("button")) return; + setSelection("provider", el.dataset.provider); + })); + applySelectionClasses(); + } + + function renderProcess() { + const v = S.jobsView(); + const jobHost = $("st-jobList"); + if (!v) { jobHost.innerHTML = `
正在加载…
`; return; } + jobHost.innerHTML = v.jobs.map((job) => { + const latest = v.latestByJob.get(job.id); + return `
+ ${C.chipHtml(latest ? latest.state : "idle")} + ${esc(job.id)}${esc(job.at)} + +
`; + }).join(""); + S.bindRunButtons(jobHost, () => renderProcess()); + jobHost.querySelectorAll(".st-job").forEach((el) => el.addEventListener("click", (e) => { + if (e.target.closest("button")) return; + setSelection("job", el.dataset.job); + })); + $("st-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], v.runs.map((r) => [ + `${r.id}`, + `${esc(r.job_id)}`, + `${esc(r.state)}`, + `${esc(timeShort(r.started_at))}`, `${esc(timeShort(r.finished_at))}`, + `${esc(r.error || "")}`, + ])); + root.querySelectorAll("#st-runsTable tr").forEach((tr, i) => { + if (i === 0) return; + const run = v.runs[i - 1]; if (run) tr.dataset.relJob = run.job_id; + }); + applySelectionClasses(); + } + + function renderPublish() { + const v = S.releaseView(); + const dateInput = $("st-rel-date"); + if (!dateInput.value) dateInput.value = C.state.releaseDate; + if (!v) { $("st-pubnum").textContent = "-"; $("st-datasetsTable").innerHTML = `
正在加载…
`; return; } + $("st-pubnum").textContent = v.pubs.length; + const eod = (S.overviewView() || {}).eod || {}; + $("st-pubwin").textContent = eod.state === "waiting_upstream" ? `等待上游 · 已试 ${eod.attempts ?? "-"} 次` : ""; + $("st-datasetsTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], v.pubs.map((p) => [ + `${esc(p.dataset)}`, + `${esc(p.active_batch)}`, `${esc(p.prev_batch || "-")}`, + C.chipHtml(p.state), `${esc(p.published_at)}`, + p.prev_batch ? `` : "-", + ])); + root.querySelectorAll("#st-datasetsTable tr").forEach((tr, i) => { + if (i === 0) return; + const p = v.pubs[i - 1]; if (p) tr.dataset.relDataset = p.dataset; + }); + S.bindRollbackButtons($("st-datasetsTable"), () => renderPublish()); + applySelectionClasses(); + } + + function renderAudit() { + const v = S.auditView(); + const host = $("st-audit"); + if (!v) { host.innerHTML = `
正在加载…
`; return; } + host.innerHTML = v.items.slice(0, 8).map((a) => ` +
+ ${esc(timeShort(a.created_at))} ${esc(a.actor)} ${esc(a.action)} · ${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""} +
`).join(""); + } + + function refresh() { if (!root) return; renderSources(); renderProcess(); renderPublish(); renderAudit(); } + + /* ---------------------------------------------------------------- 纵向贯穿动效 */ + const BAND_ORDER = ["sources", "process", "publish", "audit"]; + function pulseBand(key, err) { + if (!root) return; + const el = root.querySelector(`.st-band[data-band="${key}"]`); + if (!el) return; + el.classList.add("pulse"); el.classList.toggle("err", !!err); + clearTimeout(el._t); + el._t = setTimeout(() => el.classList.remove("pulse", "err"), 700); + } + function travelSpine(fromKey, toKey) { + if (C.state.reduced || !root) return; + const dot = $("st-dot"); + const a = root.querySelector(`.st-band[data-band="${fromKey}"]`); + const b = root.querySelector(`.st-band[data-band="${toKey}"]`); + if (!a || !b) return; + const rootRect = $("st-root").getBoundingClientRect(); + const ar = a.getBoundingClientRect(), br = b.getBoundingClientRect(); + const y0 = ar.top - rootRect.top + 20, y1 = br.top - rootRect.top + 20; + dot.style.top = y0 + "px"; dot.style.opacity = "0"; + if (dot._anim) dot._anim.cancel(); + dot._anim = dot.animate([ + { transform: "translateY(0)", opacity: 0 }, + { transform: "translateY(0)", opacity: 1, offset: .1 }, + { transform: `translateY(${y1 - y0}px)`, opacity: 1, offset: .9 }, + { transform: `translateY(${y1 - y0}px)`, opacity: 0 }, + ], { duration: 620, easing: "linear" }); + } + + function onEvent(evt) { + if (!root || !mounted) return; + if (C.FLOW_PROVIDERS.includes(evt.channel)) { + const act = root.querySelector(`.st-src[data-provider="${evt.channel}"] [data-act]`); + if (act) { + act.classList.remove("on", "err"); act.classList.add("on"); if (evt.kind === "error") act.classList.add("err"); + setTimeout(() => act.classList.remove("on", "err"), 800); + } + pulseBand("sources", evt.kind === "error"); + safeTimeout(() => { travelSpine("sources", "process"); pulseBand("process"); renderSources(); }, 200); + } else if (evt.channel === "junction") { + pulseBand("process", evt.kind === "error"); + safeTimeout(() => { travelSpine("process", "publish"); pulseBand("publish"); renderProcess(); }, 200); + } else if (evt.channel === "tx") { + pulseBand("publish", evt.kind === "rollback"); + safeTimeout(() => { travelSpine("publish", "audit"); pulseBand("audit", evt.kind === "rollback"); renderPublish(); }, 200); + } else if (evt.channel === "audit") { + pulseBand("audit", evt.kind === "rollback"); + renderAudit(); + } + } + + function mount(el) { + root = el; + mounted = true; + selection = null; + root.innerHTML = SKELETON; + refresh(); + S.bindReleaseDateReload(root, "#st-rel-date", "#st-rel-load", () => renderPublish()); + S.bindBackfillButton(root, "#st-rel-backfill", () => renderPublish()); + unsubs.push(C.Bus.on("data", refresh)); + unsubs.push(C.Bus.on("event", onEvent)); + } + function unmount() { + mounted = false; + unsubs.forEach((fn) => fn()); unsubs = []; + pendingTimers.forEach(clearTimeout); pendingTimers = []; + root = null; + } + + window.HUB_LAYOUTS = window.HUB_LAYOUTS || {}; + window.HUB_LAYOUTS.strata = { mount, unmount }; +})(); diff --git a/xiaobai-datahub/admin/main.js b/xiaobai-datahub/admin/main.js new file mode 100644 index 0000000..9d390e8 --- /dev/null +++ b/xiaobai-datahub/admin/main.js @@ -0,0 +1,91 @@ +"use strict"; +/* ========================================================================== + xiaobai-datahub 管理后台 · 启动壳 + 会话/登录/改密/登出/主题按钮/退出确认 —— 与具体布局无关,三页共用同一份。 + ========================================================================== */ +(function () { + const C = window.Core; + const { $ } = C; + + function show(id) { + ["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; }); + } + + async function boot() { + try { + const session = await C.api("/admin/api/session"); + C.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 C.api("/admin/api/login", { + method: "POST", + body: JSON.stringify({ username: form.get("username"), password: form.get("password") }), + }); + C.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 C.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 C.api("/admin/api/logout", { method: "POST", body: "{}" }); + C.Poller.stopAll(); + Object.values(window.HUB_LAYOUTS || {}).forEach((impl) => { try { impl.unmount && impl.unmount(); } catch { /* ignore */ } }); + show("login-view"); + }); + + $("theme-btn").addEventListener("click", () => C.toggleTheme()); + C.Bus.on("theme", (theme) => { $("theme-btn").textContent = theme === "night" ? "日间" : "夜间"; }); + + function updateCrumb() { + const el = $("crumb"); + if (!el) return; + if (!C.state.online) { el.textContent = "网络已断开 · 已暂停实时"; return; } + if (!C.state.visible) { el.textContent = "已切至后台 · 已暂停动效"; return; } + const d = C.state.data.overview; + el.textContent = d ? `8766 · ${d.trade_date} · 持续运转` : "8766 · 四源汇流 · 持续运转"; + } + C.Bus.on("runtime", updateCrumb); + C.Bus.on("data", updateCrumb); + + function enterShell() { + C.bootTheme(); + C.applyReduced(C.REDUCE_MQ.matches); + C.Poller.startAll(); + updateCrumb(); + C.Router.boot(); + } + + boot(); +})(); diff --git a/xiaobai-datahub/admin/shared.css b/xiaobai-datahub/admin/shared.css new file mode 100644 index 0000000..818db1f --- /dev/null +++ b/xiaobai-datahub/admin/shared.css @@ -0,0 +1,136 @@ +/* xiaobai-datahub 数据中枢后台 —— A/B/C 三方向共用组件层 + 只依赖 tokens.css 的变量;三个布局文件(flowline.css / ledger.css / strata.css) + 只写各自独有的排布,不得重复定义这里已有的颜色/组件规则。 */ +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg0); color: var(--t1); font-family: var(--font-cn); + font-size: var(--fs-body); -webkit-font-smoothing: antialiased; +} +.num, .mono { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } +.mono { font-family: var(--font-mono); } +.muted { color: var(--t3); } +.error, .fail { color: var(--error); } +.ok { color: var(--alive); } +.warn { color: var(--act); } +button { font-family: var(--font-cn); cursor: pointer; } + +/* ---------- 区段标签:mono 小字大写,是整套系统的刻度尺 ---------- */ +.sec-label { + font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em; + text-transform: uppercase; color: var(--t3); +} + +/* ---------- 卡片:只靠底色阶梯和 1px 发丝线分层 ---------- */ +.card { background: var(--bg1); border: 1px solid var(--line); border-radius: var(--r-card); } + +/* ---------- 状态 chip ---------- */ +.chip { + display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-small); + line-height: 1; padding: 4px 8px; border-radius: var(--r-chip); border: 1px solid var(--line); + color: var(--t2); white-space: nowrap; +} +.chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; } +.chip.ok { color: var(--alive); background: var(--alive-soft); border-color: transparent; } +.chip.err { color: var(--error); background: var(--error-soft); border-color: transparent; } +.chip.warn { color: var(--act); background: var(--act-soft); border-color: transparent; } +.chip.unknown { color: var(--unknown); } +.chip.unconfigured { color: var(--unconfigured); border-style: dashed; background: transparent; } +.chip.info { color: var(--action); background: var(--action-soft); border-color: transparent; } + +/* ---------- 双灯:LINK=链路健康(低亮常亮),ACT=真实活动(事件时短闪) ---------- */ +.lamp { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex: none; transition: background var(--dur-fast) var(--ease-out); } +.lamp.link.on { background: var(--alive); box-shadow: 0 0 6px rgba(60,203,154,.55); } +.lamp.link.off { background: var(--bg3); } +.lamp.link.err { background: var(--error); box-shadow: 0 0 6px rgba(229,83,75,.55); } +.lamp.act { background: var(--bg3); } +.lamp.act.on { background: var(--act); box-shadow: 0 0 7px rgba(240,167,60,.8); } +.lamp.act.on.err { background: var(--error); box-shadow: 0 0 7px rgba(229,83,75,.8); } +.lamp-tag { font-family: var(--font-mono); font-size: 9px; color: var(--t3); letter-spacing: .08em; } + +/* ---------- 按钮:蓝色只给"可操作" ---------- */ +.btn { + font-family: var(--font-cn); font-size: var(--fs-small); color: var(--action); + background: var(--action-soft); border: 1px solid transparent; border-radius: var(--r-chip); + padding: 5px 12px; cursor: pointer; transition: background var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); +} +.btn:hover { background: var(--action); color: #fff; } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn.ghost { background: transparent; border-color: var(--line); color: var(--t2); } +.btn.ghost:hover { border-color: var(--action); color: var(--action); background: transparent; } +.btn.ghost.active { border-color: var(--action); color: var(--action); background: var(--action-soft); } +.btn.danger { color: var(--error); background: var(--error-soft); } +.btn.danger:hover { background: var(--error); color: #fff; } +.btn.mini { padding: 3px 8px; font-size: 11px; } + +/* ---------- 表格 ---------- */ +table.grid { width: 100%; border-collapse: collapse; font-size: var(--fs-small); } +table.grid th { + text-align: left; font-weight: 500; color: var(--t3); font-size: 11px; + padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; +} +table.grid td { padding: 6px 10px; color: var(--t2); border-bottom: 1px solid var(--line-soft); white-space: nowrap; } +table.grid td.k { color: var(--t1); } +table.grid tr:last-child td { border-bottom: none; } + +/* ---------- 登录 / 改密(三页共用,跟布局无关) ---------- */ +.auth-panel { + max-width: 420px; margin: 12vh auto; padding: 30px 32px; border-radius: 14px; + background: var(--glass); border: 1px solid var(--line); backdrop-filter: blur(14px); + box-shadow: var(--shadow-pop); +} +.auth-panel h1 { margin: 0 0 6px; font-size: 21px; } +.auth-panel label { display: block; margin: 12px 0; font-size: 13px; color: var(--t2); } +.auth-panel input { + width: 100%; margin-top: 6px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; + background: var(--bg2); color: var(--t1); 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; font-size: 11px; padding: 3px 10px; border-radius: 999px; letter-spacing: .1em; color: var(--act); border: 1px solid var(--act); opacity: .85; } +.auth-panel button[type="submit"] { + background: var(--action); color: #fff; border: 0; border-radius: 8px; padding: 8px 16px; font-size: 13px; +} + +/* ---------- 顶栏:品牌 + 交易日 + 阶段 + A/B/C 切换 + 全局控制 ---------- */ +#topbar { + height: 48px; display: flex; align-items: center; gap: 12px; padding: 0 16px; + border-bottom: 1px solid var(--line); background: var(--bg1); position: sticky; top: 0; z-index: 20; +} +#topbar .title { font-size: 15px; font-weight: 600; color: var(--t1); white-space: nowrap; } +#topbar .title em { font-style: normal; color: var(--action); } +#topbar .vdiv { width: 1px; height: 16px; background: var(--line); flex: none; } +#topbar .tdate { font-size: 13px; color: var(--t2); white-space: nowrap; } +#topbar .spacer { flex: 1; } +#topbar .who { font-size: 12px; color: var(--t2); white-space: nowrap; } + +/* A/B/C 切换:一直可见,写清中文名称,当前页状态明确 */ +#layoutSwitch { + display: flex; align-items: center; gap: 2px; padding: 3px; border-radius: 8px; + background: var(--bg2); border: 1px solid var(--line); flex: none; +} +#layoutSwitch button { + font-family: var(--font-cn); font-size: 12.5px; color: var(--t2); background: transparent; + border: 0; border-radius: 6px; padding: 6px 11px; white-space: nowrap; + transition: background var(--dur-ui) var(--ease-out), color var(--dur-ui) var(--ease-out); +} +#layoutSwitch button .k { font-family: var(--font-mono); font-weight: 700; margin-right: 5px; opacity: .7; } +#layoutSwitch button[aria-current="page"] { + background: var(--action); color: #fff; box-shadow: var(--shadow-pop); +} +#layoutSwitch button[aria-current="page"] .k { opacity: 1; } +#layoutSwitch button:not([aria-current="page"]):hover { background: var(--bg3); color: var(--t1); } + +/* ---------- 主内容容器 ---------- */ +#page-root { min-height: calc(100vh - 48px); background: var(--bg0); } +.page-shell { padding: var(--sp4); max-width: 1584px; margin: 0 auto; } + +/* 三方向表格容器统一命名以 Table 结尾:窄列/1024 档宁可局部横向滚动, + 也不可撑破外层 grid/flex 轨道导致整页错位。 */ +[id$="Table"] { overflow-x: auto; } + +/* ---------- reduced-motion 全局兜底:不留半成品动效 ---------- */ +@media (prefers-reduced-motion: reduce) { + * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } +} +html.reduced * { animation-duration: .001ms !important; transition-duration: .001ms !important; } diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css deleted file mode 100644 index e62ead7..0000000 --- a/xiaobai-datahub/admin/styles.css +++ /dev/null @@ -1,286 +0,0 @@ -/* xiaobai-datahub 数据中枢后台 —— 第七版「轨道机芯」 - 统一动效 Token:进入/退出用强 ease-out,屏内移动用强 ease-in-out,持续流动用 linear。 - 禁止 ease-in、禁止 transition:all —— 全文件遵守。 */ -:root { - color-scheme: light; - --bar-h: 52px; - --radius: 14px; - --pad: 16px; - --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; - --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; } -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); } -.ok { color: var(--ok); } -.warn { color: var(--warn); } -.fail { color: var(--danger); } - -/* ---------- 登录 / 改密(未进入舞台的前置流程) ---------- */ -.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; } -} diff --git a/xiaobai-datahub/admin/tokens.css b/xiaobai-datahub/admin/tokens.css new file mode 100644 index 0000000..f960034 --- /dev/null +++ b/xiaobai-datahub/admin/tokens.css @@ -0,0 +1,79 @@ +/* xiaobai-datahub 数据中枢后台 —— 第九版视觉基础 Token(A/B/C 三方向共用,唯一来源) + 来源:HEL-545 视觉规范 + prototype-A-flowline.html 原稿。 + 原则:精密、克制、成熟。禁止霓虹泛光、粒子、发光描边、蓝图网格、星球/轨道造型、大蓝球/星空。 + 任何视觉改动只改这个文件里的变量,禁止在各布局 CSS 里另起一套颜色/间距。 */ +:root { + color-scheme: light; + /* ---- 日间(默认) ---- */ + --bg0: #F4F6F8; + --bg1: #FFFFFF; + --bg2: #EEF1F4; + --bg3: #E4E9EF; + --line: rgba(15, 23, 42, .12); + --line-soft: rgba(15, 23, 42, .06); + + --t1: #16202C; + --t2: #4C5C6F; + --t3: #8593A5; + + --action: #2F6FE4; + --action-soft: rgba(47, 111, 228, .10); + --alive: #0E9F6E; + --alive-soft: rgba(14, 159, 110, .10); + --act: #C77F1A; + --act-soft: rgba(199, 127, 26, .12); + --error: #D0342C; + --error-soft: rgba(208, 52, 44, .10); + --unknown: #7B8898; + --unconfigured: #9AA7B5; + + --packet: #2F6FE4; + --shadow-pop: 0 8px 24px rgba(15, 23, 42, .12); + --glass: rgba(255, 255, 255, .86); + + --font-cn: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace; + + --fs-hero: 22px; + --fs-title: 15px; + --fs-body: 13px; + --fs-small: 12px; + --fs-label: 10.5px; + + --sp1: 4px; --sp2: 8px; --sp3: 12px; --sp4: 16px; --sp5: 24px; --sp6: 32px; + --r-card: 8px; --r-chip: 5px; --r-pill: 999px; + + --ease-out: cubic-bezier(.23, 1, .32, 1); + --dur-fast: 130ms; + --dur-ui: 220ms; + --dur-travel: 900ms; +} + +:root[data-theme="night"] { + color-scheme: dark; + --bg0: #0A0E13; + --bg1: #10151C; + --bg2: #161D26; + --bg3: #1C2530; + --line: rgba(148, 163, 184, .16); + --line-soft: rgba(148, 163, 184, .08); + + --t1: #E8EDF4; + --t2: #97A4B6; + --t3: #5E6C7F; + + --action: #4C8DFF; + --action-soft: rgba(76, 141, 255, .14); + --alive: #3CCB9A; + --alive-soft: rgba(60, 203, 154, .13); + --act: #F0A73C; + --act-soft: rgba(240, 167, 60, .13); + --error: #E5534B; + --error-soft: rgba(229, 83, 75, .13); + --unknown: #8B98A9; + --unconfigured: #5A6878; + + --packet: #8FB8FF; + --shadow-pop: 0 8px 24px rgba(0, 0, 0, .45); + --glass: rgba(13, 18, 28, .82); +}