- 基于第七版稳定基线(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>
372 lines
17 KiB
JavaScript
372 lines
17 KiB
JavaScript
"use strict";
|
||
/* ==========================================================================
|
||
xiaobai-datahub 管理后台 · 第九版核心引擎(A/B/C 三方向唯一共用来源)
|
||
—— 会话/主题/轮询/真实事件识别/危险操作/路由,三个布局文件只消费这里的
|
||
state、Bus 事件与 API,禁止各自再写一套请求或业务判断。
|
||
========================================================================== */
|
||
window.Core = (function () {
|
||
function $(id) { return document.getElementById(id); }
|
||
function esc(value) {
|
||
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch]));
|
||
}
|
||
function timeShort(value) {
|
||
const s = String(value ?? "");
|
||
const m = s.match(/(\d{2}:\d{2}:\d{2})/);
|
||
return m ? m[1] : s.replace("T", " ").slice(0, 16);
|
||
}
|
||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||
function table(headers, rows) {
|
||
const thead = headers.map((h) => `<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 = key(overview/sources/...),布局重绘用
|
||
'event' → 已经发生的真实事件(供三方向各自演绎接力/推进/贯穿动效)
|
||
payload = { channel, kind, n, meta }
|
||
channel: tushare|eastmoney|tencent|ifind|junction|tx|audit
|
||
kind: ok|error|unconfigured|rollback|pub
|
||
'theme' → 主题切换,payload = 'day'|'night'
|
||
'reduced' → 减弱动效状态变化,payload = boolean
|
||
*/
|
||
const Bus = (() => {
|
||
const listeners = {};
|
||
return {
|
||
on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); return () => this.off(type, fn); },
|
||
off(type, fn) { if (listeners[type]) listeners[type] = listeners[type].filter((f) => f !== fn); },
|
||
emit(type, payload) { (listeners[type] || []).slice().forEach((fn) => { try { fn(payload); } catch (e) { console.error(e); } }); },
|
||
};
|
||
})();
|
||
|
||
/* ---------------------------------------------------------------- 常量与标签 */
|
||
const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"];
|
||
const FLOW_LABEL = {
|
||
tushare: "Tushare", eastmoney: "东方财富", tencent: "腾讯行情", ifind: "iFinD",
|
||
};
|
||
const FLOW_ROLE = {
|
||
tushare: "官方盘后", eastmoney: "盘中观察", tencent: "盘中观察", ifind: "授权实时",
|
||
};
|
||
const EOD_LABELS = {
|
||
pending_first_attempt: "等待首次尝试", waiting_upstream: "等待上游", done: "已成功",
|
||
cutoff_failed: "已截止失败", closed_day: "休市",
|
||
};
|
||
const REV_LABELS = {
|
||
waiting_review: "等待复核", review_failed: "复核失败", aligned: "已追平",
|
||
cutoff: "已截止", pending_publish: "待发布", closed_day: "休市",
|
||
};
|
||
const PHASE_LABELS = { pre: "盘前", intraday: "盘中", lunch: "午间", eod: "盘后", closed: "休市" };
|
||
function todayYmd() {
|
||
const d = new Date(); const pad = (n) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
||
}
|
||
if (!state.releaseDate) state.releaseDate = todayYmd();
|
||
|
||
function chipClass(s) {
|
||
const map = {
|
||
ok: "ok", error: "err", warn: "warn", unknown: "unknown", unconfigured: "unconfigured",
|
||
published: "ok", pending: "warn", missing: "err", building: "warn", staged: "warn", failed: "err",
|
||
running: "warn", queued: "info", idle: "", success: "ok",
|
||
};
|
||
return map[s] ?? "";
|
||
}
|
||
function chipHtml(s, textOverride) {
|
||
return `<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,
|
||
};
|
||
})();
|