"use strict"; /* ========================================================================== xiaobai-datahub 管理后台 · 第九版核心引擎(A/B/C 三方向唯一共用来源) —— 会话/主题/轮询/真实事件识别/危险操作/路由,三个布局文件只消费这里的 state、Bus 事件与 API,禁止各自再写一套请求或业务判断。 ========================================================================== */ window.Core = (function () { function $(id) { return document.getElementById(id); } function esc(value) { return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); } function timeShort(value) { const s = String(value ?? ""); const m = s.match(/(\d{2}:\d{2}:\d{2})/); return m ? m[1] : s.replace("T", " ").slice(0, 16); } function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } function table(headers, rows) { const thead = headers.map((h) => `${esc(h)}`).join(""); const body = rows.length ? rows.map((cols) => `${cols.map((c) => `${c}`).join("")}`).join("") : `暂无数据`; return `${thead}${body}
`; } const state = { csrf: "", layout: "flowline", releaseDate: "", reduced: matchMedia("(prefers-reduced-motion: reduce)").matches, visible: document.visibilityState === "visible", online: navigator.onLine, data: { overview: null, sources: null, jobs: null, batches: null, datasets: null, audit: null }, }; async function api(path, options = {}) { const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {}); if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf; const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" })); const body = await res.json(); if (!res.ok) { const msg = (body.error && body.error.message) || body.error || res.statusText; throw new Error(msg); } return body; } /* ---------------------------------------------------------------- 事件总线 'data' → 任一真实拉取完成,payload = key(overview/sources/...),布局重绘用 'event' → 已经发生的真实事件(供三方向各自演绎接力/推进/贯穿动效) payload = { channel, kind, n, meta } channel: tushare|eastmoney|tencent|ifind|junction|tx|audit kind: ok|error|unconfigured|rollback|pub 'theme' → 主题切换,payload = 'day'|'night' 'reduced' → 减弱动效状态变化,payload = boolean */ const Bus = (() => { const listeners = {}; return { on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); return () => this.off(type, fn); }, off(type, fn) { if (listeners[type]) listeners[type] = listeners[type].filter((f) => f !== fn); }, emit(type, payload) { (listeners[type] || []).slice().forEach((fn) => { try { fn(payload); } catch (e) { console.error(e); } }); }, }; })(); /* ---------------------------------------------------------------- 常量与标签 */ const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"]; const FLOW_LABEL = { tushare: "Tushare", eastmoney: "东方财富", tencent: "腾讯行情", ifind: "iFinD", }; const FLOW_ROLE = { tushare: "官方盘后", eastmoney: "盘中观察", tencent: "盘中观察", ifind: "授权实时", }; const EOD_LABELS = { pending_first_attempt: "等待首次尝试", waiting_upstream: "等待上游", done: "已成功", cutoff_failed: "已截止失败", closed_day: "休市", }; const REV_LABELS = { waiting_review: "等待复核", review_failed: "复核失败", aligned: "已追平", cutoff: "已截止", pending_publish: "待发布", closed_day: "休市", }; const PHASE_LABELS = { pre: "盘前", intraday: "盘中", lunch: "午间", eod: "盘后", closed: "休市" }; function todayYmd() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; } if (!state.releaseDate) state.releaseDate = todayYmd(); function chipClass(s) { const map = { ok: "ok", error: "err", warn: "warn", unknown: "unknown", unconfigured: "unconfigured", published: "ok", pending: "warn", missing: "err", building: "warn", staged: "warn", failed: "err", running: "warn", queued: "info", idle: "", success: "ok", }; return map[s] ?? ""; } function chipHtml(s, textOverride) { return `${esc(textOverride ?? s)}`; } /* 真实健康态归一化:tushare 走断路器术语(closed=健康/half_open=试探/open=已跳闸), 其余三路走 ok/empty/error/unconfigured;没有探测记录时是 unknown。 四态必须原样区分显示,不得把 unknown 和 error 混为一谈,也不得把断路器 "closed"(健康) 误读成红色错误。 */ function linkState(health) { const s = (health && health.state) || "unknown"; if (s === "ok" || s === "empty" || s === "closed") return "ok"; if (s === "half_open") return "warn"; if (s === "unconfigured" || s === "reserved") return "unconfigured"; if (s === "unknown") return "unknown"; return "error"; // open(断路器已跳闸)、error } function linkLampClass(link) { if (link === "ok") return "on"; if (link === "error") return "err on"; return "off"; // warn / unknown / unconfigured 灯位统一暗灭,状态文字由 chip 承载 } /* ---------------------------------------------------------------- 真实事件识别 只认已经发生的事实:recent_calls / probe 健康探测 / job runs / batch 状态变化 / publication 发布时间变化 / audit 新行。没有真实变化就不发事件,不臆造。 */ const Seen = { callsMax: -1, jobRunsMax: -1, auditMax: -1, batchState: new Map(), pubPublishedAt: new Map(), firstOverview: true, firstJobs: true, firstAudit: true, firstBatches: true, }; async function pollOverview() { const data = await api("/admin/api/overview"); const wasFirst = Seen.firstOverview; const calls = data.recent_calls || []; let maxId = Seen.callsMax; const fresh = []; for (const c of calls) { if (c.id > Seen.callsMax) fresh.push(c); if (c.id > maxId) maxId = c.id; } Seen.callsMax = maxId; Seen.firstOverview = false; if (!wasFirst && fresh.length) Bus.emit("event", { channel: "tushare", kind: fresh.some((c) => !c.ok) ? "error" : "ok", n: fresh.length, meta: { fresh } }); state.data.overview = data; Bus.emit("data", "overview"); } async function pollSources() { const data = await api("/admin/api/sources"); for (const item of data.items) { if (!FLOW_PROVIDERS.includes(item.provider)) continue; const health = item.health || {}; const link = linkState(health); item._link = link; if (item.provider !== "tushare" && link !== "unconfigured" && link !== "unknown") { Bus.emit("event", { channel: item.provider, kind: link === "ok" ? "ok" : link === "warn" ? "warn" : "error", n: 1, meta: { item } }); } } state.data.sources = data; Bus.emit("data", "sources"); } async function pollJobs() { const data = await api("/admin/api/jobs"); const wasFirst = Seen.firstJobs; let maxId = Seen.jobRunsMax; const fresh = []; for (const r of data.runs || []) { if (r.id > Seen.jobRunsMax) fresh.push(r); if (r.id > maxId) maxId = r.id; } Seen.jobRunsMax = maxId; Seen.firstJobs = false; if (!wasFirst && fresh.length) { const anyFail = fresh.some((r) => r.state === "failed"); Bus.emit("event", { channel: "junction", kind: anyFail ? "error" : "ok", n: fresh.length, meta: { fresh } }); } state.data.jobs = data; state.freshJobRuns = fresh; Bus.emit("data", "jobs"); } async function pollBatches() { const date = state.releaseDate || ""; const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); const wasFirst = Seen.firstBatches; let changed = false; for (const b of data.batches || []) { const prev = Seen.batchState.get(b.batch_id); if (prev !== b.state) { changed = true; Seen.batchState.set(b.batch_id, b.state); } } let published = false; for (const p of data.publications || []) { const key = `${p.dataset}:${p.trade_date}`; const prev = Seen.pubPublishedAt.get(key); if (prev !== p.published_at) { published = true; Seen.pubPublishedAt.set(key, p.published_at); } } Seen.firstBatches = false; if (!wasFirst && published) Bus.emit("event", { channel: "tx", kind: "pub", n: 1, meta: {} }); else if (!wasFirst && changed) Bus.emit("event", { channel: "junction", kind: "ok", n: 1, meta: {} }); state.data.batches = data; Bus.emit("data", "batches"); } async function pollDatasets() { const data = await api(`/admin/api/datasets?date=${encodeURIComponent("")}`); state.data.datasets = data; Bus.emit("data", "datasets"); } async function pollAudit() { const data = await api("/admin/api/audit"); const wasFirst = Seen.firstAudit; let maxId = Seen.auditMax; const fresh = []; for (const a of data.items || []) { if (a.id > Seen.auditMax) fresh.push(a); if (a.id > maxId) maxId = a.id; } Seen.auditMax = maxId; Seen.firstAudit = false; if (!wasFirst && fresh.length) { const rollback = fresh.some((a) => String(a.action).includes("rollback")); Bus.emit("event", { channel: "audit", kind: rollback ? "rollback" : "ok", n: fresh.length, meta: { fresh } }); } state.data.audit = data; Bus.emit("data", "audit"); } const Poller = (() => { const specs = [ { key: "overview", fn: pollOverview, every: 20000 }, { key: "sources", fn: pollSources, every: 45000 }, { key: "jobs", fn: pollJobs, every: 25000 }, { key: "batches", fn: pollBatches, every: 25000 }, { key: "datasets", fn: pollDatasets, every: 60000 }, { key: "audit", fn: pollAudit, every: 20000 }, ]; const timers = new Map(); function runtimeAvailable() { return state.visible && state.online; } function tickOne(spec) { spec.fn().catch((err) => console.error(`[hub] poll ${spec.key} failed`, err)); } function schedule(spec) { clearTimer(spec.key); const t = setInterval(() => { if (runtimeAvailable()) tickOne(spec); }, spec.every); timers.set(spec.key, t); } function clearTimer(key) { if (timers.has(key)) { clearInterval(timers.get(key)); timers.delete(key); } } function startAll() { specs.forEach((spec, i) => { setTimeout(() => { tickOne(spec); schedule(spec); }, i * 160); }); } function stopAll() { specs.forEach((s) => clearTimer(s.key)); } function pause() { stopAll(); } function resume() { specs.forEach((spec) => { tickOne(spec); schedule(spec); }); } return { startAll, stopAll, pause, resume, runtimeAvailable }; })(); function onRuntimeAvailabilityChange() { if (Poller.runtimeAvailable()) Poller.resume(); else Poller.pause(); Bus.emit("runtime", Poller.runtimeAvailable()); } document.addEventListener("visibilitychange", () => { state.visible = document.visibilityState === "visible"; onRuntimeAvailabilityChange(); }); window.addEventListener("online", () => { state.online = true; onRuntimeAvailabilityChange(); }); window.addEventListener("offline", () => { state.online = false; onRuntimeAvailabilityChange(); }); /* ---------------------------------------------------------------- 主题 */ function applyTheme(theme) { const root = document.documentElement; if (theme === "night") root.setAttribute("data-theme", "night"); else root.removeAttribute("data-theme"); try { localStorage.setItem("hub_theme", theme); } catch { /* ignore */ } Bus.emit("theme", theme); } function currentTheme() { return document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"; } function toggleTheme() { applyTheme(currentTheme() === "night" ? "day" : "night"); } function bootTheme() { const saved = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })(); applyTheme(saved === "night" ? "night" : "day"); } /* ---------------------------------------------------------------- 减少动态效果 */ const REDUCE_MQ = matchMedia("(prefers-reduced-motion: reduce)"); function applyReduced(reduced) { state.reduced = reduced; document.documentElement.classList.toggle("reduced", reduced); Bus.emit("reduced", reduced); } REDUCE_MQ.addEventListener("change", (e) => applyReduced(e.matches)); /* ---------------------------------------------------------------- 危险操作确认 */ async function dangerous(kind, dataset, onDone) { const date = state.releaseDate || ""; const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference)", "daily"); if (!ds) return; const password = prompt("二次确认:输入管理密码"); if (!password) return; const confirmWord = `${ds}:${date}`; const typed = prompt(`请输入确认词:${confirmWord}`); if (!typed) return; const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill"; try { const result = await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) }); if (kind === "rollback") Bus.emit("event", { channel: "tx", kind: "rollback", n: 1, meta: {} }); await pollBatches(); await pollAudit(); if (onDone) onDone(result); } catch (err) { alert(err.message); } } async function probeSource(provider) { const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" }); if (FLOW_PROVIDERS.includes(provider)) { const link = linkState(result); Bus.emit("event", { channel: provider, kind: link === "ok" ? "ok" : link === "warn" ? "warn" : "error", n: 1, meta: { manual: true } }); } await pollSources(); return result; } async function runJob(jobId, tradeDate) { const result = await api(`/admin/api/jobs/${jobId}/run`, { method: "POST", body: JSON.stringify({ trade_date: tradeDate || "" }) }); await pollJobs(); return result; } /* ---------------------------------------------------------------- 路由:?layout=flowline|ledger|strata 单一静态文件,只靠查询参数区分三页;不改后端路由。 支持直接打开 / 刷新 / 浏览器前进后退。 */ const LAYOUT_NAMES = ["flowline", "ledger", "strata"]; const Router = (() => { let mounted = null; function parse() { const qs = new URLSearchParams(location.search); const l = qs.get("layout"); return LAYOUT_NAMES.includes(l) ? l : "flowline"; } function urlFor(name) { const qs = new URLSearchParams(location.search); qs.set("layout", name); return `${location.pathname}?${qs.toString()}${location.hash}`; } function navigate(name) { if (!LAYOUT_NAMES.includes(name)) return; if (parse() === name) return; history.pushState(null, "", urlFor(name)); mount(name); } function mount(name) { if (mounted && window.HUB_LAYOUTS[mounted] && window.HUB_LAYOUTS[mounted].unmount) { try { window.HUB_LAYOUTS[mounted].unmount(); } catch (e) { console.error(e); } } state.layout = name; mounted = name; document.querySelectorAll("#layoutSwitch button[data-layout]").forEach((btn) => { const on = btn.dataset.layout === name; if (on) btn.setAttribute("aria-current", "page"); else btn.removeAttribute("aria-current"); }); const root = $("page-root"); const impl = window.HUB_LAYOUTS[name]; if (!impl) { root.innerHTML = `
布局未加载:${esc(name)}
`; return; } impl.mount(root); } function boot() { document.querySelectorAll("#layoutSwitch button[data-layout]").forEach((btn) => { btn.addEventListener("click", () => navigate(btn.dataset.layout)); }); window.addEventListener("popstate", () => mount(parse())); mount(parse()); } return { boot, navigate, current: parse, urlFor }; })(); return { $, esc, timeShort, clamp, table, state, api, Bus, Poller, Router, FLOW_PROVIDERS, FLOW_LABEL, FLOW_ROLE, EOD_LABELS, REV_LABELS, PHASE_LABELS, todayYmd, chipClass, chipHtml, linkState, linkLampClass, applyTheme, currentTheme, toggleTheme, bootTheme, applyReduced, REDUCE_MQ, dangerous, probeSource, runJob, pollOverview, pollSources, pollJobs, pollBatches, pollDatasets, pollAudit, }; })();