Files
xiaobai-review/xiaobai-datahub/admin/core.js
T
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

372 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
};
})();