Compare commits

..
Author SHA1 Message Date
011ecc0d1a feat(HEL-546): 数据中枢 A/B/C 三方向落地为可用页面
- 基于第七版稳定基线(be647bb)重构 admin 前端为模块化架构:
  tokens.css(设计token唯一来源) + shared.css(公共组件) + core.js(状态/API/事件总线/轮询/路由)
  + main.js(入口) + layouts/{flowline,ledger,strata}.{css,js}(三方向独立布局)
  + layouts/shared.js(三方向共用数据视图与操作绑定)
- 三个页面(A装配线/B值班台账/C地层剖面)通过 ?layout= 查询参数独立可达、可刷新、可前进后退导航,
  共享登录态、真实后端数据(overview/sources/jobs/batches/datasets/audit)、错误处理与主题
- 六大板块(总览/数据源/调度任务/盘后发布/数据集/审计)在三个方向均可查看与操作(探测/触发/回滚/补数)
- 修正健康状态语义:CircuitBreaker closed/half_open/open 与适配器 ok/error/empty/unconfigured/unknown
  统一归一化为 ok/warn/error/unconfigured/unknown 供三方向一致展示
- 事件驱动动效:A 水平接力光梭、B 行级高亮+实时调用跑马灯、C 纵向贯穿光点+分层标记;
  统一使用 transform/opacity/WAAPI,避免 transition:all,支持 prefers-reduced-motion 静态降级
- 修复响应式布局在 1024/1280 断点因 CSS Grid 默认 min-width:auto 被内部宽表格撑爆轨道的问题
- 修复布局切换/前进后退时残留 setTimeout 回调在 unmount 后访问 null root 导致的报错(mounted 标志位+
  safeTimeout+统一清理定时器)
- 自测:Playwright 全断点(1440/1280/1024)x 双主题矩阵截图、90 次连续布局切换+前进后退压力测试无报错、
  探测/触发/回滚danger操作流程验证、reduced-motion 验证;后端 pytest 全量 133 用例通过,无回归
- 未改动:登录/鉴权契约、后端 API、'问天'冻结区、生产数据/权限

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-13 16:28:50 +08:00
14 changed files with 1915 additions and 1756 deletions
File diff suppressed because it is too large Load Diff
+371
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
}
function timeShort(value) {
const s = String(value ?? "");
const m = s.match(/(\d{2}:\d{2}:\d{2})/);
return m ? m[1] : s.replace("T", " ").slice(0, 16);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function table(headers, rows) {
const thead = headers.map((h) => `<th>${esc(h)}</th>`).join("");
const body = rows.length
? rows.map((cols) => `<tr>${cols.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")
: `<tr><td colspan="${headers.length}">暂无数据</td></tr>`;
return `<table class="grid"><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
}
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 = keyoverview/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 `<span class="chip ${chipClass(s)}">${esc(textOverride ?? s)}</span>`;
}
/* 真实健康态归一化: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 = `<div class="page-shell">布局未加载:${esc(name)}</div>`; 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,
};
})();
+25 -39
View File
@@ -4,7 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>xiaobai-datahub 管理后台 · 数据中枢</title>
<link rel="stylesheet" href="/admin/styles.css" />
<link rel="stylesheet" href="/admin/tokens.css" />
<link rel="stylesheet" href="/admin/shared.css" />
<link rel="stylesheet" href="/admin/layouts/flowline.css" />
<link rel="stylesheet" href="/admin/layouts/ledger.css" />
<link rel="stylesheet" href="/admin/layouts/strata.css" />
</head>
<body>
<div id="app">
@@ -30,49 +34,31 @@
</form>
</section>
<section id="shell" hidden>
<section id="shell" hidden style="display:flex; flex-direction:column; min-height:100vh;">
<header id="topbar">
<div class="logo">小白复盘 <em>·</em> 数据中枢</div>
<span class="crumb" id="crumb">8766 · 四源汇流 · 持续运转</span>
<span id="phase" class="pill"></span>
<div class="title">小白复盘 <em>·</em> 数据中枢</div>
<span class="vdiv"></span>
<span class="tdate mono" id="crumb">8766 · 四源汇流 · 持续运转</span>
<span class="spacer"></span>
<span id="who" class="muted who"></span>
<button type="button" id="theme-btn" class="ghost">夜间</button>
<button type="button" id="logout-btn" class="ghost">退出</button>
<nav id="layoutSwitch" aria-label="页面切换">
<button type="button" data-layout="flowline"><span class="k">A</span>装配线</button>
<button type="button" data-layout="ledger"><span class="k">B</span>值班台账</button>
<button type="button" data-layout="strata"><span class="k">C</span>地层剖面</button>
</nav>
<span class="who muted" id="who"></span>
<button type="button" id="theme-btn" class="btn ghost">夜间</button>
<button type="button" id="logout-btn" class="btn ghost">退出</button>
</header>
<!-- 动效版:一台连续空间舞台,六幕滚动切换,同一核心与流道贯穿始终 -->
<div id="stageWrap">
<canvas id="scene"></canvas>
<div id="cabin"></div>
<div id="legend">
<span class="lampico li-link"></span>LINK 链路常亮(低亮)<br>
<span class="lampico li-act"></span>ACT 活动灯 · 事件成簇短闪
</div>
<div id="rail"></div>
<div id="hint">滚 动 推 进 镜 头</div>
<div id="detail" class="closed">
<div class="dtag"></div>
<button id="detailClose" type="button" aria-label="关闭详情">×</button>
<div id="detailBody"></div>
</div>
</div>
<div id="track" aria-hidden="true">
<section data-scene="0"></section>
<section data-scene="1"></section>
<section data-scene="2"></section>
<section data-scene="3"></section>
<section data-scene="4"></section>
<section data-scene="5"></section>
</div>
<!-- 减少动态效果版:六幕静态空间图 + 固定文字状态,信息与功能完全等价 -->
<main id="staticShell" hidden>
<nav id="staticNav"></nav>
<div id="staticScenes"></div>
</main>
<main id="page-root" style="flex:1;"></main>
</section>
</div>
<script src="/admin/app.js"></script>
<script src="/admin/core.js"></script>
<script src="/admin/layouts/shared.js"></script>
<script src="/admin/layouts/flowline.js"></script>
<script src="/admin/layouts/ledger.js"></script>
<script src="/admin/layouts/strata.js"></script>
<script src="/admin/main.js"></script>
</body>
</html>
@@ -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; }
}
+300
View File
@@ -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 = `
<div id="fl-root" class="page-shell">
<section id="fl-band" class="card">
<div class="lane-labels">
<span class="sec-label">来源 SOURCES</span>
<span class="sec-label">加工 PROCESS</span>
<span class="sec-label">发布 PUBLISH</span>
<span class="sec-label">审计 AUDIT</span>
</div>
<div id="fl-wire"><span class="chev" style="left:26%">&rsaquo;</span><span class="chev" style="left:57%">&rsaquo;</span><span class="chev" style="left:80%">&rsaquo;</span></div>
<div id="fl-lanes">
<div id="fl-srcCards"></div>
<div id="fl-process" class="card fl-station" data-station="process">
<div class="hd"><span class="nm">中枢处理</span><span class="grow"></span><span data-pct class="mono">-</span></div>
<div id="fl-processBody"></div>
</div>
<div id="fl-publish" class="card fl-station fl-gate" data-station="publish">
<div class="hd"><span class="nm">发布闸口</span><span class="grow"></span><span class="sec-label">GATE</span></div>
<div id="fl-publishBody"></div>
</div>
<div id="fl-audit" class="card fl-station fl-audit" data-station="audit">
<div class="hd"><span class="nm">审计末端</span></div>
<div id="fl-auditBody"></div>
</div>
</div>
<div id="fl-packet"></div>
</section>
<div id="fl-cols">
<section id="fl-col1" class="card fl-col">
<span class="sec-label">最近调用 RECENT CALLS</span>
<div id="fl-callsTable"></div>
<div id="fl-callsFoot"></div>
</section>
<section id="fl-col2" class="card fl-col">
<span class="sec-label">调度任务 JOBS</span>
<div id="fl-jobRows"></div>
<span class="sec-label sub-label">最近运行 RUNS</span>
<div id="fl-runsTable"></div>
</section>
<section id="fl-col3" class="card fl-col">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="toolrow">
<input id="fl-rel-date" value="" spellcheck="false" />
<button type="button" class="btn" id="fl-rel-load">查看</button>
<button type="button" class="btn danger" id="fl-rel-backfill">补数</button>
</div>
<span class="sec-label sub-label">当前映射 MAPPING</span>
<div id="fl-pubTable"></div>
<div id="fl-anom" class="fl-anom"></div>
</section>
</div>
</div>
`;
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 `
<div class="fl-src-card card" data-provider="${esc(item.provider)}">
<div class="edge"></div>
<div class="row">
<span class="name">${esc(C.FLOW_LABEL[item.provider] || item.provider)}</span>
<span class="role">${esc(C.FLOW_ROLE[item.provider] || item.role)}</span>
<span class="grow"></span>
${C.chipHtml(link, health.state || link)}
</div>
<div class="row">
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}" data-link></i><span class="lamp-tag">LINK</span></span>
<span class="lamps"><i class="lamp act" data-act></i><span class="lamp-tag">ACT</span></span>
<span class="grow"></span>
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
</div>
<div class="row">
<span class="cred mono">${credText}</span><span class="grow"></span>
<button class="btn ghost mini" data-probe="${esc(item.provider)}">探测一次</button>
</div>
</div>`;
}
function renderSources() {
const v = S.sourcesView();
const host = $("fl-srcCards");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map(srcCardHtml).join("") +
(v.reserved.length ? `<div class="fl-src-card card muted" style="align-items:center;justify-content:center;font-size:12px;grid-column: 1 / -1">预留源 ${v.reserved.length} 个 · 未接入 · 无真实调用</div>` : "");
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 = `<div class="muted">正在加载…</div>`; 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 = `
<div class="task mono"><b>${esc(latest.job_id)}</b> · ${esc((jv.jobs.find((j) => j.id === latest.job_id) || {}).title || "")}</div>
<div class="fl-pbar"><i style="width:${pct}%;background:${latest.state === "failed" ? "var(--error)" : "var(--action)"}"></i></div>
<div class="queue">队列 <b class="mono">${running}</b> · 最近 20 次失败 <span class="chip ${jv.failedRecent ? "err" : "ok"}">${jv.failedRecent}</span></div>
<div class="fl-miniruns">
${jv.runs.slice(0, 3).map((r) => `<div class="mr mono"><span class="id">${r.id}</span><span class="${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span><span class="${r.error ? "why" : "muted"}">${esc(r.error || (r.finished_at !== "-" && r.finished_at ? timeShort(r.finished_at) : "—"))}</span></div>`).join("")}
</div>`;
}
function renderPublish() {
const rv = S.releaseView();
const body = $("fl-publishBody");
if (!rv) { body.innerHTML = `<div class="muted">正在加载…</div>`; 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 = `
<div class="bignum"><span class="n mono" data-pubnum>${rv.pubs.length}</span><span class="cap">今日发布</span></div>
<div class="latest mono">最新批次 ${esc(latestPub ? `${latestPub.active_batch} · ${latestPub.published_at}` : "暂无")}</div>
${valuation ? `<div class="valrow"><span class="mono">valuation</span>${C.chipHtml(valuation.state)}</div>` : ""}`;
}
function renderAudit() {
const av = S.auditView();
const body = $("fl-auditBody");
if (!av || !av.items.length) { body.innerHTML = `<div class="muted">正在加载…</div>`; return; }
const a0 = av.items[0];
body.innerHTML = `
<div class="al mono">${esc(timeShort(a0.created_at))} ${esc(a0.actor)} · ${esc(a0.action)}</div>
<div class="al mono dim">${esc(a0.target)}${a0.detail ? " · " + esc(a0.detail) : ""}</div>
<div class="adiv"></div>
${av.items.slice(1, 3).map((a) => `<div class="al mono dim">${esc(timeShort(a.created_at))} ${esc(a.actor)} · ${esc(a.action)}</div>`).join("")}`;
}
function renderCalls() {
const ov = S.overviewView();
const sv = S.sourcesView();
if (!ov) { $("fl-callsTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("fl-callsTable").innerHTML = C.table(["时间", "源", "端点", "结果", "耗时"], ov.recentCalls.map((row) => [
`<span class="mono">${esc(timeShort(row.created_at))}</span>`,
`<span class="mono k">${esc(row.provider)}</span>`,
`<span class="mono">${esc(row.endpoint)}</span>`,
row.ok ? '<span class="res-ok">成功</span>' : `<span class="res-err mono">${esc(row.error)}</span>`,
`<span class="mono">${row.latency_ms ?? "-"} ms</span>`,
]));
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 = `<div class="muted">正在加载…</div>`; return; }
jobRows.innerHTML = jv.jobs.map((job) => `
<div class="fl-jobrow">
<span class="jid mono">${esc(job.id)}</span><span class="jti">${esc(job.title)}</span>
<span class="jat mono">${esc(job.at)}</span>${C.chipHtml(jv.latestByJob.get(job.id) ? jv.latestByJob.get(job.id).state : "idle")}
<span class="grow"></span><button class="btn ghost mini" data-run="${esc(job.id)}">手动触发</button>
</div>`).join("");
S.bindRunButtons(jobRows);
$("fl-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], jv.runs.map((r) => [
`<span class="mono">${r.id}</span>`, `<span class="mono k">${esc(r.job_id)}</span>`,
`<span class="mono ${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span>`,
`<span class="mono">${esc(timeShort(r.started_at))}</span>`, `<span class="mono">${esc(timeShort(r.finished_at))}</span>`,
`<span class="fail">${esc(r.error || "")}</span>`,
]));
}
function renderReleaseCol() {
const rv = S.releaseView();
const dateInput = $("fl-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!rv) { $("fl-pubTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("fl-pubTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], rv.pubs.map((p) => [
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
`<span class="mono">${esc(p.prev_batch || "-")}</span>`, C.chipHtml(p.state),
`<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
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) => `<span class="chip err mono">${esc(b.batch_id)} · ${esc(b.dataset)} · ${esc(b.error || "异常")}</span>`).join(" ")
: `<span class="chip ok">批次全部正常</span>`;
}
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 };
})();
+81
View File
@@ -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; }
}
+291
View File
@@ -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 = `
<div id="lg-root" class="page-shell">
<div id="lg-grid">
<div id="lg-side">
<section class="card lg-panel">
<span class="sec-label">来源健康 SOURCES</span>
<div id="lg-srcList"></div>
</section>
<section class="card lg-panel">
<span class="sec-label">调度任务 JOBS</span>
<div id="lg-jobList"></div>
</section>
</div>
<section class="card" id="lg-ledger">
<div class="lg-ledger-hd">
<span class="sec-label">事件台账 EVENT LEDGER</span>
<span class="hint">每条事件依次经过 响应→汇入→处理→发布→审计</span>
</div>
<div style="overflow:auto; max-height: 62vh;">
<table class="lg-ledger">
<thead><tr>
<th>时间</th><th>事件</th><th>来源</th>
<th class="stage">响应<br/><span style="opacity:.6">RESP</span></th>
<th class="stage">汇入<br/><span style="opacity:.6">INGEST</span></th>
<th class="stage">处理<br/><span style="opacity:.6">PROC</span></th>
<th class="stage">发布<br/><span style="opacity:.6">PUB</span></th>
<th class="stage">审计<br/><span style="opacity:.6">AUDIT</span></th>
</tr></thead>
<tbody id="lg-ledgerBody"></tbody>
</table>
</div>
<div id="lg-tape">
<span class="sec-label">调用带 CALL TAPE</span>
<div id="lg-tape-track"><div id="lg-tape-inner"></div></div>
</div>
</section>
<div id="lg-side-r">
<section class="card lg-panel" id="lg-release">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="toolrow">
<input id="lg-rel-date" value="" />
<button type="button" class="btn mini" id="lg-rel-load">查看</button>
<button type="button" class="btn danger mini" id="lg-rel-backfill">补数</button>
</div>
<div id="lg-pubTable"></div>
</section>
<section class="card lg-panel">
<span class="sec-label">审计 AUDIT</span>
<div id="lg-auditList"></div>
</section>
</div>
</div>
</div>
`;
function renderSources() {
const v = S.sourcesView();
const host = $("lg-srcList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map((it) => {
const health = it.health || {};
const link = it._link || C.linkState(health);
return `<div class="lg-src-row" data-provider="${esc(it.provider)}">
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}"></i><i class="lamp act" data-act></i></span>
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
${C.chipHtml(link, health.state || link)}
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
<button class="btn ghost mini" data-probe="${esc(it.provider)}">探测</button>
</div>`;
}).join("");
S.bindProbeButtons(host, () => renderSources());
}
function renderJobs() {
const v = S.jobsView();
const host = $("lg-jobList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.jobs.map((job) => {
const latest = v.latestByJob.get(job.id);
return `<div class="lg-jobline">
${C.chipHtml(latest ? latest.state : "idle")}
<span class="nm mono">${esc(job.id)}</span>
<span class="at mono">${esc(job.at)}</span>
<button class="btn ghost mini" data-run="${esc(job.id)}">触发</button>
</div>`;
}).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 = `<div class="muted">正在加载…</div>`; return; }
$("lg-pubTable").innerHTML = C.table(["数据集", "批次", "状态", "时间", ""], v.pubs.map((p) => [
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
C.chipHtml(p.state), `<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
S.bindRollbackButtons($("lg-pubTable"), () => renderRelease());
}
function renderAuditList() {
const v = S.auditView();
const host = $("lg-auditList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.items.slice(0, 10).map((a) => `
<div class="lg-auditline mono">
<div>${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> <span class="${String(a.action).includes("rollback") ? "warn" : ""}">${esc(a.action)}</span></div>
<div class="dim">${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}</div>
</div>`).join("");
}
/* ---------------------------------------------------------------- 台账行合成
每行只填该事件类型真实具备的阶段;不同类型互不编造对方的字段。 */
function cell(text, kind) {
if (text == null || text === "") return `<span class="lg-cell dash">—</span>`;
return `<span class="lg-cell ${kind || ""}">${esc(text)}</span>`;
}
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) => `
<tr class="lg-row ${seenRowKeys.has(r.key) ? "" : "flashnew"} ${r.err ? "err" : ""}" data-key="${esc(r.key)}">
<td class="mono">${esc(timeShort(r.t))}</td>
<td class="ev"><div class="nm">${esc(r.name)}</div><div class="sub">${esc(r.sub || "")}</div></td>
<td class="src mono">${esc(r.source)}</td>
<td class="stage">${r.resp}</td>
<td class="stage">${r.ingest}</td>
<td class="stage">${r.proc}</td>
<td class="stage">${r.pub}</td>
<td class="stage">${r.audit}</td>
</tr>`).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 = `<span class="lg-tape-item muted">暂无真实调用</span>`; inner.style.animation = "none"; return; }
const items = ov.recentCalls.slice(0, 16).map((c) => `<span class="lg-tape-item ${c.ok ? "" : "err"}">${esc(timeShort(c.created_at))} <b>${esc(c.provider)}</b> ${esc(c.endpoint)} ${c.ok ? "✓" : "× " + esc(c.error)}</span>`).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 };
})();
+136
View File
@@ -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,
};
})();
+60
View File
@@ -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; }
}
+248
View File
@@ -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 = `
<div id="st-root" class="page-shell">
<div id="st-spine"></div>
<div id="st-dot"></div>
<section class="st-band card" data-band="sources">
<div class="st-idx"><span class="n">01</span><span class="lb">SOURCES<br/>来源层</span></div>
<div id="st-sources"></div>
</section>
<section class="st-band card" data-band="process">
<div class="st-idx"><span class="n">02</span><span class="lb">PROCESS<br/>加工层</span></div>
<div id="st-process">
<div class="st-jobs">
<span class="sec-label">调度任务 JOBS</span>
<div id="st-jobList"></div>
</div>
<div class="st-runs">
<span class="sec-label">最近运行 RUNS</span>
<div id="st-runsTable"></div>
</div>
</div>
</section>
<section class="st-band card" data-band="publish">
<div class="st-idx"><span class="n">03</span><span class="lb">PUBLISH<br/>发布层</span></div>
<div id="st-publish">
<div class="st-gate">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="bignum"><span class="n mono" id="st-pubnum">-</span><span class="cap">今日发布</span></div>
<div class="win mono" id="st-pubwin"></div>
<div class="toolrow" style="margin-top:8px;display:flex;gap:6px;">
<input id="st-rel-date" style="width:88px;font-family:var(--font-mono);font-size:11px;background:var(--bg2);border:1px solid var(--line);border-radius:5px;padding:4px 6px;color:var(--t1)" />
<button type="button" class="btn mini" id="st-rel-load">查看</button>
<button type="button" class="btn danger mini" id="st-rel-backfill">补数</button>
</div>
</div>
<div class="st-datasets">
<span class="sec-label">数据集 DATASETS</span>
<div id="st-datasetsTable"></div>
</div>
</div>
</section>
<section class="st-band card" data-band="audit">
<div class="st-idx"><span class="n">04</span><span class="lb">AUDIT<br/>审计层</span></div>
<div id="st-audit"></div>
</section>
</div>
`;
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 = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map((it) => {
const health = it.health || {};
const link = it._link || C.linkState(health);
return `<div class="st-src card" data-sel-provider="${esc(it.provider)}" data-provider="${esc(it.provider)}">
<div class="row">
<span class="lamp link ${C.linkLampClass(link)}"></span>
<span class="lamp act" data-act></span>
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
${C.chipHtml(link, health.state || link)}
</div>
<div class="sub">${esc(C.FLOW_ROLE[it.provider] || it.role)} · 配置 ${(it.credential || {}).configured ? "已配置" : "未配置"} · ${it.calls_today ?? 0} 次</div>
<div class="row" style="margin-top:6px;justify-content:space-between">
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
<button class="btn ghost mini" data-probe="${esc(it.provider)}">探测一次</button>
</div>
</div>`;
}).join("") + (v.reserved.length ? `<div class="st-reserved">预留 ${v.reserved.length} 个<br/>未接入</div>` : "");
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 = `<div class="muted">正在加载…</div>`; return; }
jobHost.innerHTML = v.jobs.map((job) => {
const latest = v.latestByJob.get(job.id);
return `<div class="st-job" data-sel-job="${esc(job.id)}" data-job="${esc(job.id)}">
${C.chipHtml(latest ? latest.state : "idle")}
<span class="nm mono">${esc(job.id)}</span><span class="at mono">${esc(job.at)}</span>
<button class="btn ghost mini" data-run="${esc(job.id)}">触发</button>
</div>`;
}).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) => [
`<span class="mono" data-rel-job="${esc(r.job_id)}">${r.id}</span>`,
`<span class="mono k">${esc(r.job_id)}</span>`,
`<span class="mono ${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span>`,
`<span class="mono">${esc(timeShort(r.started_at))}</span>`, `<span class="mono">${esc(timeShort(r.finished_at))}</span>`,
`<span class="fail">${esc(r.error || "")}</span>`,
]));
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 = `<div class="muted">正在加载…</div>`; 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) => [
`<span class="mono k" data-rel-dataset="${esc(p.dataset)}">${esc(p.dataset)}</span>`,
`<span class="mono">${esc(p.active_batch)}</span>`, `<span class="mono">${esc(p.prev_batch || "-")}</span>`,
C.chipHtml(p.state), `<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
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 = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.items.slice(0, 8).map((a) => `
<div class="st-audit-line ${String(a.action).includes("rollback") ? "warn" : ""}">
${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> ${esc(a.action)} · ${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}
</div>`).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 };
})();
+91
View File
@@ -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();
})();
+136
View File
@@ -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; }
-278
View File
@@ -1,278 +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;
--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);
/* 星港中枢 · 画布调色(与 app.js PAL.day 保持一致,仅作文档留存) */
--star-hi: #FFFFFF; --star-a: #EAF6FF; --star-b: #A9CCF0; --star-c: #5488C4; --star-d: #2E5A94;
--orbit-line: rgba(90, 120, 180, .40);
--sat1: #3997B4; --sat2: #BF873F; --sat3: #537EBF; --sat4: #BF525F;
}
:root[data-theme="night"] {
color-scheme: dark;
--bg0: #05080F;
--bg1: #0A1020;
--surface: #12172A;
--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);
/* 星港中枢 · 画布调色(与 app.js PAL.night 保持一致,仅作文档留存) */
--star-hi: #F2FAFF; --star-a: #C9E7FF; --star-b: #7FB2EA; --star-c: #3E6FAE; --star-d: #16305E;
--orbit-line: rgba(140, 180, 255, .22);
--sat1: #4CC9F0; --sat2: #FFB454; --sat3: #6FA8FF; --sat4: #FF6E7F;
}
* { 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; }
/* ---------- 全幅星空舞台(100vw × (100vh-52px),六幕连续场景) ----------
第八版取消卡片式舞台:星空即整页背景,无圆角、无边框、无外边距。
#track 的六段高幕(每段 130vh)在它身后正常参与文档流把页面撑高,
#stageWrap 用 sticky 钉在视口里,滚动时只有 #track 的高度被“划过”,
舞台本体在这段距离内始终可见、位置不变,直到 #track 撑出的空间耗尽才随之离场。
星系几何(星体/轨道/卫星/去向端/双灯)的安全盒与缩放公式见 app.js Stage.resize()。 */
#stageWrap {
flex: none; width: 100vw; height: calc(100vh - var(--bar-h)); min-height: 480px;
margin: 0; border-radius: 0; overflow: hidden; border: 0;
box-shadow: inset 0 0 160px rgba(0, 0, 0, .10);
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: 24px; top: 24px; width: 300px; z-index: 5; max-height: calc(100% - 48px);
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 三档桌面宽(须与 app.js cabinW 三档同步) ---------- */
@media (max-width: 1280px) {
#cabin { width: 260px; }
#detail { width: 320px; }
}
@media (max-width: 1024px) {
#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; }
}
+79
View File
@@ -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);
}