Files
xiaobai-review/xiaobai-datahub/admin/app.js
T
总工andmultica-agent fa82b8621e feat(HEL-566): 去掉主站行情管理入口并修好模型列表收起
主站行情管理并入数据中枢
- 数据中枢数据源配置页新增「主站行情任务」卡:交易时段后台刷新开关、
  手动后台刷新、历史区间回补(任务提交 + 轮询主站 job 记录亮灯),
  数据与调度仍归主站,控制台只经桥接代操作
- 新增桥接端点 /api/hub-admin/market/{refresh,backfill};回补改为
  market.backfill 后台任务(jobs.config.json 注册,锁独立,超时 30 分钟),
  桥接调用秒回,不再阻塞
- 主站桌面端删除顶栏「行情管理」按钮与整个行情管理对话框,清理随之
  失效的 CSS;移动端删除 system/admin 页与入口,管理员专区只留数据中枢
- Tushare Token / iFinD 凭证编辑沿用数据源页既有凭证区,无功能缺失

模型池收起修复
- 拉出模型清单后按钮切换为「收起列表」,收起只留一行摘要;再次点击
  重新拉取并展开;勾选添加完成后清单自动收起(原逻辑保留)
- 交互全部沿用 HEL-558 已确认样式的既有按钮与提示组件,未新增视觉

自测
- verify_baseline 通过;pytest 492 项通过;数据中枢 240 项通过
- verify_datahub_console 新增 [11b] 行情任务桥接端到端段;UI 自测新增
  行情任务卡开关往返、模型清单展开/收起/再展开/添加自动收起,日夜主题
  与 1030 窄屏复验通过
- Playwright e2e 102/103:唯一失败项在基线提交上同样失败(本机字体度量
  导致的头部溢出,与本卡无关)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 17:24:05 +08:00

2217 lines
124 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';
/* =====================================================================
小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原
视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.htmlv12 血缘压缩增补),
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
/admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路
/admin/api/{providers/status,source-catalog,lineage,lineage/affected}。
动效遵循 SPEC-V11marquee 46s 线性 / flowline 1.4s / radar 4.2s /
blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink /
延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。
===================================================================== */
/* ================= 基础:DOM helper / API / CSRF ================= */
function $(id) { return document.getElementById(id); }
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
const state = {
csrf: '', page: 'overview', calm: false, username: '',
reviewUrl: '', theme: 'night',
// 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存
models: null, members: null, invites: null,
vendorDraft: {}, freshCodes: {}, modelTests: {},
// HEL-566: 主站行情任务(后台刷新开关 / 手动刷新 / 历史回补)同样经桥接
market: { status: null, toggleBusy: false, jobs: { refresh: null, backfill: 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().catch(() => ({}));
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
const error = new Error(msg);
error.status = res.status;
error.detail = body;
throw error;
}
return body;
}
/* ================= 认证:主站会话门禁(数据中枢无独立账号) =================
浏览器在同一主机不同端口间共享主站的 xiaobai_session cookie,本页只把它
交给 /admin/api/session 由主站校验:未登录 401 → 引导去主站登录页,
非管理员 403 → 明确拒绝,管理员才渲染控制台外壳。 */
function showView(id) {
$('gate-view').hidden = id !== 'gate';
$('appRoot').hidden = id !== 'shell';
}
function showGate(title, desc, links) {
$('gate-title').textContent = title;
$('gate-desc').textContent = desc;
const login = $('gate-login');
const site = $('gate-site');
login.hidden = !links.loginUrl;
if (links.loginUrl) login.href = links.loginUrl;
site.hidden = !links.reviewUrl;
if (links.reviewUrl) site.href = links.reviewUrl;
showView('gate');
}
async function boot() {
applyTheme(localStorage.getItem('datahub-theme') === 'day' ? 'day' : 'night');
try {
const session = await api('/admin/api/session');
state.csrf = session.csrf;
state.username = session.username;
state.reviewUrl = session.review_url || '';
$('who').textContent = session.username;
showView('shell');
await startApp();
} catch (err) {
const detail = err.detail || {};
if (err.status === 403) {
showGate('仅管理员可进入', '数据中枢只对管理员开放。当前登录账号「' + (detail.username || '未知') + '」没有管理员权限,请用管理员账号登录主站后再进入。', { reviewUrl: state.reviewUrl || '/' });
return;
}
const loginUrl = detail.login_url || '';
if (loginUrl) state.reviewUrl = loginUrl.replace(/\/login\/?$/, '');
showGate('请先登录小白复盘', '数据中枢使用主站账号,未检测到登录状态。到主站用管理员账号登录后回到本页即可。', { loginUrl: loginUrl });
}
}
$('gate-retry').addEventListener('click', () => { boot(); });
$('logout-btn').addEventListener('click', async () => {
let loginUrl = '';
try {
const result = await api('/admin/api/logout', { method: 'POST', body: '{}' });
loginUrl = result.login_url || '';
} catch {}
Poller.stop();
showGate('已退出登录', '主站会话已结束。重新登录后可再次进入数据中枢。', { loginUrl: loginUrl });
});
/* ---------- 日间 / 夜间主题(同一组 token 换值,不叠加覆盖样式) ---------- */
function applyTheme(theme) {
state.theme = theme === 'day' ? 'day' : 'night';
document.documentElement.dataset.theme = state.theme;
localStorage.setItem('datahub-theme', state.theme);
const btn = $('themeBtn');
if (btn) btn.textContent = state.theme === 'day' ? '夜间' : '日间';
}
$('themeBtn').addEventListener('click', () => applyTheme(state.theme === 'day' ? 'night' : 'day'));
/* ================= 状态归类(真实状态字符串 → 7 类视觉桶) =================
桶: ok / slow / fail / plan / review / degrade / off
来源覆盖:observability.classify_error/classify_rows、CircuitBreaker、
eod/revision progress、batches/publications、job_runs 的全部真实取值。 */
const HEALTH = {
ok: { label: '正常', led: 'ok', color: 'var(--mint)' },
slow: { label: '慢', led: 'slow', color: 'var(--amb)' },
fail: { label: '失败', led: 'fail', color: 'var(--rd)' },
plan: { label: '计划', led: 'plan', color: 'var(--mut)' },
review: { label: '复核中', led: 'rev', color: 'var(--cy)' },
degrade: { label: '降级', led: 'slow', color: 'var(--amb)' },
off: { label: '未配置', led: 'off', color: 'var(--dim)' },
};
const STATE_MAP = {
ok: 'ok', closed: 'ok', published: 'ok', done: 'ok', aligned: 'ok',
half_open: 'slow', waiting_upstream: 'slow',
open: 'fail', timeout: 'fail', blocked: 'fail', empty: 'fail', missing_fields: 'fail',
error: 'fail', failed: 'fail', cutoff_failed: 'fail', cutoff: 'fail', review_failed: 'fail',
degraded: 'degrade', stale: 'degrade',
pending_first_attempt: 'plan', pending_publish: 'plan',
running: 'review', staged: 'review', waiting_review: 'review',
unconfigured: 'off', reserved: 'off', unknown: 'off', closed_day: 'off',
};
function bucketOf(rawState) { return STATE_MAP[String(rawState || '').toLowerCase()] || 'off'; }
const SEV = { fail: 6, degrade: 5, slow: 4, review: 3, plan: 2, off: 1, ok: 0 };
function worstBucket(list) {
let best = 'ok', bestSev = -1;
for (const b of list) { const s = SEV[b] ?? 0; if (s > bestSev) { bestSev = s; best = b; } }
return list.length ? best : 'ok';
}
/* ================= 渲染小工具(结构照抄 hub-kimi.html ================= */
function ledHtml(st, pulse) { return `<span class="led ${st}${pulse ? ' pulse' : ''}"></span>`; }
function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `<span class="spill" style="color:${h.color}">${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}</span>`; }
function tagHtml(tone, inner) { return `<span class="tag ${tone}">${inner}</span>`; }
function panelHtml(title, right, body, pad) {
return `<section class="panel"><header class="panel-hd"><span><span style="color:var(--cy);margin-right:6px">//</span><b>${title}</b></span>${right ? `<span style="display:flex;align-items:center;gap:8px">${right}</span>` : ''}</header><div class="${pad === false ? '' : 'panel-b'}">${body}</div></section>`;
}
function kpiHtml(label, value, sub, tone, live) {
const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt'];
return `<div class="kpi"><span class="lab">${label}</span><span class="num kv ${tc}">${value}${live ? '<span class="blink" style="color:var(--cy);margin-left:2px">_</span>' : ''}</span>${sub ? `<span class="ks">${sub}</span>` : ''}</div>`;
}
/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字,
仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */
function latHtml(ms, warn) {
if (ms == null) return '<span style="color:var(--dim)">—</span>';
const color = ms > 1000 ? 'var(--amb)' : (warn ? 'var(--amb)' : 'var(--txt)');
return `<span class="num jit" data-lat="${Number(ms)}" style="color:${color}">${Number(ms).toLocaleString('en-US')}ms</span>`;
}
/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */
function ageHtml(iso) {
const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T'));
if (!iso || Number.isNaN(ms)) return '<span style="color:var(--dim)">—</span>';
return `<span class="num" data-age-epoch="${ms}" style="color:var(--mut)">—</span>`;
}
function fmtAgeSec(s) {
if (s == null) return '—';
// 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀)
return `${Number(s).toLocaleString('en-US')}s`;
}
function localDate(iso) {
const at = new Date(String(iso || '').replace(' ', 'T'));
return Number.isNaN(at.getTime()) ? null : at;
}
function ymd(iso) {
const at = localDate(iso);
if (!at) return String(iso || '').slice(0, 10);
return `${at.getFullYear()}-${p2(at.getMonth() + 1)}-${p2(at.getDate())}`;
}
function stamp(iso) {
const at = localDate(iso);
if (!at) return String(iso || '');
return `${ymd(iso)} ${p2(at.getHours())}:${p2(at.getMinutes())}`;
}
function hm(iso) {
if (!iso) return '—';
const s = String(iso);
const idx = s.indexOf('T') >= 0 ? s.indexOf('T') : s.indexOf(' ');
if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—';
return s.slice(idx + 1, idx + 6);
}
/* 大白话错误摘要:主表只放一行短句,完整原文放 title 悬停提示。
不猜业务结论,只做确定性文案映射 + 定长截断。 */
function plainError(raw, max = 18) {
const s = String(raw || '');
if (!s) return '';
const rules = [
[/UNIQUE constraint failed/i, '重复数据写入冲突'],
[/circuit open|breaker/i, '熔断保护中'],
[/timeout|timed out|ETIMEDOUT/i, '请求超时'],
[/row ratio/i, '行数校验未达标'],
[/field gate/i, '字段完整率未达标'],
[/empty official batch|no_rows_returned|empty_or_incomplete/i, '上游返回空数据'],
[/release group not switched/i, '发布组未切换'],
[/missing_fields/i, '返回缺字段'],
[/data_age_exceeds/i, '数据过期超阈值'],
[/blocked|captcha|安全验证|访问异常/i, '被上游拦截'],
];
for (const [re, text] of rules) if (re.test(s)) return text;
return s.length > max ? s.slice(0, max) + '…' : s;
}
/* 单行截断单元格:class ellipstyles.css 生产补充段),title 放全文 */
function ellipHtml(text, color, fontSize, title) {
return `<span class="ellip" style="color:${color};font-size:${fontSize || ''}" title="${esc(title ?? text)}">${esc(text)}</span>`;
}
const p2 = (x) => String(x).padStart(2, '0');
/* ================= sparkline(真实历史:来自 provider_call_log ================= */
const SPARK_COLORS = { cy: 'var(--cy)', mint: 'var(--mint)', amb: 'var(--amb)', rd: 'var(--rd)' };
function sparkHtml(jid, tone, w, h) {
return `<svg width="${w}" height="${h}" class="spark" data-jid="${esc(jid)}" data-w="${w}" data-h="${h}" style="display:block;opacity:.9"><polyline points="" fill="none" style="stroke:${SPARK_COLORS[tone]}" stroke-width="1.2" stroke-opacity=".8"/><circle class="spark-end" r="2" style="fill:${SPARK_COLORS[tone]}" cx="0" cy="0"/></svg>`;
}
function sparkSeries(jid) {
const hist = LatHist.get(jid) || [];
if (hist.length < 2) return null;
const max = Math.max(...hist, 1);
return hist.map((v) => v / max);
}
function sparkPaint(svg) {
const jid = svg.dataset.jid, w = +svg.dataset.w, h = +svg.dataset.h;
const data = sparkSeries(jid);
const poly = svg.querySelector('polyline');
const dot = svg.querySelector('circle');
if (!data) {
const y = (h / 2).toFixed(2);
poly.setAttribute('points', `0,${y} ${w},${y}`);
poly.setAttribute('stroke-opacity', '.25');
dot.style.display = 'none';
return;
}
poly.setAttribute('stroke-opacity', '.8');
dot.style.display = '';
const pts = data.map((v, i) => `${((i / (data.length - 1)) * w).toFixed(2)},${(h - v * (h - 3) - 1).toFixed(2)}`).join(' ');
poly.setAttribute('points', pts);
dot.setAttribute('cx', (w - 1).toFixed(2));
dot.setAttribute('cy', (h - data[data.length - 1] * (h - 3) - 1).toFixed(2));
}
/* 每个“观测对象”(接口或数据集)的真实延迟历史(旧→新),来自
provider_call_log 最近 200 条。 */
const LatHist = new Map();
const CALL_MATCHERS = {};
function buildLatHist() {
const calls = (RAW.providers && RAW.providers.recent_calls) || [];
const buckets = new Map();
[...calls].reverse().forEach((c) => {
if (c.latency_ms == null) return;
const key = `${c.provider}:${c.interface}`;
(buckets.get(key) || buckets.set(key, []).get(key)).push(c.latency_ms);
});
LatHist.clear();
buckets.forEach((arr, key) => LatHist.set(key, arr.slice(-24)));
// 数据集维度的历史:tushare 观测行写的是数据集名,直接按数据集聚合
const dsBuckets = new Map();
[...calls].reverse().forEach((c) => {
if (c.provider !== 'tushare' || c.latency_ms == null) return;
(dsBuckets.get(c.interface) || dsBuckets.set(c.interface, []).get(c.interface)).push(c.latency_ms);
});
dsBuckets.forEach((arr, ds) => LatHist.set(`ds:${ds}`, arr.slice(-24)));
}
/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */
const RAW = {
overview: null, sources: null, providers: null, catalog: null,
lineage: null, jobs: null, batches: null, audit: null,
};
async function fetchAll() {
const [overview, sources, providers, catalog, lineage, jobs, batches, audit] = await Promise.all([
api('/admin/api/overview'),
api('/admin/api/sources'),
api('/admin/api/providers/status?limit=200'),
api('/admin/api/source-catalog'),
api('/admin/api/lineage'),
api('/admin/api/jobs'),
api(`/admin/api/batches?date=${today()}`),
api('/admin/api/audit'),
]);
RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog;
RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.audit = audit;
buildLatHist();
}
function today() { return RAW.overview ? RAW.overview.trade_date : ''; }
const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks'];
const DATASET_NAME = {
daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K',
limit_events: '涨停池', popularity: '人气榜', dragon_tiger: '龙虎榜', sector_daily: '题材板块', stocks: '股票主档',
};
/* =====================================================================
总览页(结构照抄 hub-kimi.html,数据全部真实)
===================================================================== */
function buildOverviewVM() {
const ov = RAW.overview || {};
const eod = ov.eod_status || {};
const rev = ov.revision_status || {};
const pubsByDs = {};
(ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; });
const batchByDs = {};
((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; });
const totalOfficial = OFFICIAL_DATASET_IDS.length;
const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length;
const anomalyCount = (ov.anomalies || []).length;
const recentCalls = ov.recent_calls || [];
const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null;
// 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集
const fallbackPages = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
if (it.tier !== 'provisional' || !it.backup_source) return;
const rows = it.live_provider_health || [];
const primaryProv = (it.providers || [])[0];
const backupProv = (it.providers || [])[1];
const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state)));
const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state)));
if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') {
(it.known_consumers || []).forEach((c) => fallbackPages.push(c));
}
});
const datasetCards = OFFICIAL_DATASET_IDS.map((id) => {
const pub = pubsByDs[id];
const batch = batchByDs[id];
let bucket = 'plan', note = '';
if (pub) {
bucket = pub.state === 'published' ? 'ok' : (pub.state === 'degraded' ? 'degrade' : 'fail');
note = pub.published_at ? hm(pub.published_at) : '';
} else if (batch) {
bucket = batch.state === 'failed' ? 'fail' : (batch.state === 'staged' ? 'review' : 'plan');
note = batch.error ? esc(batch.error) : '';
}
const rows = (batch && batch.rows_out) || (pub && pub.rows_out) || null;
return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' };
});
// 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true
const catalogByProv = {};
(RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; });
const observers = [];
['eastmoney', 'tencent'].forEach((prov) => {
const cat = catalogByProv[prov];
if (!cat) return;
cat.interfaces.forEach((iface) => {
if (!iface.observed) return;
observers.push({
jid: `${prov}:${iface.interface}`,
name: iface.capability, provider: prov, interface: iface.interface,
lastOk: iface.observed_at, lat: iface.observed_latency_ms,
bucket: bucketOf(iface.observed_state),
note: iface.observed_note || '',
});
});
});
// 主站影响:lineage() 条目按 known_consumers 展开(接口级状态)
const siteImpact = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
const bucket = lineageItemBucket(it);
(it.known_consumers || []).forEach((c) => {
siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) });
});
});
const okPages = siteImpact.filter((s) => s.bucket === 'ok').length;
// 今晚时间线:真实 jobs() 定义 + job_runs
const jobDefs = (RAW.jobs && RAW.jobs.jobs || []).filter((j) => j.at !== 'manual');
const runsByJob = {};
((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); });
const timeline = jobDefs.map((j) => {
const runs = (runsByJob[j.id] || []).slice().sort((a, b) => (a.id < b.id ? 1 : -1));
const latest = runs[0];
let st = 'wait';
if (latest) st = latest.state === 'ok' ? 'done' : (latest.state === 'failed' ? 'fail' : 'wait');
return { t: (j.at.split('-')[0] || j.at).slice(0, 5), label: j.title, state: st, jobId: j.id };
});
// 待处理异常:overview.anomalies 已在后端收敛为“仍未恢复的最新异常”
const incidents = [];
(ov.anomalies || []).forEach((b) => {
incidents.push({ sev: 'fail', tag: b.state, title: `${DATASET_NAME[b.dataset] || b.dataset} ${b.state === 'failed' ? '失败' : '停滞'}`, meta: `${b.error ? esc(b.error) : '无错误详情'} · 批次 ${b.batch_id}`, dataset: b.dataset });
});
if (eod.state === 'waiting_upstream' || eod.state === 'cutoff_failed') {
incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '自动重试窗口', meta: `15:1523:30 · 每 30 分钟 · 成功即停 · 已试 ${eod.attempts || 0}${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '', cd: eod.next_retry_at });
}
const hidx = {};
((RAW.providers && RAW.providers.health) || []).forEach((r) => { (hidx[r.provider] = hidx[r.provider] || []).push(r); });
Object.keys(hidx).forEach((prov) => {
const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail');
if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov });
});
const ifindCat = catalogByProv.ifind;
if (ifindCat && !(ifindCat.credential && ifindCat.credential.configured)) {
incidents.push({ sev: 'off', tag: '未配置', title: 'iFinD · 授权源未启用', meta: '凭证未配置 · 功能隐藏 · 配置后启用', provider: 'ifind' });
}
return {
trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev,
publishedCount, totalOfficial, anomalyCount, successRate,
fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents,
unpublished: OFFICIAL_DATASET_IDS.filter((id) => !pubsByDs[id] || pubsByDs[id].state !== 'published'),
};
}
function heroHtml(vm) {
const anomalyTxt = vm.anomalyCount ? `<span class="glow-rd">${vm.anomalyCount} 项异常</span>` : `<span style="color:var(--mint)">无异常</span>`;
const tags = [];
if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `<span class="minidot pulse-dot" style="background:var(--amb)"></span>等待上游 · 已试 ${vm.eod.attempts || 0} 次`));
if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布'));
const subParts = [];
if (vm.eod.attempts) subParts.push(`已试 <span style="color:var(--amb)">${vm.eod.attempts}</span> 次`);
if (vm.eod.next_retry_at) subParts.push(`下次重试 <span class="num" data-cd-epoch="${Date.parse(String(vm.eod.next_retry_at).replace(' ', 'T'))}" data-cdfmt="mmss" style="color:var(--amb)">--:--</span>`);
if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 <span style="color:var(--rd)">${esc(vm.eod.missing_datasets.join(','))}</span>`);
if (vm.rev.window) subParts.push(`复核窗口 <span style="color:var(--cy)">${esc(vm.rev.window)}</span>`);
return `<div class="panel hero">` +
`<div class="hero-l">` +
`<div class="hero-title"><span style="color:var(--txt-strong)">${vm.anomalyCount ? '部分异常' : '整体可用'}</span><span class="dot">·</span>${anomalyTxt}</div>` +
`<div class="hero-tags">${tags.join('')}</div>` +
(subParts.length ? `<div class="hero-sub">${subParts.join('<span class="sep">|</span>')}</div>` : '') +
`</div><span class="flex1"></span>` +
`<div class="kpis">` +
kpiHtml('已发布数据集', `<span class="glow-mint">${vm.publishedCount}</span><span style="color:var(--dim);font-size:18px">/${vm.totalOfficial}</span>`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') +
kpiHtml('今日调用成功率', vm.successRate == null ? '<span style="color:var(--dim)">—</span>' : `<span data-rate>${vm.successRate.toFixed(1)}%</span>`, null, 'mint', vm.successRate != null) +
kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') +
kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') +
`</div></div>`;
}
function datasetStripHtml(vm) {
return `<div class="ds-grid">${vm.datasetCards.map((d) => {
const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : '';
const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint';
let note = '';
if (d.time) note += `<span class="num" style="color:var(--mut)">${esc(d.time)} · </span>`;
if (d.rows) note += `<span class="num">${Number(d.rows).toLocaleString('en-US')} 行</span>`;
if (d.err) note += `<span class="ellip" style="color:var(--rd)" title="${esc(d.err)}">${esc(plainError(d.err, 16))}</span>`;
if (!d.time && !d.rows && !d.err) note = '<span style="color:var(--dim)">今日尚未发布</span>';
return `<div class="panel dsc ${ring}" data-dataset="${d.id}">` +
`<div class="dsc-top"><span class="dsc-name">${esc(d.name)}</span>${pillHtml(d.bucket)}</div>` +
`<div class="dsc-note">${note}</div>` +
sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) +
`</div>`;
}).join('')}</div>`;
}
function observatoryHtml(vm) {
const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' };
const right = `<span class="legend">${Object.keys(legendByProv).map((p) => `<span>${ledHtml('ok')}${legendByProv[p]}</span>`).join('')}</span>`;
const rows = vm.observers.length ? vm.observers.map((o) => `<tr>` +
`<td style="color:var(--txt-strong)">${esc(o.name)}</td>` +
`<td style="color:var(--mut);white-space:nowrap">${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}</td>` +
`<td>${ageHtml(o.lastOk)}</td>` +
`<td>${latHtml(o.lat, o.bucket !== 'ok')}</td>` +
`<td>${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}</td>` +
`<td style="max-width:260px"><span style="display:flex;align-items:center;gap:8px;min-width:0">${pillHtml(o.bucket)}${o.note ? ellipHtml(plainError(o.note, 20), '', '10px', o.note) : ''}</span></td>` +
`</tr>`).join('') : `<tr><td colspan="6" class="empty-hint">尚无真实观测记录(等待首次探测/调用)</td></tr>`;
const body = `<table class="dtable"><thead><tr><th>观察项</th><th>当前来源</th><th>缓存年龄</th><th>延迟</th><th>趋势</th><th>状态 · 备注</th></tr></thead><tbody>${rows}</tbody></table>`;
return panelHtml('实时观察层', right, body, false);
}
function siteImpactHtml(vm) {
const bad = vm.siteImpact.filter((s) => s.bucket !== 'ok');
const right = `<span style="font-size:10px"><span style="color:var(--mint)">${vm.okPages} 项正常</span><span style="color:var(--dim)"> · </span><span style="color:var(--amb)">${vm.siteImpact.length - vm.okPages} 项待关注</span></span>`;
const rows = bad.length ? bad.slice(0, 5).map((s) => `<tr>` +
`<td style="color:var(--txt-strong);width:40%">${esc(s.page)}</td>` +
`<td style="width:20%">${pillHtml(s.bucket)}</td>` +
`<td style="font-size:11px;color:var(--dim);max-width:220px">${s.note ? ellipHtml(plainError(s.note, 22), 'var(--dim)', '11px', s.note) : '—'}</td>` +
`<td style="text-align:right"><button class="tbtn mini" data-goto-lineage="${esc(s.dataset)}">查看血缘</button></td>` +
`</tr>`).join('') : `<tr><td style="color:var(--txt-strong);width:40%">全部页面正常</td><td>${pillHtml('ok')}</td><td style="font-size:11px;color:var(--dim)">—</td><td></td></tr>`;
const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket }));
const byPage = new Map();
footPages.forEach((p) => { const cur = byPage.get(p.name); if (!cur || SEV[p.bucket] > SEV[cur]) byPage.set(p.name, p.bucket); });
const foot = `<div class="site-foot"><span style="color:var(--mint);margin-right:4px">正常 ${vm.okPages}</span>` +
[...byPage.entries()].map(([name, bucket]) => bucket === 'ok'
? `<span style="display:inline-flex;align-items:center;gap:6px"><span>${esc(name)}</span><span style="color:var(--line2)">·</span></span>`
: `<span style="display:inline-flex;align-items:center;gap:6px;color:${HEALTH[bucket].color}">${esc(name)}(${HEALTH[bucket].label})</span><span style="color:var(--line2)">·</span></span>`).join('') +
`</div>`;
return panelHtml('主站影响', right, `<table class="dtable"><tbody>${rows}</tbody></table>${foot}`, false);
}
function timelineHtml(vm) {
const nodes = vm.timeline;
if (!nodes.length) return panelHtml('今晚时间线', '', '<div class="empty-hint">暂无可展示的调度任务</div>', true);
const W = 560, H = 150, x0 = 34, x1 = W - 30, y = 75;
const step = (x1 - x0) / Math.max(1, nodes.length - 1);
const X = (i) => x0 + i * step;
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + (m || 0); };
let nowX = X(0);
for (let i = 0; i < nodes.length - 1; i++) {
const a = toMin(nodes[i].t), b = toMin(nodes[i + 1].t);
if (nowMinutes >= a && nowMinutes <= b && b > a) { nowX = X(i) + (X(i + 1) - X(i)) * ((nowMinutes - a) / (b - a)); break; }
if (nowMinutes < toMin(nodes[0].t)) { nowX = X(0); break; }
if (nowMinutes > toMin(nodes[nodes.length - 1].t)) { nowX = X(nodes.length - 1); }
}
let s = `<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">` +
`<defs><linearGradient id="tlprog" x1="0" y1="0" x2="1" y2="0"><stop offset="0" style="stop-color:var(--mint)"/><stop offset="1" style="stop-color:var(--cy)"/></linearGradient></defs>` +
`<line x1="${x0 - 8}" y1="${y}" x2="${x1 + 8}" y2="${y}" style="stroke:var(--line2)" stroke-width="1"/>` +
`<line x1="${x0 - 8}" y1="${y}" x2="${nowX}" y2="${y}" stroke="url(#tlprog)" stroke-width="1.6" class="flowline"/>`;
nodes.forEach((n, i) => {
const nx = X(i), up = i % 2 === 0;
const ty = up ? y - 26 : y + 34, ly = up ? y - 12 : y + 20;
const color = n.state === 'done' ? 'var(--mint)' : n.state === 'fail' ? 'var(--rd)' : 'var(--dim)';
const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle';
s += `<g><line x1="${nx}" y1="${up ? y - 6 : y + 6}" x2="${nx}" y2="${up ? ly - 3 : ly + 3}" style="stroke:var(--line2)" stroke-width="1"/>`;
if (n.state === 'fail') s += `<circle cx="${nx}" cy="${y}" r="6" style="fill:rgba(248,113,113,.15);stroke:var(--rd)" stroke-width="1.2"/><text x="${nx}" y="${y + 3.2}" text-anchor="middle" font-size="9" style="fill:var(--rd)">×</text>`;
else s += `<circle cx="${nx}" cy="${y}" r="${n.state === 'done' ? 4.2 : 3.4}" style="fill:${n.state === 'done' ? color : 'var(--panel)'};stroke:${color}" stroke-width="1.2"/>`;
s += `<text x="${nx}" y="${ly + (up ? -1 : 1)}" text-anchor="${anchor}" font-size="9.5" style="fill:${n.state === 'fail' ? 'var(--rd)' : 'var(--txt-soft)'}" font-weight="600" class="num">${esc(n.t)}</text>` +
`<text x="${nx}" y="${ty + (up ? -1 : 1)}" text-anchor="${anchor}" font-size="9" style="fill:${n.state === 'fail' ? 'rgba(248,113,113,.75)' : 'var(--dim)'}">${esc(n.label)}</text></g>`;
});
s += `<g><line x1="${nowX}" y1="${y - 44}" x2="${nowX}" y2="${y + 44}" style="stroke:var(--cy)" stroke-width="1" stroke-dasharray="2 3" opacity=".7"/>` +
`<circle cx="${nowX}" cy="${y}" r="3.6" style="fill:var(--cy)" class="now-dot now-r"/>` +
`<text x="${nowX}" y="${y - 50}" text-anchor="middle" font-size="9" style="fill:var(--cy)" font-weight="600" class="num">NOW</text></g></svg>`;
const range = nodes.length ? `${nodes[0].t}${nodes[nodes.length - 1].t}` : '';
const right = `<span style="font-size:10px;color:var(--dim)">现在 <span class="num" style="color:var(--cy)">${p2(now.getHours())}:${p2(now.getMinutes())}</span> · ${range} · <span style="color:var(--mut)">SCHEDULER.PY</span></span>`;
return panelHtml('今晚时间线', right, s, false);
}
function incidentsHtml(vm) {
if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '<div class="empty-hint">当前无待处理异常</div>', true);
const cards = vm.incidents.map((it) => {
const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag));
const meta = it.cd
? `${it.meta}<span class="num" style="color:var(--amb)"> · 下次重试 <span data-cd-epoch="${Date.parse(String(it.cd).replace(' ', 'T'))}" data-cdfmt="ms">--m--s</span></span>`
: it.meta;
return `<div class="inc ${it.sev}">${tag}` +
`<div style="flex:1;min-width:0">` +
`<div class="inc-title"${it.sev === 'off' ? ' style="color:var(--mut)"' : ''}>${esc(it.title)}</div>` +
`<div class="inc-meta ellip" title="${esc(String(it.meta).replace(/<[^>]+>/g, ''))}">${meta}</div>` +
`</div>` +
`<button class="tbtn" style="flex:none" data-incident-action data-dataset="${esc(it.dataset || '')}" data-provider="${esc(it.provider || '')}">去处理 →</button>` +
`</div>`;
}).join('');
return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `<div class="col" style="gap:10px">${cards}</div>`, true);
}
function overviewHtml() {
const vm = buildOverviewVM();
return `<div class="col">${heroHtml(vm)}${datasetStripHtml(vm)}` +
`<div class="ov-grid"><div class="ov-l col">${observatoryHtml(vm)}${siteImpactHtml(vm)}</div>` +
`<div class="ov-r col">${timelineHtml(vm)}${incidentsHtml(vm)}</div></div></div>`;
}
/* =====================================================================
数据源配置页(分组接口表照抄打样 mx-grid 结构,数据全部真实)
===================================================================== */
const srcUiState = {};
const PROV_ROLE_TEXT = {
tushare: 'official · 盘后正式数据唯一源',
eastmoney: 'free · 盘中观察主源',
tencent: 'free · 盘中观察备源',
ifind: 'licensed · 图表 / 问财 / 实时快照',
};
function buildSourcesVM() {
const catalog = (RAW.catalog && RAW.catalog.items) || [];
const legacy = {};
(RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; });
const cidx = {};
((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (!cidx[c.provider]) cidx[c.provider] = c; });
const active = catalog.filter((c) => c.role !== 'reserved');
const reserved = catalog.filter((c) => c.role === 'reserved');
const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0);
// “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注)
const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0);
const cards = active.map((c) => {
const cred = c.credential || {};
const observedRows = c.interfaces.filter((i) => i.observed);
const worst = observedRows.length ? worstBucket(observedRows.map((i) => bucketOf(i.observed_state))) : null;
const configured = cred.configured;
let bucket;
if (worst) bucket = worst;
else if (configured) bucket = 'plan';
else bucket = 'off';
const lastCall = cidx[c.provider];
if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null };
const groups = [];
const groupMap = new Map();
c.interfaces.forEach((i) => {
const g = i.group || '其他';
if (!groupMap.has(g)) groupMap.set(g, []);
groupMap.get(g).push(i);
});
groupMap.forEach((items, name) => groups.push({ name, items }));
const ifaceRows = (group) => group.items.map((i) => ({
api: i.interface, use: i.capability,
ok: i.observed ? (i.observed_at ? hm(i.observed_at) : '—') : '—',
lat: i.observed ? i.observed_latency_ms : null,
st: i.observed ? bucketOf(i.observed_state) : (configured ? 'plan' : 'off'),
obs: i.observed, basis: i.observed_basis,
err: i.observed && i.observed_state !== 'ok' ? (i.observed_note || '') : '',
}));
return {
id: c.provider, name: c.label, roleText: PROV_ROLE_TEXT[c.provider] || c.role,
tag: c.credential_key ? 'licensed' : 'free',
bucket, credential: cred,
usedCount: c.interfaces.filter((i) => i.observed).length, totalCount: c.interfaces.length,
lastProbe: lastCall ? `${hm(lastCall.fetched_at)} ${lastCall.status === 'ok' ? `✓ ${lastCall.latency_ms ?? ''}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—',
latency: observedRows.length ? (observedRows.map((r) => r.observed_latency_ms).filter((v) => v != null)[0] || null) : null,
error: observedRows.find((r) => r.observed_state !== 'ok' && r.observed_note)?.observed_note || '',
groups: groups.map((g) => ({ name: g.name, rows: ifaceRows(g) })),
};
});
return { cards, reserved, totalIfaces, usedIfaces };
}
function sourceCardHtml(card) {
const ui = srcUiState[card.id];
const ringStyle = card.bucket === 'fail' ? 'border-color:rgba(248,113,113,.35)' :
card.bucket === 'degrade' ? 'border-color:rgba(251,191,36,.25)' :
card.bucket === 'slow' ? 'border-color:rgba(251,191,36,.3)' :
card.bucket === 'off' ? 'border-style:dashed;border-color:var(--line2)' : '';
const ledSt = HEALTH[card.bucket].led;
// source_catalog.pycredential_key 为空的来源(eastmoney/tencent 等公开
// 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true
// 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。
const credBadge = card.credential.note ? card.credential.note :
card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置';
const badges = [credBadge];
if (card.credential.configured && card.credential.updated_at) badges.push(`${String(card.credential.updated_at).slice(5, 10).replace('-', '/')} 更新`);
badges.push(card.id === 'tushare' ? '主(无备)' : (card.id === 'ifind' ? '图表主源(未启用)' : (card.id === 'tencent' ? '实时层备' : '实时层主')));
let h = `<section class="panel" style="${ringStyle}">`;
h += `<header class="src-head">` +
ledHtml(ledSt, card.bucket === 'fail') +
`<span class="src-name">${esc(card.name)}</span>` +
`<span class="src-role">${esc(card.roleText)}</span>` +
pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') +
`<span style="display:flex;gap:6px">${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}</span>` +
`<span class="flex1"></span>` +
`<button class="tbtn" data-probe="${card.id}"${ui.probing || card.bucket === 'off' ? ' disabled' : ''}>${ui.probing ? '<span class="animate-spin" style="margin-right:4px">◌</span>探测中…' : '探测一次'}</button>` +
(card.bucket === 'off'
? `<span class="tag dashed">下方填入凭证即可启用</span>`
: `<button class="tbtn" data-toggle="${card.id}">${ui.open ? '收起接口清单' : '展开接口清单'}</button>`) +
`</header>`;
const probeCell = ui.probeResult
? `<span class="num" style="color:${ui.probeResult.ok ? 'var(--mint)' : 'var(--rd)'}">${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}</span>`
: `<span class="num"${card.lastProbe.includes('✓') ? ' style="color:var(--mint)"' : card.lastProbe.includes('×') ? ' style="color:var(--rd)"' : ''}>${esc(card.lastProbe)}</span>`;
h += `<div class="src-meta">` +
`<span>接口 <span class="num" style="color:${card.bucket === 'off' ? 'var(--dim)' : 'var(--cy)'}">${card.usedCount}/${card.totalCount}</span> 在用</span>` +
`<span class="msep">|</span><span>最后探测 ${probeCell}</span>` +
`<span class="msep">|</span><span>延迟 ${latHtml(card.latency)}</span>` +
(card.error ? `<span class="msep">|</span><span style="color:var(--rd);display:inline-flex;align-items:center;gap:4px;min-width:0">最近错误 ${ellipHtml(plainError(card.error, 26), 'var(--rd)', '11px', card.error)}</span>` : (card.bucket !== 'off' ? `<span class="msep">|</span><span style="color:var(--dim)">无最近错误</span>` : '')) +
`</div>`;
h += credBoxHtml(card);
if (ui.open && card.bucket !== 'off') {
h += `<div class="mx-grid">` + card.groups.map((g) => `<div style="min-width:0">` +
`<div class="lab" style="margin-bottom:6px">${esc(g.name)}</div>` +
`<table class="dtable"><thead><tr><th>接口</th><th>用途</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody>` +
g.rows.map((it) => `<tr>` +
`<td style="color:var(--cy);font-size:11px;word-break:break-all;min-width:90px">${esc(it.api)}</td>` +
`<td style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(it.use)}</td>` +
`<td class="num" style="font-size:11px;color:${it.ok.includes('×') || it.ok === '昨日' ? 'var(--rd)' : 'var(--mut)'}">${esc(it.ok)}</td>` +
`<td style="font-size:11px">${latHtml(it.lat)}</td>` +
`<td style="white-space:nowrap"><span style="display:flex;align-items:center;gap:6px">${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? ellipHtml(plainError(it.err, 16), 'var(--rd)', '10px', it.err) : ''}</span></td>` +
`</tr>`).join('') +
`</tbody></table></div>`).join('') + `</div>`;
}
if (card.bucket === 'off') {
h += `<div style="padding:16px"><div class="offbox">凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` +
`<div style="margin-top:8px;display:flex;justify-content:center;gap:6px;flex-wrap:wrap">${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}</div>` +
`</div></div>`;
}
return h + `</section>`;
}
/* ---------- 凭证可编辑区(样图 v2「新增可编辑区」,原卡内容一项不删) ----------
公开源(东财/腾讯)无凭证可填,保持"无需凭证"徽标即可;Tushare 与 iFinD
的 Token 存在数据中枢的加密库里,这里只回显掩码,留空表示保持不变。 */
const CRED_FORMS = {
tushare: {
field: 'tushare_token',
label: 'API TOKEN',
placeholder: '粘贴 Tushare token',
save: '保存凭证',
test: true,
hint: '掩码显示 · 粘贴新值即覆盖 · 留空保持不变',
note: '',
},
ifind: {
field: 'ifind_refresh_token',
label: 'REFRESH TOKEN',
placeholder: '粘贴 iFinD refresh_token',
save: '保存并启用',
test: false,
hint: '凭据服务端加密保存 · 任何界面不回显明文',
note: '保存并通过最近一次探测后自动纳入数据血缘',
},
};
function credBoxHtml(card) {
const form = CRED_FORMS[card.id];
if (!form) return '';
const saved = card.credential.configured;
const probe = card.credential.updated_at ? `上次保存 ${esc(stamp(card.credential.updated_at))}` : '';
return `<div class="cred-box" data-cred-form="${card.id}">` +
`<span class="lab">${esc(form.label)} <span class="cred-flag">· 新增可编辑区</span></span>` +
`<div class="cred-line">` +
`<input type="password" autocomplete="off" data-cred-input="${form.field}"` +
` placeholder="${saved ? '••••••••••••••••••••' : esc(form.placeholder)}">` +
`<span class="cred-acts">` +
(form.test ? `<button class="tbtn" data-cred-test="${card.id}"${card.bucket === 'off' ? ' disabled' : ''}>测试连接</button>` : '') +
`<button class="pbtn" data-cred-save="${card.id}">${esc(form.save)}</button></span>` +
`</div>` +
`<div class="cred-feet"><span class="form-hint">${esc(form.hint)}</span>` +
`<span class="form-hint">${esc(form.note) || probe}</span></div>` +
`</div>`;
}
async function saveCredential(provider) {
const form = CRED_FORMS[provider];
const box = document.querySelector(`[data-cred-form="${provider}"]`);
if (!form || !box) return;
const input = box.querySelector(`[data-cred-input="${form.field}"]`);
const value = input ? input.value.trim() : '';
if (!value) { toast('请先粘贴新的凭证,留空表示保持不变', 'err'); return; }
const button = box.querySelector('[data-cred-save]');
if (button) button.disabled = true;
try {
await api(`/admin/api/credentials/${provider}`, { method: 'POST', body: JSON.stringify({ [form.field]: value }) });
toast('凭证已保存,已对下一次取数生效', 'ok');
await refreshQuiet();
renderSourceCards();
} catch (err) {
toast(err.message, 'err');
if (button) button.disabled = false;
}
}
function sourcesHtml() {
const vm = buildSourcesVM();
const alerts = [];
vm.cards.forEach((c) => {
if (c.bucket === 'fail') alerts.push(`<span style="display:inline-flex;align-items:center;gap:6px;font-size:11px">${ledHtml('fail', true)}<span style="color:var(--rd)">${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}</span></span>`);
else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`<span style="display:inline-flex;align-items:center;gap:6px;font-size:11px">${ledHtml('slow')}<span style="color:var(--amb)">${esc(c.name)} 降级/延迟</span></span>`);
});
return `<div class="col">` +
`<div class="panel strip">` +
`<span style="font-size:13px;color:var(--txt-strong)">数据源 <span class="num" style="color:var(--cy)">${vm.cards.length}</span> 路 · 接口 <span class="num" style="color:var(--cy)">${vm.usedIfaces}/${vm.totalIfaces}</span> 在用</span>` +
alerts.join('') +
`<span class="flex1"></span>` +
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">凭证安全存储 · 探测只读 · 不影响盘中链路</span>` +
`</div>` +
`<div id="marketCard" class="col">${marketCardHtml()}</div>` +
`<div id="srcCards" class="col">${vm.cards.map(sourceCardHtml).join('')}</div>` +
`<div class="rsv-grid">${vm.reserved.map((r) => `<div class="rsv">${ledHtml('off')}<span style="font-size:13px">${esc(r.label)}</span>${tagHtml('dashed', '预留位 · 未接入')}</div>`).join('')}</div>` +
`</div>`;
}
function renderSourceCards() {
const el = $('srcCards');
if (!el) return;
el.innerHTML = buildSourcesVM().cards.map(sourceCardHtml).join('');
paintSparks();
}
/* =====================================================================
主站行情任务(HEL-566:自主站「行情管理」迁入,数据与调度仍归主站)
===================================================================== */
function dashDate(compact) {
const s = String(compact || '');
return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s;
}
async function loadMarketStatus() {
try {
state.market.status = await api('/admin/api/market/status');
} catch (err) {
state.market.status = { error: err.message };
}
renderMarketCard();
}
function marketJobNote(job) {
if (!job) return '';
if (job.status === 'running') return `${ledHtml('rev', true)}任务运行中…`;
if (job.status === 'success') return `${ledHtml('ok')}任务完成${job.finished_at ? ' · ' + esc(hm(job.finished_at)) : ''}`;
return `${ledHtml('fail')}任务失败${job.message || job.error_code ? ' · ' + esc(plainError(String(job.message || job.error_code || ''), 24)) : ''}`;
}
function marketCardHtml() {
const data = state.market.status;
let body;
if (!data) body = '<p class="form-hint">正在读取主站行情设置…</p>';
else if (data.error) {
body = `<div class="vend-note bad">${ledHtml('fail')}主站行情状态读取失败 · ${esc(data.error)}</div>`;
} else {
const enabled = Boolean(data.background_refresh_enabled);
body =
`<div class="route-line">` +
`<div class="field"><span class="lab">交易时段后台刷新</span>` +
`<span class="vend-note">${ledHtml(enabled ? 'ok' : 'off')}${enabled ? '已启用 · 后台定时更新公共快照' : '已暂停 · 交易时段不再自动刷新'}</span></div>` +
`<span class="form-actions">` +
`<button class="tbtn" data-market-toggle${state.market.toggleBusy ? ' disabled' : ''}>${state.market.toggleBusy ? '保存中…' : (enabled ? '暂停后台刷新' : '启用后台刷新')}</button>` +
`</span></div>` +
`<div class="route-line">` +
`<div class="field"><span class="lab">手动刷新快照日期</span><input type="date" data-market-refresh-date value="${esc(dashDate(today()))}"></div>` +
`<span class="form-actions"><button class="pbtn" data-market-refresh>立即后台刷新</button>` +
`<span class="vend-note">${marketJobNote(state.market.jobs.refresh)}</span></span></div>` +
`<div class="form-hint">所有用户共读同一份主站快照,任务在主站后台执行,完成后此处亮绿灯。</div>` +
`<div class="route-line">` +
`<div class="field"><span class="lab">历史回补开始日期</span><input type="date" data-market-backfill-start></div>` +
`<div class="field"><span class="lab">历史回补结束日期</span><input type="date" data-market-backfill-end></div>` +
`<span class="form-actions"><button class="tbtn" data-market-backfill>开始回补</button>` +
`<span class="vend-note">${marketJobNote(state.market.jobs.backfill)}</span></span></div>` +
`<div class="form-hint">补齐缺失的历史行情快照,区间最多 15 个交易日,周末与节假日自动跳过。</div>`;
}
return panelHtml('主站行情任务', tagHtml('dim', '自主站行情管理迁入'), body);
}
function renderMarketCard() {
const el = $('marketCard');
if (!el) return;
el.innerHTML = marketCardHtml();
}
function trackMarketJob(kind, jobKey) {
state.market.jobs[kind] = { status: 'running' };
renderMarketCard();
let attempts = 0;
// 刷新最快数十秒;回补按分钟计。超时只是停止等待,任务仍在主站继续。
const limit = kind === 'backfill' ? 450 : 120;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > limit) {
clearInterval(timer);
state.market.jobs[kind] = { status: 'running', message: '等待超时,任务仍在主站执行,可稍后重进本页查看结果' };
renderMarketCard();
return;
}
try {
const status = await api('/admin/api/market/status');
state.market.status = status;
const job = (status.jobs || []).find((item) => item.idempotency_key === jobKey);
if (job && (job.status === 'success' || job.status === 'failed')) {
clearInterval(timer);
state.market.jobs[kind] = job;
renderMarketCard();
toast(
job.status === 'success' ? (kind === 'backfill' ? '历史回补完成' : '后台刷新完成') : `任务失败:${job.message || job.error_code || '未知错误'}`,
job.status === 'success' ? 'ok' : 'err'
);
return;
}
if (job) state.market.jobs[kind] = job;
} catch {}
}, 2000);
}
async function toggleMarketRefresh() {
const data = state.market.status;
if (!data || data.error || state.market.toggleBusy) return;
state.market.toggleBusy = true;
renderMarketCard();
try {
state.market.status = await api('/admin/api/market/settings', {
method: 'POST',
body: JSON.stringify({ background_refresh_enabled: !data.background_refresh_enabled }),
});
toast(!data.background_refresh_enabled ? '后台刷新已启用' : '后台刷新已暂停', 'ok');
} catch (err) {
toast(err.message, 'err');
}
state.market.toggleBusy = false;
renderMarketCard();
}
async function startMarketRefresh() {
const dateEl = document.querySelector('[data-market-refresh-date]');
try {
const result = await api('/admin/api/market/refresh', {
method: 'POST',
body: JSON.stringify({ trade_date: dateEl ? dateEl.value : '' }),
});
if (!result.started) { toast(result.message || '已有后台刷新任务正在运行', 'err'); return; }
toast(result.message || '后台刷新已开始', 'ok');
trackMarketJob('refresh', result.job_key);
} catch (err) { toast(err.message, 'err'); }
}
async function startMarketBackfill() {
const startEl = document.querySelector('[data-market-backfill-start]');
const endEl = document.querySelector('[data-market-backfill-end]');
const start = startEl ? startEl.value : '';
const end = endEl ? endEl.value : '';
if (!start || !end) { toast('请先选择回补的开始与结束日期', 'err'); return; }
if (!window.confirm(`确定回补 ${start}${end} 的历史行情?`)) return;
try {
const result = await api('/admin/api/market/backfill', {
method: 'POST',
body: JSON.stringify({ start_date: start, end_date: end }),
});
if (!result.started) { toast(result.message || '已有回补任务正在运行', 'err'); return; }
toast(result.message || '回补任务已开始', 'ok');
trackMarketJob('backfill', result.job_key);
} catch (err) { toast(err.message, 'err'); }
}
/* =====================================================================
数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
===================================================================== */
function pageNameOf(consumer) {
const idx = consumer.indexOf('(');
return (idx > 0 ? consumer.slice(0, idx) : consumer).trim();
}
/* 一条血缘项的服务接口集合(primary/backup 冒号后的真实接口名) */
function servingInterfaces(item) {
const out = new Set([item.dataset]);
[item.primary_source, item.backup_source].forEach((src) => {
if (!src) return;
src.split(':').slice(1).join(':').split('+').forEach((api) => {
const name = api.trim();
if (name) out.add(name);
});
});
return out;
}
/* 接口级状态:只看本数据集真正走的那几条接口,不用提供商级汇总顶替。 */
function lineageItemRowsHealth(item) {
const apis = servingInterfaces(item);
return (item.live_provider_health || []).filter((r) => apis.has(r.interface));
}
function lineageItemBucket(item) {
const rows = lineageItemRowsHealth(item);
if (item.tier === 'official') {
if (item.publication) return item.publication.state === 'published' ? 'ok' : (item.publication.state === 'degraded' ? 'degrade' : 'fail');
return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan';
}
if (item.tier === 'licensed') {
const cred = ((RAW.catalog && RAW.catalog.items) || []).find((c) => c.provider === 'ifind');
if (cred && !(cred.credential && cred.credential.configured)) return 'off';
return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off';
}
// provisional:已有数据来源;有观测行看接口级状态,暂无观测行=已配置/待观测
return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan';
}
function lineageItemNote(item) {
const rows = lineageItemRowsHealth(item);
const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || '';
if (note) return note;
if (item.tier === 'licensed') return 'iFinD 未配置 · 功能隐藏';
if (item.tier === 'provisional' && !rows.length) return '已配置 · 暂无观测记录';
return '';
}
const linFilter = { onlyBad: false, src: '全部', q: '' };
let linHover = null;
let GW = 1500, GH = 286;
const gpos = {};
const adj = { sd: new Map(), ds: new Map(), dp: new Map(), pd: new Map() };
function adjAdd(m, k, v) { if (!m.has(k)) m.set(k, new Set()); m.get(k).add(v); }
function buildLineageVM() {
const items = (RAW.lineage && RAW.lineage.items) || [];
const PROV_LABEL = { tushare: 'Tushare', eastmoney: '东方财富', tencent: '腾讯行情', ifind: 'iFinD' };
const srcState = {};
const dsState = {};
const pageState = {};
const edgesSD = [];
const edgesDP = [];
const rows = [];
items.forEach((it) => {
const rowsHealth = lineageItemRowsHealth(it);
const bucket = lineageItemBucket(it);
dsState[it.dataset] = bucket;
(it.providers || []).forEach((p) => {
srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]);
edgesSD.push([p, it.dataset]);
});
const consumers = it.known_consumers || [];
const pages = [...new Set(consumers.map(pageNameOf))];
pages.forEach((pg) => {
pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]);
edgesDP.push([it.dataset, pg]);
});
rows.push({
page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset,
via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`,
role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'),
freq: it.update_freq || '—',
ok: it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at,
lat: rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null),
st: bucket,
note: lineageItemNote(it),
});
});
const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] }));
const datasets = Object.keys(dsState).map((id) => ({ id, st: dsState[id] }));
const pages = Object.keys(pageState).map((id) => ({ id, name: id, st: pageState[id] }));
return { sources, datasets, pages, edgesSD, edgesDP, rows };
}
function spread(n, y0, y1) { return Array.from({ length: n }, (_, i) => (n === 1 ? (y0 + y1) / 2 : y0 + (i / (n - 1)) * (y1 - y0))); }
function layoutGraph(vm) {
const wide = (window.innerWidth || 1440) > 1100;
GW = wide ? 1500 : 1000; GH = wide ? 286 : 560;
vm.sources.forEach((s, i) => { gpos['s:' + s.id] = { x: wide ? 80 : 60, y: spread(vm.sources.length, wide ? 60 : 90, wide ? 232 : 500)[i] }; });
vm.datasets.forEach((d, i) => { gpos['d:' + d.id] = { x: wide ? 700 : 480, y: spread(vm.datasets.length, wide ? 30 : 45, wide ? 264 : 525)[i] }; });
vm.pages.forEach((p, i) => { gpos['p:' + p.id] = { x: wide ? 1400 : 930, y: spread(vm.pages.length, wide ? 26 : 30, wide ? 276 : 540)[i] }; });
}
function linkPath(p1, p2) { const mx = (p1.x + p2.x) / 2; return `M ${p1.x} ${p1.y} C ${mx} ${p1.y}, ${mx} ${p2.y}, ${p2.x} ${p2.y}`; }
const stColorOf = (b) => (HEALTH[b] || HEALTH.off).color;
function lineageGraphHtml(vm) {
layoutGraph(vm);
adj.sd.clear(); adj.ds.clear(); adj.dp.clear(); adj.pd.clear();
vm.edgesSD.forEach(([s, d]) => { adjAdd(adj.sd, s, d); adjAdd(adj.ds, d, s); });
vm.edgesDP.forEach(([d, p]) => { adjAdd(adj.dp, d, p); adjAdd(adj.pd, p, d); });
let h = `<svg viewBox="0 0 ${GW} ${GH}" style="width:100%;display:block" id="linSvg">`;
const colStyle = 'font-size="10" letter-spacing="2" style="fill:var(--dim)"';
if (vm.sources.length) h += `<text x="${gpos['s:' + vm.sources[0].id].x}" y="12" text-anchor="middle" ${colStyle}>来源 · ${vm.sources.length}</text>`;
if (vm.datasets.length) h += `<text x="${gpos['d:' + vm.datasets[0].id].x}" y="12" text-anchor="middle" ${colStyle}>数据集 · ${vm.datasets.length}</text>`;
if (vm.pages.length) h += `<text x="${gpos['p:' + vm.pages[0].id].x}" y="12" text-anchor="middle" ${colStyle}>主站页面 · ${vm.pages.length}</text>`;
const dsBy = {}; vm.datasets.forEach((d) => { dsBy[d.id] = d.st; });
// 已连接线路全部有可辨识的定向流动(flowline),颜色按数据集真实状态
vm.edgesSD.forEach(([s, d]) => {
if (!gpos['s:' + s] || !gpos['d:' + d]) return;
h += `<path data-link data-kind="sd" data-a="${esc(s)}" data-b="${esc(d)}" d="${linkPath(gpos['s:' + s], gpos['d:' + d])}" fill="none" stroke-width="1" stroke-opacity="0.28" class="${dsBy[d] === 'off' ? '' : 'flowline'}" style="stroke:${stColorOf(dsBy[d])};transition:stroke-opacity .18s ease,stroke-width .18s ease"/>`;
});
vm.edgesDP.forEach(([d, p]) => {
if (!gpos['d:' + d] || !gpos['p:' + p]) return;
h += `<path data-link data-kind="dp" data-a="${esc(d)}" data-b="${esc(p)}" d="${linkPath(gpos['d:' + d], gpos['p:' + p])}" fill="none" stroke-width="1" stroke-opacity="0.22" class="${dsBy[d] === 'off' ? '' : 'flowline'}" style="stroke:${stColorOf(dsBy[d])};transition:stroke-opacity .18s ease,stroke-width .18s ease"/>`;
});
vm.sources.forEach((s) => {
const p = gpos['s:' + s.id]; if (!p) return;
h += `<g data-nid="${esc(s.id)}" data-gkind="src" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="16" style="fill:rgba(34,211,238,.05);stroke:var(--line2)"/>` +
`<circle cx="${p.x}" cy="${p.y}" r="5" ${s.st !== 'off' ? `class="gnode-pulse" style="fill:${stColorOf(s.st)};animation-delay:${(vm.sources.indexOf(s) * 0.37).toFixed(2)}s"` : `style="fill:${stColorOf(s.st)}"`}/>` +
`<text x="${p.x + 24}" y="${p.y + 4}" font-size="12" style="fill:var(--txt)" font-weight="600">${esc(s.name)}</text></g>`;
});
vm.datasets.forEach((d) => {
const p = gpos['d:' + d.id]; if (!p) return;
h += `<g data-nid="${esc(d.id)}" data-gkind="ds" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="4" style="fill:var(--panel);stroke:${stColorOf(d.st)}" stroke-width="1.4"/>` +
`<text x="${p.x - 12}" y="${p.y + 3.5}" text-anchor="end" font-size="10.5" style="fill:var(--mut)">${esc(d.id)}</text></g>`;
});
vm.pages.forEach((pg) => {
const p = gpos['p:' + pg.id]; if (!p) return;
h += `<g data-nid="${esc(pg.id)}" data-gkind="pg" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="3" style="fill:${stColorOf(pg.st)}"/>` +
`<text x="${p.x - 12}" y="${p.y + 3.5}" text-anchor="end" font-size="10.5" style="fill:var(--mut)">${esc(pg.name)}</text></g>`;
});
h += `</svg><div class="lin-hint" id="linHint">悬停节点 · 追踪上下游链路</div>`;
return h;
}
function activeSetCalc(hover, vm) {
if (!hover) return null;
const set = new Set([hover]);
const isSrc = vm.sources.some((s) => s.id === hover);
const isDs = vm.datasets.some((d) => d.id === hover);
if (isSrc) { (adj.sd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.dp.get(d) || new Set()).forEach((p) => set.add(p)); }); }
else if (isDs) { (adj.ds.get(hover) || new Set()).forEach((s) => set.add(s)); (adj.dp.get(hover) || new Set()).forEach((p) => set.add(p)); }
else { (adj.pd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.ds.get(d) || new Set()).forEach((s) => set.add(s)); }); }
return set;
}
function applyLinHover(vm) {
const act = activeSetCalc(linHover, vm);
const isSrcHov = vm.sources.some((s) => s.id === linHover);
document.querySelectorAll('#linSvg path[data-link]').forEach((p) => {
const a = p.dataset.a, b = p.dataset.b;
const shown = !act || (act.has(a) && act.has(b));
let hot = false;
if (act && act.has(a) && act.has(b)) hot = isSrcHov ? (a === linHover ? true : (adj.sd.get(linHover) || new Set()).has(a)) : true;
p.setAttribute('stroke-width', hot ? 1.8 : 1);
p.setAttribute('stroke-opacity', shown ? (act ? 0.85 : (p.dataset.kind === 'sd' ? 0.28 : 0.22)) : 0.05);
});
document.querySelectorAll('#linSvg g[data-nid]').forEach((g) => {
const id = g.dataset.nid, kind = g.dataset.gkind;
const dimmed = act && !act.has(id);
g.setAttribute('opacity', dimmed ? (kind === 'src' ? 0.3 : 0.25) : 1);
if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.style.fill = act && act.has(id) ? 'var(--txt-strong)' : 'var(--mut)'; }
});
const hint = $('linHint');
if (hint) hint.textContent = linHover ? `FOCUS: ${linHover} · 高亮上下游链路` : '悬停节点 · 追踪上下游链路';
}
function setLinHover(id, vm) { linHover = id; applyLinHover(vm); }
function linRowsData(vm) {
return vm.rows.filter((r) => {
if (linFilter.onlyBad && !['fail', 'slow', 'degrade', 'off', 'review', 'plan'].includes(r.st)) return false;
if (linFilter.src !== '全部' && !r.via.includes({ Tushare: 'tushare', 东方财富: 'eastmoney', 腾讯: 'tencent', iFinD: 'ifind' }[linFilter.src] || '')) return false;
if (linFilter.q && !(r.page + r.item + r.dataset + r.via).toLowerCase().includes(linFilter.q.toLowerCase())) return false;
return true;
});
}
function renderLinRows(vm) {
const body = $('linBody'), cnt = $('linCount'), bodyN = $('linBodyN');
if (!body) return;
const rows = linRowsData(vm);
if (cnt) cnt.textContent = `${rows.length}/${vm.rows.length} 行`;
const cls = (st) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : '';
const stLabel = (r) => (r.st === 'plan' && r.note.includes('待观测')) ? '已配置 · 待观测' : '';
body.innerHTML = rows.map((r) => `<tr class="${cls(r.st)}">` +
`<td style="color:var(--txt-strong);white-space:nowrap">${esc(r.page)}</td>` +
`<td style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(r.item)}</td>` +
`<td style="color:var(--cy);font-size:11px">${esc(r.dataset)}</td>` +
`<td style="color:var(--mut);font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.via)}">${esc(r.via)}</td>` +
`<td style="color:var(--dim);font-size:11px;white-space:nowrap">${esc(r.role)}</td>` +
`<td class="num" style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(r.freq)}</td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:var(--mut)">${r.ok ? esc(hm(r.ok)) : '—'}</td>` +
`<td class="num" style="font-size:11px;color:${r.lat != null && r.lat > 1000 ? 'var(--amb)' : 'var(--mut)'}">${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}</td>` +
`<td><span style="display:flex;align-items:center;gap:8px;white-space:nowrap">${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? ellipHtml(plainError(r.note, 20), 'var(--dim)', '10px', r.note) : ''}</span></td>` +
`</tr>`).join('') || `<tr><td colspan="9" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
if (bodyN) bodyN.innerHTML = rows.map((r) => `<tr class="${cls(r.st)}">` +
`<td style="color:var(--txt-strong);white-space:nowrap">${esc(r.page)}<span class="sub" style="color:var(--mut)">${esc(r.item)}</span></td>` +
`<td style="color:var(--cy);font-size:11px">${esc(r.dataset)}</td>` +
`<td style="color:var(--mut);font-size:11px;word-break:break-all">${esc(r.via)}</td>` +
`<td class="num" style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(r.freq)}<span class="sub">${esc(r.role)}</span></td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:var(--mut)">${r.ok ? esc(hm(r.ok)) : '—'}<span class="sub">${r.lat != null ? r.lat + 'ms' : '—'}</span></td>` +
`<td>${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `<span class="sub ellip" title="${esc(r.note)}">${esc(plainError(r.note, 20))}</span>` : ''}</td>` +
`</tr>`).join('') || `<tr><td colspan="6" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
}
function lineageHtml() {
const vm = buildLineageVM();
const failDs = vm.datasets.filter((d) => d.st === 'fail');
let impactTag;
if (failDs.length) {
const pages = new Set();
failDs.forEach((d) => { (adj.dp.get(d.id) || new Set()).forEach((p) => pages.add(p)); });
impactTag = tagHtml('rd', `${failDs.map((d) => d.id).join('/')} 断链影响 ${pages.size} 页`);
} else {
impactTag = tagHtml('mint', '暂无断链影响');
}
const html = `<div class="col">` +
`<div class="panel strip" style="gap:8px 20px">` +
`<span style="font-size:13px;color:var(--txt-strong)">血缘地图</span>` +
`<span class="num" style="font-size:11px;color:var(--mut)"><span style="color:var(--cy)">${vm.sources.length}</span> 源 → <span style="color:var(--cy)">${vm.datasets.length}</span> 数据集 → <span style="color:var(--cy)">${vm.pages.length}</span> 页面</span>` +
`<span class="legend" style="color:var(--dim);letter-spacing:0"><span>${ledHtml('ok')}健康</span><span>${ledHtml('slow')}慢/降级</span><span>${ledHtml('fail')}失败</span><span>${ledHtml('plan')}计划</span><span>${ledHtml('off')}未配置</span></span>` +
`<span class="flex1"></span>` +
impactTag +
`</div>` +
`<section class="panel"><div style="position:relative">${lineageGraphHtml(vm)}</div></section>` +
`<section class="panel">` +
`<div class="lin-filter">` +
`<select class="lin-sel" id="linSel">${['全部', 'Tushare', '东方财富', '腾讯', 'iFinD'].map((o) => `<option${linFilter.src === o ? ' selected' : ''}>按源筛选 · ${o}</option>`).join('')}</select>` +
`<button class="tbtn${linFilter.onlyBad ? ' on' : ''}" id="linBad">${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常</button>` +
`<span class="flex1"></span>` +
`<span class="num" style="font-size:10px;color:var(--dim)" id="linCount"></span>` +
`<input class="lin-q" id="linQ" placeholder="搜索页面 / 接口 / 数据集" value="${esc(linFilter.q)}">` +
`</div>` +
`<div class="lin-scroll"><table class="dtable lin-table"><thead><tr><th>主站页面</th><th>数据项</th><th>数据集</th><th>源 : 接口</th><th>主/备</th><th>更新频率</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody id="linBody"></tbody></table>` +
`<table class="dtable lin-table-n"><thead><tr><th>主站页面</th><th>数据集</th><th>源 : 接口</th><th>更新频率</th><th>最近成功</th><th>状态</th></tr></thead><tbody id="linBodyN"></tbody></table></div>` +
`</section>` +
`</div>`;
return html;
}
/* =====================================================================
模型池(供应商制,样图 v2):一个供应商一张卡,卡内填 API 地址与 Key,
自动拉取模型列表勾选纳入;拉取失败退回卡内「手动录入(兜底)」。
数据仍归主站所有,本页经桥接读写。
===================================================================== */
function vendorDraft(key) {
if (!state.vendorDraft[key]) {
state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '', collapsed: false };
}
return state.vendorDraft[key];
}
function modelRoleTag(id) {
const data = state.models || {};
if (id && id === data.primary_model_id) return tagHtml('cy', '主模型');
if (id && id === data.fallback_model_id) return tagHtml('amb', '备用');
return tagHtml('dashed', '备用');
}
function modelRowHtml(model, vendorLabel) {
const test = state.modelTests[model.id];
const result = test
? (test.pending
? '<span class="mr-state">测试中…</span>'
: `<span class="mr-state" style="color:${test.ok ? 'var(--mint)' : 'var(--rd)'}">${test.ok ? `已连通 · ${test.ms}ms` : `未连通 · ${esc(test.error || '失败')}`}</span>`)
: '<span class="mr-state">未测试</span>';
return `<div class="model-row" data-model-id="${esc(model.id)}">` +
`<div class="mr-main"><div class="mr-name">${esc(model.name || model.id)} ${modelRoleTag(model.id)}</div>` +
`<div class="mr-sub">${esc(vendorLabel)} · ${esc(model.model || '')}</div></div>` +
`<span class="mr-test"><button class="tbtn" data-model-test="${esc(model.id)}">测试连接</button>${result}</span>` +
`<button class="tbtn" data-model-remove="${esc(model.id)}">删除</button>` +
`</div>`;
}
function vendorStateTag(group, draft) {
if (draft.error) return tagHtml('rd', '拉取失败');
if (draft.fetching) return tagHtml('cy', '拉取中');
if (group.configured) return tagHtml('mint', '密钥已保存');
return tagHtml('amb', '待配置');
}
function vendorFetchHtml(group, draft) {
if (draft.fetching) {
return `<div class="vend-note">${ledHtml('rev', true)}正在请求 /models … 通常 1~3 秒,期间不可重复点击</div>`;
}
if (draft.error) {
return `<div class="vend-note bad">${ledHtml('fail')}拉取失败 · ${esc(draft.error)} · 也可改用手动录入模型标识</div>` +
manualEntryHtml(group);
}
if (!draft.fetched) return group.models.length ? '' : manualEntryHtml(group);
// HEL-566: 拉取结果可收起。收起后只留一行摘要,重新拉取即可再次展开。
if (draft.collapsed) {
return `<div class="vend-note">${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` +
`${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 清单已收起</div>` +
manualEntryHtml(group);
}
const existing = new Set(group.models.map((model) => String(model.model || '')));
const pending = draft.fetched.filter((name) => !existing.has(name));
const picks = draft.fetched.map((name) => {
const added = existing.has(name);
return `<label class="vend-pick"><input type="checkbox" data-vend-pick="${esc(group.base_url)}" value="${esc(name)}"` +
`${added ? ' checked disabled' : ''}>${esc(name)}${added ? '<span class="added">已添加</span>' : ''}</label>`;
}).join('');
return `<div class="vend-note">${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` +
`${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 来自 GET /models</div>` +
`<div class="vend-fetch-list">${picks}</div>` +
`<div class="form-actions"><button class="pbtn" data-vend-add="${esc(group.base_url)}"${pending.length ? '' : ' disabled'}>` +
`添加勾选模型${pending.length ? ` (${pending.length})` : ''}</button>` +
`<span class="form-hint">${pending.length ? '勾选后一键添加,免手敲模型标识' : '全部已添加'}</span></div>` +
manualEntryHtml(group);
}
function manualEntryHtml(group) {
const draft = vendorDraft(group.base_url);
if (!draft.error && !draft.manual) {
return `<div class="form-actions"><button class="tbtn" data-vend-manual="${esc(group.base_url)}">手动录入模型</button>` +
`<span class="form-hint">拉取失败或供应商不支持 /models 时使用</span></div>`;
}
return `<div class="vend-manual"><span class="lab">手动录入(兜底)</span>` +
`<div class="field"><span class="lab">模型标识 <span style="color:var(--cy)">*</span></span>` +
`<input data-vend-manual-input="${esc(group.base_url)}" placeholder="例:qwen-plus"></div>` +
`<button class="tbtn" data-vend-manual-add="${esc(group.base_url)}">添加该模型</button></div>`;
}
function vendorCardHtml(group) {
const draft = vendorDraft(group.base_url);
const required = '<span style="color:var(--cy)">*</span>';
// 样图:地址与 Key 并排一行,两个操作按钮贴在同一行右端,提示挂在 Key 下面
const body = `<div class="vend-line">` +
`<div class="field"><span class="lab">API BASE URL ${required}</span>` +
`<input data-vend-url="${esc(group.base_url)}" value="${esc(group.base_url)}" placeholder="https://api.example.com/v1"></div>` +
`<div class="field"><span class="lab">API KEY ${required}</span>` +
`<input type="password" autocomplete="off" data-vend-key="${esc(group.base_url)}"` +
`${group.configured ? ' placeholder="••••••••••••••••"' : ' placeholder="输入 API Key"'}>` +
`<span class="form-hint">${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}</span></div>` +
`<span class="vend-acts">` +
`<button class="tbtn" data-vend-fetch="${esc(group.base_url)}"${draft.fetching ? ' disabled' : ''}>` +
`${vendFetchLabel(group, draft)}</button>` +
`<button class="pbtn" data-vend-save="${esc(group.base_url)}">保存供应商</button></span>` +
`</div>` +
vendorFetchHtml(group, draft) +
group.models.map((model) => modelRowHtml(model, group.label)).join('');
const head = `<span class="vend-name">${ledHtml(group.configured ? 'ok' : 'off')}${esc(group.label)}</span>` +
vendorStateTag(group, draft) +
tagHtml('cy', `${group.models.length} 个模型`);
return `<section class="panel"><header class="panel-hd vend-hd">${head}</header>` +
`<div class="panel-b vend-body">${body}</div></section>`;
}
/* HEL-566: 列表展开时按钮变「收起列表」,收起/未拉取时回到拉取文案,
点击重新拉取会再次展开,交互沿用既有按钮,不新增视觉元素。 */
function vendFetchLabel(group, draft) {
if (draft.fetched && !draft.collapsed && !draft.fetching) return '收起列表';
return group.models.length ? '重新拉取模型列表' : '获取模型列表';
}
function modelsHtml() {
const data = state.models;
if (!data) return panelHtml('模型池', '', '<p class="form-hint">正在从主站读取模型池…</p>');
const named = (id) => (data.models.find((model) => model.id === id) || {}).name || '';
const option = (model, want) =>
`<option value="${esc(model.id)}"${model.id === want ? ' selected' : ''}>${esc(model.name || model.id)}</option>`;
const strip = `<div class="panel strip">` +
`<span style="font-size:13px;color:var(--txt-strong)">供应商 <span class="num" style="color:var(--cy)">${data.groups.length}</span>` +
` · 模型 <span class="num" style="color:var(--cy)">${data.models.length}</span> 个` +
` · 主模型 <span style="color:var(--cy)">${esc(named(data.primary_model_id) || '未指定')}</span>` +
` · 辅助模型 <span style="color:${data.fallback_model_id ? 'var(--amb)' : 'var(--dim)'}">${esc(named(data.fallback_model_id) || '未启用')}</span></span>` +
`<span class="flex1"></span>` +
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">密钥加密存储 · 保存后全站即时生效 · 自主站系统管理迁入</span>` +
`</div>`;
const routing = panelHtml('调用编排 ROUTING',
`<span style="font-size:10px;color:var(--dim)">主模型故障时自动回落辅助模型</span>`,
`<div class="route-line">` +
`<div class="field"><span class="lab">主模型 PRIMARY</span><select id="primaryModel">` +
`<option value="">未指定</option>${data.models.map((model) => option(model, data.primary_model_id)).join('')}</select></div>` +
`<div class="field"><span class="lab">辅助模型 FALLBACK</span><select id="fallbackModel">` +
`<option value="">不启用辅助模型</option>${data.models.map((model) => option(model, data.fallback_model_id)).join('')}</select></div>` +
`<button class="pbtn" id="saveRoles">保存模型池</button>` +
`<span class="form-hint">主 / 辅不可指向同一模型</span></div>`);
const vendors = data.groups.map(vendorCardHtml).join('');
const adder = `<section class="panel"><div class="panel-b form-actions">` +
`<button class="tbtn" id="addVendor">+ 添加供应商</button>` +
`<select id="newVendorPreset"><option value="">自定义接口地址…</option>` +
data.vendors.map((vendor) => `<option value="${esc(vendor.base_url)}">${esc(vendor.label)}</option>`).join('') +
`</select>` +
`<input id="newVendorUrl" placeholder="https://api.example.com/v1">` +
`<span class="form-hint">同一供应商(同一 API 地址)可挂多个模型 · 主 / 辅正在引用的模型不可删除,需先在上方切换</span>` +
`</div></section>`;
return `<div class="col">${strip}${routing}${vendors}${adder}</div>`;
}
async function loadModels() {
state.models = await api('/admin/api/models');
}
async function saveModels(models, extra) {
const payload = Object.assign({ models: models }, extra || {});
state.models = await api('/admin/api/models/save', { method: 'POST', body: JSON.stringify(payload) });
renderPage('models');
}
/* =====================================================================
会员管理 + 邀请码:账号仍归主站,本页只调会员状态/时长/额度;
注册必须凭一次性邀请码,这里负责生成 / 看状态 / 作废。
===================================================================== */
const MEMBER_DURATIONS = [
['', '选择时长'], ['1_month', '1个月'], ['3_months', '3个月'],
['12_months', '12个月'], ['3_years', '3年'], ['permanent', '永久'],
];
function memberExpiryText(u) {
if (u.membership_subscribed) return u.membership_expires_at ? '有效至 ' + ymd(u.membership_expires_at) : '永久有效';
if (u.membership_status === 'suspended') return '已于 ' + (ymd(u.membership_expires_at) || '—') + ' 停用';
if (u.membership_status === 'active' && u.membership_expires_at) return '已于 ' + ymd(u.membership_expires_at) + ' 到期';
return '尚未开通';
}
function memberStatusValue(u) {
if (u.role === 'admin') return 'active';
return u.membership_status || 'inactive';
}
function memberIdentityHtml(u) {
const parts = [];
if (u.role === 'admin') parts.push(tagHtml('cy', '管理员'));
if (u.membership_subscribed) parts.push(tagHtml('mint', '会员有效'));
else if (u.membership_status === 'suspended') parts.push(tagHtml('rd', '会员已停用'));
else parts.push(tagHtml('dashed', '普通用户'));
return parts.join('');
}
function membersHtml() {
const data = state.members;
const invites = state.invites;
if (!data || !invites) return panelHtml('会员管理', '', '<p class="form-hint">正在从主站读取会员与邀请码…</p>');
const users = data.users || [];
const memberCount = users.filter((u) => u.membership_subscribed).length;
const usage = users.reduce((sum, u) => sum + (Number(u.used_today) || 0), 0);
const quota = (data.membership || {}).member_daily_limit || 50;
const strip = `<section class="panel"><div class="panel-b" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px">` +
`<span style="font-size:13px;color:var(--txt-strong)">注册用户 <span class="num" style="color:var(--cy)">${users.length}</span>` +
`<span style="color:var(--dim)"> · </span>会员 <span class="num" style="color:var(--mint)">${memberCount}</span>` +
`<span style="color:var(--dim)"> · </span>今日调用 <span class="num" style="color:var(--cy)">${usage}</span></span>` +
`<span class="flex1"></span>` +
`<span class="lab" style="margin:0">会员每日调用额度</span>` +
`<input id="memberQuota" class="num" value="${esc(String(quota))}" inputmode="numeric" style="width:90px;background:var(--panel);border:1px solid var(--line2);border-radius:4px;color:var(--txt);font-family:inherit;font-size:12px;padding:6px 9px">` +
`<button class="pbtn" id="saveQuota">保存额度</button></div></section>`;
const rows = users.map((u) => {
const id = String(u.id);
const canEdit = u.role !== 'admin';
return `<tr>` +
`<td style="color:var(--txt-strong);white-space:nowrap">${esc(u.username)}</td>` +
`<td><span style="display:flex;gap:6px">${memberIdentityHtml(u)}</span></td>` +
`<td class="num">${esc(String(u.used_today ?? 0))}</td>` +
`<td><select data-member-status="${esc(id)}"${canEdit ? '' : ' disabled title="管理员会员状态由主站固定,不在此处调整"'}>` +
[['inactive', '未开通'], ['active', '有效'], ['suspended', '停用']]
.map(([v, l]) => `<option value="${v}"${memberStatusValue(u) === v ? ' selected' : ''}>${l}</option>`).join('') +
`</select></td>` +
`<td>${canEdit
? `<select data-member-duration="${esc(id)}">` +
MEMBER_DURATIONS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('') + `</select>`
: '<span style="color:var(--dim)">—</span>'}</td>` +
`<td style="font-size:11px;color:${u.membership_subscribed ? 'var(--txt)' : 'var(--dim)'};white-space:nowrap">${esc(memberExpiryText(u))}</td>` +
`<td class="cell-actions">${canEdit ? `<button class="tbtn" data-member-save="${esc(id)}">应用</button>` : '<span style="color:var(--dim)">—</span>'}</td>` +
`</tr>`;
}).join('');
const memberPanel = panelHtml('会员管理 MEMBERS', `<span style="font-size:10px;color:var(--dim)">自主站系统管理迁入</span>`,
`<div class="wide-scroll"><table class="dtable wide-table"><thead><tr>` +
`<th>用户名</th><th>身份</th><th>今日调用</th><th>会员状态</th><th>开通 / 续期</th><th>当前到期</th><th>操作</th>` +
`</tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-hint">暂无注册用户</td></tr>'}</tbody></table></div>`, false);
const codeRows = (invites.codes || []).map((c) => {
// 明文只在"还能用"的时候有意义;用掉或作废后立刻退回掩码并忘掉它。
if (c.status !== 'unused') delete state.freshCodes[c.code_id];
const fresh = c.status === 'unused' ? state.freshCodes[c.code_id] : '';
const tone = c.status === 'unused' ? tagHtml('mint', ledHtml('ok') + ' 未使用')
: (c.status === 'used' ? tagHtml('dashed', '已使用') : tagHtml('rd', '作废'));
const use = c.status === 'used'
? `${esc(stamp(c.used_at))} · ${esc(c.used_by_username || '未知')} 注册成功`
: (c.status === 'revoked' ? `${esc(stamp(c.revoked_at))} 管理员作废` : '—');
return `<tr>` +
`<td class="invite-code"${c.status === 'unused' ? '' : ' style="color:var(--dim)"'}>${esc(fresh || c.code_masked)}</td>` +
`<td>${tone}</td>` +
`<td class="num" style="font-size:11px">${esc(stamp(c.created_at) || '—')}</td>` +
`<td style="font-size:11px;color:var(--mut)">${use}</td>` +
`<td class="cell-actions">` +
(c.status === 'unused'
? `<button class="tbtn" data-invite-copy="${esc(c.code_id)}">复制</button>` +
`<button class="tbtn" data-invite-revoke="${esc(c.code_id)}">作废</button>`
: '<span style="color:var(--dim)">—</span>') +
`</td></tr>`;
}).join('');
const counts = [1, 2, 3, 5, 10].map((n) => `<option value="${n}">${n} 个</option>`).join('');
const invitePanel = panelHtml('一次性邀请码 INVITE CODES',
`<span style="font-size:10px;color:var(--dim)">生成</span>` +
`<select id="inviteCount" style="background:var(--panel);border:1px solid var(--line2);border-radius:4px;color:var(--txt);font-family:inherit;font-size:11px;padding:5px 8px">${counts}</select>` +
`<button class="pbtn" id="createInvites">生成邀请码</button>`,
`<div class="invite-note">${ledHtml('slow')}每个邀请码仅可成功注册一次 · 注册必须提交有效邀请码 · 并发重复使用只会成功一次,其余请求立即失效</div>` +
`<div class="wide-scroll"><table class="dtable wide-table"><thead><tr>` +
`<th>邀请码</th><th>状态</th><th>生成时间</th><th>使用情况</th><th>操作</th>` +
`</tr></thead><tbody>${codeRows || '<tr><td colspan="5" class="empty-hint">还没有邀请码</td></tr>'}</tbody></table></div>` +
`<div class="table-foot">邀请码仅掩码显示 · 完整码仅在生成瞬间与「复制」动作中可得 · 已使用 / 已作废不可恢复</div>`, false);
return `${strip}<div class="grid-1">${memberPanel}${invitePanel}</div>`;
}
async function loadMembers() {
const [members, invites] = await Promise.all([api('/admin/api/members'), api('/admin/api/invites')]);
state.members = members;
state.invites = invites;
}
/* =====================================================================
页面路由 / 顶栏 / EVENT TAPE / 抽屉 / 危险操作弹层
===================================================================== */
const mainEl = $('mainEl');
function pageFromHash() {
const h = location.hash.replace('#', '');
return ['overview', 'sources', 'models', 'members', 'lineage'].includes(h) ? h : 'overview';
}
function renderPage(p) {
state.page = p;
mainEl.dataset.page = p;
document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p));
if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); }
else if (p === 'sources') {
mainEl.innerHTML = sourcesHtml();
paintSparks();
if (!state.market.status) loadMarketStatus();
}
else if (p === 'models') {
mainEl.innerHTML = modelsHtml();
restoreVendorDrafts();
if (!state.models) loadModels().then(() => { if (state.page === 'models') renderPage('models'); }).catch((err) => toast(err.message, 'err'));
} else if (p === 'members') {
mainEl.innerHTML = membersHtml();
if (!state.members) loadMembers().then(() => { if (state.page === 'members') renderPage('members'); }).catch((err) => toast(err.message, 'err'));
} else {
mainEl.innerHTML = lineageHtml();
const vm = buildLineageVM();
window.__linVM = vm;
renderLinRows(vm);
applyLinHover(vm);
const svg = $('linSvg');
if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM));
fitLineageTable();
}
window.scrollTo(0, 0);
updatePhaseTag();
}
window.addEventListener('hashchange', () => renderPage(pageFromHash()));
document.querySelectorAll('[data-nav]').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.nav; }));
let resizeT = null;
window.addEventListener('resize', () => {
clearTimeout(resizeT);
resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180);
});
/* HEL-529: 血缘详情表随真实数据集数量动态撑高;宽档(>1100px,覆盖
1920×920/1440×900 验收位)给表格容器一个基于剩余可视高度计算的
max-height + 内部滚动,保证整页 scrollHeight 始终 <= clientHeight
同时全部行仍可在容器内滚动查看。窄档(<=1100px)整页纵滚。 */
function fitLineageTable() {
const wrap = document.querySelector('.lin-scroll');
const tape = document.querySelector('.tape');
if (!wrap || !tape) return;
wrap.style.maxHeight = '';
if (window.innerWidth <= 1100) return;
const tapeTop = tape.getBoundingClientRect().top;
const wrapTop = wrap.getBoundingClientRect().top;
const mainPadBottom = parseFloat(getComputedStyle(mainEl).paddingBottom) || 0;
const avail = tapeTop - wrapTop - mainPadBottom - 4;
if (avail > 0) wrap.style.maxHeight = Math.floor(avail) + 'px';
}
function updatePhaseTag() {
const ov = RAW.overview;
if (!ov) return;
const bad = (ov.anomalies || []).length > 0;
$('radarBlipBad').style.display = bad ? '' : 'none';
$('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`;
}
/* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */
function handleModelClick(e) {
const fetchBtn = e.target.closest('[data-vend-fetch]');
if (fetchBtn) { toggleVendorList(fetchBtn.dataset.vendFetch); return true; }
const saveVendor = e.target.closest('[data-vend-save]');
if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; }
const manual = e.target.closest('[data-vend-manual]');
if (manual) { vendorDraft(manual.dataset.vendManual).manual = true; renderPage('models'); return true; }
const manualAdd = e.target.closest('[data-vend-manual-add]');
if (manualAdd) { addManualModel(manualAdd.dataset.vendManualAdd); return true; }
const add = e.target.closest('[data-vend-add]');
if (add) { addPickedModels(add.dataset.vendAdd); return true; }
const test = e.target.closest('[data-model-test]');
if (test) { testModel(test.dataset.modelTest); return true; }
const remove = e.target.closest('[data-model-remove]');
if (remove) { removeModel(remove.dataset.modelRemove); return true; }
if (e.target.closest('#saveRoles')) { saveRoles(); return true; }
if (e.target.closest('#addVendor')) { addVendor(); return true; }
return false;
}
function vendorInputs(baseUrl) {
const draft = vendorDraft(baseUrl);
const urlEl = document.querySelector(`[data-vend-url="${CSS.escape(baseUrl)}"]`);
const keyEl = document.querySelector(`[data-vend-key="${CSS.escape(baseUrl)}"]`);
draft.url = (urlEl ? urlEl.value : draft.url || baseUrl).trim();
draft.key = (keyEl ? keyEl.value : draft.key).trim();
return { url: draft.url || baseUrl, key: draft.key };
}
function restoreVendorDrafts() {
Object.keys(state.vendorDraft).forEach((baseUrl) => {
const draft = state.vendorDraft[baseUrl];
const urlEl = document.querySelector(`[data-vend-url="${CSS.escape(baseUrl)}"]`);
const keyEl = document.querySelector(`[data-vend-key="${CSS.escape(baseUrl)}"]`);
if (urlEl && draft.url) urlEl.value = draft.url;
if (keyEl && draft.key) keyEl.value = draft.key;
});
}
function vendorGroup(baseUrl) {
return (state.models.groups || []).find((group) => group.base_url === baseUrl) || { models: [], configured: false };
}
async function fetchVendorModels(baseUrl) {
const { url, key } = vendorInputs(baseUrl);
const draft = vendorDraft(baseUrl);
if (!url) { toast('请先填写 API BASE URL', 'err'); return; }
if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; }
draft.fetching = true;
draft.error = '';
draft.collapsed = false;
renderPage('models');
try {
const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) });
draft.fetched = result.models || [];
draft.fetchedAt = `今天 ${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`;
if (!draft.fetched.length) draft.error = '供应商未返回可用模型';
} catch (err) {
draft.fetched = null;
draft.error = err.message;
}
draft.fetching = false;
renderPage('models');
}
/* HEL-566: 拉出的清单可收起可展开——展开时收起,收起/未拉取时重新拉取。 */
function toggleVendorList(baseUrl) {
const draft = vendorDraft(baseUrl);
if (!draft.fetching && draft.fetched && !draft.collapsed) {
draft.collapsed = true;
renderPage('models');
return;
}
fetchVendorModels(baseUrl);
}
function nextModelId(model) {
// 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。
const used = new Set(state.models.models.map((m) => m.id));
let base = model.replace(/[^A-Za-z0-9]+/g, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 70);
if (base.length < 3) base = `model-${base}`.slice(0, 70);
let id = base;
let i = 2;
while (used.has(id)) { id = `${base}-${i}`; i += 1; }
return id;
}
function rebasedModels(baseUrl, url, key) {
// 改地址等于整组搬迁;Key 留空表示沿用主站已存的那把。
return state.models.models.map((model) => {
if (model.base_url !== baseUrl) return Object.assign({}, model);
const moved = Object.assign({}, model, { base_url: url });
if (key) moved.api_key = key;
return moved;
});
}
async function saveVendorProfile(baseUrl) {
const { url, key } = vendorInputs(baseUrl);
if (!url) { toast('请先填写 API BASE URL', 'err'); return; }
const group = vendorGroup(baseUrl);
if (!group.models.length) {
if (!key) { toast('新供应商需要先填写 API Key,再拉取或手动录入模型', 'err'); return; }
toast('该供应商还没有模型,请先拉取或手动录入', 'err');
return;
}
try {
await saveModels(rebasedModels(baseUrl, url, key));
vendorDraft(baseUrl).key = '';
toast('供应商已保存', 'ok');
} catch (err) { toast(err.message, 'err'); }
}
async function addModels(baseUrl, names) {
const { url, key } = vendorInputs(baseUrl);
if (!key && !vendorGroup(baseUrl).configured) { toast('新供应商需要先填写 API Key', 'err'); return; }
const models = rebasedModels(baseUrl, url, key);
const existingNames = new Set(models.map((model) => String(model.name || '')));
const added = [];
names.forEach((name) => {
// 主站要求模型名称全局唯一,同名模型接在供应商域名后区分。
let label = name;
if (existingNames.has(label)) label = `${name} @ ${url.split('//').pop().split('/')[0]}`.slice(0, 50);
if (existingNames.has(label)) return;
existingNames.add(label);
const entry = { id: nextModelId(name), name: label, model: name, base_url: url };
if (key) entry.api_key = key;
models.push(entry);
added.push(entry);
});
if (!added.length) { toast('这些模型已在池中', 'err'); return; }
const draft = vendorDraft(baseUrl);
draft.fetched = null;
draft.collapsed = false;
draft.manual = false;
draft.key = '';
const extra = {};
// 池子非空时主站强制要求主模型在池内,首个模型自动兼任主模型。
if (!state.models.primary_model_id) extra.primary_model_id = added[0].id;
await saveModels(models, extra);
toast(`已加入 ${added.length} 个模型`, 'ok');
}
function addPickedModels(baseUrl) {
const picks = Array.from(document.querySelectorAll(`[data-vend-pick="${CSS.escape(baseUrl)}"]`))
.filter((box) => box.checked && !box.disabled).map((box) => box.value);
if (!picks.length) { toast('请先勾选要纳入的模型', 'err'); return; }
addModels(baseUrl, picks).catch((err) => toast(err.message, 'err'));
}
function addManualModel(baseUrl) {
const input = document.querySelector(`[data-vend-manual-input="${CSS.escape(baseUrl)}"]`);
const name = input ? input.value.trim() : '';
if (!name) { toast('请填写模型标识', 'err'); return; }
addModels(baseUrl, [name]).catch((err) => toast(err.message, 'err'));
}
async function testModel(modelId) {
state.modelTests[modelId] = { pending: true };
renderPage('models');
try {
const response = await api('/admin/api/models/test', { method: 'POST', body: JSON.stringify({ model_id: modelId }) });
const detail = response.result || {};
state.modelTests[modelId] = {
ok: Boolean(detail.ok),
ms: detail.latency_ms || 0,
error: plainError(String(detail.error || '失败'), 18),
};
toast(detail.ok ? '模型连通' : '模型不可用:' + (detail.error || '未知错误'), detail.ok ? 'ok' : 'err');
} catch (err) {
state.modelTests[modelId] = { ok: false, ms: 0, error: plainError(err.message, 18) };
toast(err.message, 'err');
}
renderPage('models');
}
async function removeModel(modelId) {
const data = state.models;
if (modelId === data.primary_model_id || modelId === data.fallback_model_id) {
toast('该模型正被主/辅分工引用,请先在「调用编排」里换掉再删除', 'err');
return;
}
if (!window.confirm('确定从模型池移除该模型?')) return;
try {
await saveModels(data.models.filter((model) => model.id !== modelId));
toast('模型已移除', 'ok');
} catch (err) { toast(err.message, 'err'); }
}
async function saveRoles() {
const primary = $('primaryModel').value;
const fallback = $('fallbackModel').value;
if (primary && fallback && primary === fallback) { toast('主模型与辅助模型不能相同', 'err'); return; }
try {
await saveModels(state.models.models, { primary_model_id: primary, fallback_model_id: fallback });
toast('模型分工已保存', 'ok');
} catch (err) { toast(err.message, 'err'); }
}
function addVendor() {
const url = ($('newVendorUrl').value || $('newVendorPreset').value).trim();
if (!url) { toast('请先选择或填写供应商接口地址', 'err'); return; }
if (state.models.groups.some((group) => group.base_url === url)) { toast('该供应商已在列表中', 'err'); return; }
const preset = state.models.vendors.find((vendor) => vendor.base_url === url);
state.models.groups.push({
base_url: url,
label: (preset || {}).label || url.split('//').pop().split('/')[0],
configured: false, key_last4: '', models: [],
});
renderPage('models');
}
function handleMemberClick(e) {
const save = e.target.closest('[data-member-save]');
if (save) { saveMember(save.dataset.memberSave); return true; }
if (e.target.closest('#saveQuota')) { saveQuota(); return true; }
if (e.target.closest('#createInvites')) { createInvites(); return true; }
const copy = e.target.closest('[data-invite-copy]');
if (copy) { copyInvite(copy.dataset.inviteCopy); return true; }
const revoke = e.target.closest('[data-invite-revoke]');
if (revoke) { revokeInvite(revoke.dataset.inviteRevoke); return true; }
return false;
}
async function saveMember(userId) {
const status = document.querySelector(`[data-member-status="${CSS.escape(userId)}"]`);
const duration = document.querySelector(`[data-member-duration="${CSS.escape(userId)}"]`);
const payload = { user_id: Number(userId), status: status ? status.value : 'inactive' };
if (payload.status === 'active') {
payload.duration = duration ? duration.value : '';
if (!payload.duration) { toast('开通或续期需要先选择时长', 'err'); return; }
}
try {
state.members = await api('/admin/api/members/save', { method: 'POST', body: JSON.stringify(payload) });
renderPage('members');
toast('会员设置已保存', 'ok');
} catch (err) { toast(err.message, 'err'); }
}
async function saveQuota() {
const value = Number($('memberQuota').value || 0);
if (!Number.isFinite(value) || value < 1) { toast('每日调用额度至少为 1', 'err'); return; }
try {
state.members = await api('/admin/api/members/quota', { method: 'POST', body: JSON.stringify({ member_daily_limit: value }) });
renderPage('members');
toast('会员调用额度已保存', 'ok');
} catch (err) { toast(err.message, 'err'); }
}
async function createInvites() {
const count = Number($('inviteCount').value || 1);
try {
const result = await api('/admin/api/invites/create', { method: 'POST', body: JSON.stringify({ count: count }) });
// 完整邀请码只在本次响应里出现,之后列表只给掩码,所以先缓存在内存里供复制。
(result.created || []).forEach((item) => { state.freshCodes[item.code_id] = item.code; });
state.invites = { summary: result.summary, codes: result.codes };
renderPage('members');
toast(`已生成 ${(result.created || []).length} 个邀请码,请立即复制`, 'ok');
} catch (err) { toast(err.message, 'err'); }
}
async function copyInvite(codeId) {
const code = state.freshCodes[codeId];
if (!code) { toast('完整邀请码仅在生成后当次可见,请重新生成', 'err'); return; }
try {
await navigator.clipboard.writeText(code);
toast('邀请码已复制:' + code, 'ok');
} catch {
toast('复制失败,请手动记录:' + code, 'err');
}
}
async function revokeInvite(codeId) {
if (!window.confirm('作废后该邀请码将无法用于注册,确定继续?')) return;
try {
state.invites = await api('/admin/api/invites/revoke', { method: 'POST', body: JSON.stringify({ code_id: codeId }) });
renderPage('members');
toast('邀请码已作废', 'ok');
} catch (err) { toast(err.message, 'err'); }
}
mainEl.addEventListener('click', (e) => {
const mt = e.target.closest('[data-market-toggle]');
if (mt) { toggleMarketRefresh(); return; }
const mr = e.target.closest('[data-market-refresh]');
if (mr) { startMarketRefresh(); return; }
const mb = e.target.closest('[data-market-backfill]');
if (mb) { startMarketBackfill(); return; }
const pb = e.target.closest('[data-probe]');
if (pb) { doProbe(pb.dataset.probe); return; }
const tg = e.target.closest('[data-toggle]');
if (tg) { const ui = srcUiState[tg.dataset.toggle]; if (ui) { ui.open = !ui.open; renderSourceCards(); } return; }
const cs = e.target.closest('[data-cred-save]');
if (cs) { saveCredential(cs.dataset.credSave); return; }
const ct = e.target.closest('[data-cred-test]');
if (ct) { doProbe(ct.dataset.credTest); return; }
if (handleModelClick(e) || handleMemberClick(e)) return;
const goto = e.target.closest('[data-goto-lineage]');
if (goto) { linFilter.q = ''; location.hash = 'lineage'; return; }
const inc = e.target.closest('[data-incident-action]');
if (inc) { openOpsDrawer('release', { dataset: inc.dataset.dataset, provider: inc.dataset.provider }); return; }
if (e.target.closest('#linBad')) {
linFilter.onlyBad = !linFilter.onlyBad;
renderLinRows(window.__linVM);
const btn = $('linBad');
btn.classList.toggle('on', linFilter.onlyBad);
btn.innerHTML = `${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常`;
return;
}
});
mainEl.addEventListener('change', (e) => {
if (e.target.id === 'newVendorPreset' && e.target.value) { $('newVendorUrl').value = e.target.value; return; }
if (e.target.id === 'linSel') { linFilter.src = e.target.value.split('· ')[1] || '全部'; renderLinRows(window.__linVM); } });
mainEl.addEventListener('input', (e) => { if (e.target.id === 'linQ') { linFilter.q = e.target.value; renderLinRows(window.__linVM); } });
mainEl.addEventListener('mouseover', (e) => { const g = e.target.closest('g[data-nid]'); if (g) setLinHover(g.dataset.nid, window.__linVM); });
async function doProbe(id) {
const ui = srcUiState[id];
if (!ui || ui.probing) return;
ui.probing = true;
renderSourceCards();
try {
await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' });
ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: true, ms: null };
} catch (err) {
ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: false, ms: null };
toast(err.message, 'err');
}
ui.probing = false;
await refreshQuiet();
renderSourceCards();
}
/* ---------- EVENT TAPE:预装最近真实事件 + 持续滚动 + 新事件闪动 ---------- */
const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0 };
let tapeBuf = [];
let tapeSeeded = false;
function tapeEventHtml(e, fresh) {
return `<span class="tape-item${fresh ? ' flash' : ''}"><span class="tt num">${esc(hm(e.ts))}</span><span class="ts">${esc(e.src)}</span><span class="tm">${esc(e.msg)}</span>` +
(e.ok ? `<span class="tok num">✓${e.ms ? ` ${e.ms}ms` : ''}</span>` : '<span class="tbad">× 失败</span>') +
`<span class="tsep">///</span></span>`;
}
function renderTape(freshIds) {
const items = tapeBuf.slice(-14);
const html = items.length
? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('')
: `<span class="tape-item"><span class="tm" style="color:var(--dim)">暂无真实事件 · 等待首次调用/任务</span><span class="tsep">///</span></span>`;
$('tapeA').innerHTML = html;
$('tapeB').innerHTML = html;
}
function tapeEventsFromRaw() {
const evs = [];
((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); });
((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.provCall) evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }); });
((RAW.audit && RAW.audit.items) || []).forEach((a) => { if (a.id > tapeSeen.audit) evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }); });
((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { if (r.id > tapeSeen.job) evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }); });
const maxOf = (arr, key) => arr.reduce((m, x) => Math.max(m, x[key] || 0), 0);
tapeSeen.call = Math.max(tapeSeen.call, maxOf((RAW.overview && RAW.overview.recent_calls) || [], 'id'));
tapeSeen.provCall = Math.max(tapeSeen.provCall, maxOf((RAW.providers && RAW.providers.recent_calls) || [], 'id'));
tapeSeen.audit = Math.max(tapeSeen.audit, maxOf((RAW.audit && RAW.audit.items) || [], 'id'));
tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id'));
return evs;
}
function seedTape() {
// 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动)
const evs = [];
((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }));
((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }));
((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }));
((RAW.audit && RAW.audit.items) || []).forEach((a) => evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }));
evs.sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
tapeBuf = evs.slice(-14);
tapeSeeded = true;
renderTape(null);
}
function updateTape() {
if (!tapeSeeded) { seedTape(); return; }
const evs = tapeEventsFromRaw();
if (!evs.length) return;
const fresh = new Set(evs.map((e) => e.sortId));
tapeBuf = tapeBuf.concat(evs).slice(-16);
renderTape(fresh);
}
/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */
let drawerTab = 'jobs';
function openOpsDrawer(tab, ctx) {
drawerTab = tab || 'jobs';
drawerCtx = ctx || {};
$('drawerMask').classList.add('open');
renderDrawer();
}
function closeOpsDrawer() { $('drawerMask').classList.remove('open'); }
let drawerCtx = {};
$('drawerClose').addEventListener('click', closeOpsDrawer);
$('drawerMask').addEventListener('click', (e) => { if (e.target.id === 'drawerMask') closeOpsDrawer(); });
$('opsBtn').addEventListener('click', () => openOpsDrawer('jobs', {}));
$('drawerTabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-dtab]');
if (!b) return;
drawerTab = b.dataset.dtab;
renderDrawer();
});
function renderDrawer() {
$('drawerTabs').querySelectorAll('[data-dtab]').forEach((b) => b.classList.toggle('act', b.dataset.dtab === drawerTab));
$('drawerSub').textContent = { jobs: '既有调度手动触发', release: '盘后发布 / 回滚 / 补数', audit: '操作审计日志' }[drawerTab] || '';
if (drawerTab === 'jobs') renderDrawerJobs();
else if (drawerTab === 'release') renderDrawerRelease();
else renderDrawerAudit();
}
function renderDrawerJobs() {
const jobs = (RAW.jobs && RAW.jobs.jobs) || [];
const runs = (RAW.jobs && RAW.jobs.runs) || [];
const runsByJob = {};
runs.forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); });
$('drawerBody').innerHTML = `
<div class="drawer-section">
<div class="lab">调度任务定义</div>
<table class="dtable"><thead><tr><th>任务</th><th>时刻</th><th>操作</th></tr></thead><tbody>
${jobs.map((j) => `<tr><td style="color:var(--txt-strong)">${esc(j.id)} · ${esc(j.title)}</td><td class="num" style="color:var(--mut)">${esc(j.at)}</td>
<td><input class="lin-q" data-run-date="${esc(j.id)}" placeholder="YYYYMMDD 留空=今天" style="width:150px"><button class="opbtn" data-run="${esc(j.id)}">手动触发</button></td></tr>`).join('')}
</tbody></table>
</div>
<div class="drawer-section">
<div class="lab">最近运行(最新 20 条)</div>
<table class="dtable"><thead><tr><th>ID</th><th>任务</th><th>状态</th><th>开始</th><th>结束</th><th>错误</th></tr></thead><tbody>
${runs.slice(0, 20).map((r) => `<tr><td class="num">${r.id}</td><td>${esc(r.job_id)}</td><td>${pillHtml(bucketOf(r.state))}</td><td class="num" style="font-size:11px">${esc(r.started_at || '')}</td><td class="num" style="font-size:11px">${esc(r.finished_at || '')}</td><td style="color:var(--rd);font-size:11px">${esc(r.error || '')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-hint">暂无运行记录</td></tr>'}
</tbody></table>
</div>`;
$('drawerBody').querySelectorAll('[data-run]').forEach((btn) => {
btn.addEventListener('click', async () => {
const dateInput = $('drawerBody').querySelector(`[data-run-date="${btn.dataset.run}"]`);
try {
await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: 'POST', body: JSON.stringify({ trade_date: (dateInput && dateInput.value) || '' }) });
toast('已触发:' + btn.dataset.run, 'ok');
await refreshQuiet(); renderDrawer();
} catch (err) { toast(err.message, 'err'); }
});
});
}
function renderDrawerRelease() {
const date = drawerCtx.date || today();
$('drawerBody').innerHTML = `
<div class="dtoolbar"><label style="font-size:11px;color:var(--mut)">日期 <input id="relDate" value="${esc(date)}" style="margin-left:6px"></label>
<button class="opbtn" id="relLoad">查看</button>
<button class="opbtn danger" id="relBackfill">补数</button></div>
<div id="relBody"></div>`;
$('relLoad').addEventListener('click', async () => { drawerCtx.date = $('relDate').value; await loadRelease(); });
$('relBackfill').addEventListener('click', () => openDangerModal('backfill', { dataset: drawerCtx.dataset || '', date: drawerCtx.date || date }));
loadRelease();
}
async function loadRelease() {
const date = drawerCtx.date || today();
let data;
try { data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); } catch (err) { toast(err.message, 'err'); return; }
$('relBody').innerHTML = `
<div class="drawer-section"><div class="lab">发布映射</div>
<table class="dtable"><thead><tr><th>数据集</th><th>活跃批次</th><th>上一批次</th><th>状态</th><th>发布时间</th><th>操作</th></tr></thead><tbody>
${data.publications.map((row) => `<tr><td style="color:var(--txt-strong)">${esc(row.dataset)}</td><td class="num" style="font-size:11px">${esc(row.active_batch)}</td><td class="num" style="font-size:11px">${esc(row.prev_batch || '')}</td><td>${pillHtml(bucketOf(row.state))}</td><td class="num" style="font-size:11px">${esc(row.published_at || '')}</td>
<td>${row.prev_batch ? `<button class="opbtn danger" data-rollback="${esc(row.dataset)}">回滚</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-hint">当日暂无发布记录</td></tr>'}
</tbody></table></div>
<div class="drawer-section"><div class="lab">批次</div>
<table class="dtable"><thead><tr><th>batch_id</th><th>数据集</th><th>状态</th><th>行数</th><th>错误</th></tr></thead><tbody>
${data.batches.map((row) => `<tr><td class="num" style="font-size:11px">${esc(row.batch_id)}</td><td>${esc(row.dataset)}</td><td>${pillHtml(bucketOf(row.state))}</td><td class="num">${row.rows_out ?? ''}</td><td style="color:var(--rd);font-size:11px">${esc(row.error || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-hint">当日暂无批次</td></tr>'}
</tbody></table></div>`;
$('relBody').querySelectorAll('[data-rollback]').forEach((btn) => btn.addEventListener('click', () => openDangerModal('rollback', { dataset: btn.dataset.rollback, date })));
}
function renderDrawerAudit() {
const items = (RAW.audit && RAW.audit.items) || [];
$('drawerBody').innerHTML = `<table class="dtable"><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>对象</th><th>详情</th></tr></thead><tbody>
${items.map((row) => `<tr><td class="num" style="font-size:11px">${esc(row.created_at)}</td><td>${esc(row.actor)}</td><td style="color:var(--cy)">${esc(row.action)}</td><td style="font-size:11px">${esc(row.target || '')}</td><td style="font-size:11px;color:var(--mut);max-width:220px;overflow:hidden;text-overflow:ellipsis">${esc(row.detail || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-hint">暂无审计记录</td></tr>'}
</tbody></table>`;
}
/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */
let dangerCtx = null;
function openDangerModal(kind, ctx) {
dangerCtx = { kind, ...ctx };
const word = `${ctx.dataset}:${ctx.date}`;
$('modalTitle').textContent = kind === 'rollback' ? '确认回滚' : '确认补数';
$('modalDesc').textContent = kind === 'rollback'
? `将把 ${ctx.dataset}${ctx.date} 的发布回退到上一批次,需管理密码与确认词。`
: `将对 ${ctx.dataset || '(请先在发布列表选择数据集)'}${ctx.date} 重新补数并发布,需管理密码与确认词。`;
$('modalConfirmWord').textContent = word;
$('modalPassword').value = ''; $('modalConfirm').value = ''; $('modalErr').textContent = '';
$('modalMask').classList.add('open');
}
$('modalCancel').addEventListener('click', () => $('modalMask').classList.remove('open'));
$('modalMask').addEventListener('click', (e) => { if (e.target.id === 'modalMask') $('modalMask').classList.remove('open'); });
$('modalOk').addEventListener('click', async () => {
if (!dangerCtx) return;
const password = $('modalPassword').value;
const confirm = $('modalConfirm').value;
const path = dangerCtx.kind === 'rollback' ? '/admin/api/rollback' : '/admin/api/backfill';
try {
await api(path, { method: 'POST', body: JSON.stringify({ dataset: dangerCtx.dataset, trade_date: dangerCtx.date, password, confirm }) });
$('modalMask').classList.remove('open');
toast('操作成功', 'ok');
await refreshQuiet();
if (state.page === 'overview') renderPage('overview');
if (drawerTab === 'release') loadRelease();
} catch (err) {
$('modalErr').textContent = err.message;
}
});
/* ---------- toast ---------- */
let toastT = null;
function toast(msg, kind) {
const el = $('toastEl');
el.textContent = msg;
el.className = 'toast show' + (kind ? ' ' + kind : '');
clearTimeout(toastT);
toastT = setTimeout(() => { el.classList.remove('show'); }, 3200);
}
/* ================= 顶栏:时钟 / 减少动态 ================= */
let clockEls = null;
function updateClock() {
if (!clockEls) clockEls = { d: $('ckD'), h: $('ckH'), m: $('ckM'), s: $('ckS') };
const now = new Date();
const ds = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;
if (clockEls.d.textContent !== ds) clockEls.d.textContent = ds;
const hs = p2(now.getHours()), ms = p2(now.getMinutes()), ss = p2(now.getSeconds());
if (clockEls.h.textContent !== hs) clockEls.h.textContent = hs;
if (clockEls.m.textContent !== ms) clockEls.m.textContent = ms;
if (clockEls.s.textContent !== ss) clockEls.s.textContent = ss;
}
function applyCalm(on) {
state.calm = on;
// 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则
// .reduce-motion * 选不中旁路层过渡。
document.body.classList.toggle('reduce-motion', on);
const app = $('app');
if (app) app.classList.toggle('reduce-motion', on);
$('calmBtn').textContent = on ? '恢复动态' : '减少动态';
$('calmBtn').classList.toggle('on', on);
}
$('calmBtn').addEventListener('click', () => applyCalm(!state.calm));
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const syncMq = () => { if (mq.matches && !state.calm) applyCalm(true); };
syncMq();
mq.addEventListener ? mq.addEventListener('change', syncMq) : mq.addListener(syncMq);
}
/* ================= 动态更新:200ms tick(打样节奏) =================
- 时钟每秒翻字(冒号 blink 由 CSS 承担)
- 缓存年龄依据真实时间戳每秒增长(>30s 变琥珀)
- 倒计时按真实 next_retry_at 每秒递减
- sparkline 端点闪烁由 CSS 承担,折线只在真实历史变化时重画
- 延迟数字只在真实值变化时闪一下(flash),绝不随机伪造数值 */
function paintSparks() {
document.querySelectorAll('svg.spark').forEach((svg) => sparkPaint(svg));
}
const prevLat = new Map();
function flashChangedLatencies() {
document.querySelectorAll('[data-rate]').forEach((el) => {
const v = el.textContent;
const prev = prevLat.get(el);
if (prev !== undefined && prev !== v) {
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
prevLat.set(el, v);
});
document.querySelectorAll('[data-lat]').forEach((el) => {
const jid = el.closest('[data-jid]') ? el.closest('[data-jid]').dataset.jid : (el.closest('tr') ? 'row:' + [...el.closest('tr').children].indexOf(el) : '');
const v = el.dataset.lat;
const key = state.page + ':' + (el.closest('[data-dataset]')?.dataset.dataset || jid) + ':' + v;
const prev = prevLat.get(el);
if (prev !== undefined && prev !== v) {
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
prevLat.set(el, v);
});
}
function updateDynamics() {
const now = Date.now();
document.querySelectorAll('[data-age-epoch]').forEach((el) => {
const ms = +el.dataset.ageEpoch;
if (!ms || Number.isNaN(ms)) return;
const s = Math.max(0, Math.floor((now - ms) / 1000));
const txt = fmtAgeSec(s);
if (el.textContent !== txt) el.textContent = txt;
const color = s > 30 ? 'var(--amb)' : 'var(--mut)';
if (el.style.color !== color) el.style.color = color;
});
document.querySelectorAll('[data-cd-epoch]').forEach((el) => {
const target = +el.dataset.cdEpoch;
if (!target || Number.isNaN(target)) return;
const left = Math.floor((target - now) / 1000);
if (left > 0) {
const m = Math.floor(left / 60), s = left % 60;
const txt = el.dataset.cdfmt === 'ms' ? `${m}m${p2(s)}s` : `${p2(m)}:${p2(s)}`;
if (el.textContent !== txt) el.textContent = txt;
} else if (el.textContent !== '已到点') {
el.textContent = '已到点';
}
});
}
/* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */
const dataVersion = { overview: '', sources: '', lineage: '' };
function snapshotVersion(part) {
if (part === 'overview') return JSON.stringify([RAW.overview, RAW.batches && RAW.batches.batches, RAW.providers && RAW.providers.health, RAW.catalog]);
if (part === 'sources') return JSON.stringify([RAW.catalog, RAW.providers && RAW.providers.recent_calls, RAW.sources]);
return JSON.stringify(RAW.lineage);
}
const Poller = (() => {
let timer = null;
let running = false;
const INTERVAL = 4000;
async function tick() {
if (!running) return;
try {
await fetchAll();
updateTape();
const vOv = snapshotVersion('overview'), vSrc = snapshotVersion('sources'), vLin = snapshotVersion('lineage');
if (state.page === 'overview' && vOv !== dataVersion.overview) {
dataVersion.overview = vOv;
mainEl.innerHTML = overviewHtml();
paintSparks();
flashChangedLatencies();
} else if (state.page === 'overview') {
flashChangedLatencies();
}
if (state.page === 'sources' && vSrc !== dataVersion.sources) {
dataVersion.sources = vSrc;
renderSourceCards();
}
if (state.page === 'lineage' && vLin !== dataVersion.lineage) {
dataVersion.lineage = vLin;
mainEl.innerHTML = lineageHtml();
const vm = buildLineageVM();
window.__linVM = vm;
renderLinRows(vm);
applyLinHover(vm);
const svg = $('linSvg');
if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM));
fitLineageTable();
}
// 真实调用事件脉冲:血缘页上对应来源节点亮一圈 ledPing 节奏的光环
pulseSourcesOnNewCalls();
updatePhaseTag();
setNetOk(true);
} catch (err) {
setNetOk(false);
}
}
function setNetOk(ok) {
$('liveSpan').style.opacity = ok ? '1' : '.35';
}
function scheduleNext() { timer = setTimeout(async () => { await tick(); scheduleNext(); }, INTERVAL); }
return {
async start() {
if (running) return;
running = true;
await tick();
scheduleNext();
},
stop() { running = false; if (timer) clearTimeout(timer); timer = null; },
isRunning() { return running; },
};
})();
/* 真实调用脉冲:provider_call_log 出现新调用时,给血缘图对应来源节点
叠一圈与 ledPing 同节奏(1.6s ease-out)的扩散光环;这是事件动效,
与 gnode-pulse 的静态呼吸不同,一次即消散。 */
let pulseSeenCall = 0;
function pulseSourcesOnNewCalls() {
if (state.page !== 'lineage') {
pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall);
return;
}
const calls = (RAW.providers && RAW.providers.recent_calls) || [];
const fresh = calls.filter((c) => (c.id || 0) > pulseSeenCall);
pulseSeenCall = calls.reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall);
fresh.forEach((c) => {
const g = document.querySelector(`#linSvg g[data-nid="${CSS.escape(c.provider)}"]`);
if (!g) return;
const circle = g.querySelector('circle');
if (!circle) return;
const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
ring.setAttribute('cx', circle.getAttribute('cx'));
ring.setAttribute('cy', circle.getAttribute('cy'));
ring.setAttribute('r', '5');
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke-width', '1.2');
ring.style.stroke = 'var(--cy)';
ring.setAttribute('class', 'node-ping');
g.appendChild(ring);
setTimeout(() => ring.remove(), 1700);
});
}
function resetTapeBaselines() {
// 恢复轮询时静默重建基线,不补播暂停期间错过的事件(但保留已上带内容)。
tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER;
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) { Poller.stop(); }
else if (!$('appRoot').hidden) { resetTapeBaselines(); Poller.start(); }
});
window.addEventListener('offline', () => { Poller.stop(); $('liveSpan').style.opacity = '.35'; });
window.addEventListener('online', () => { if (!$('appRoot').hidden && !document.hidden) { resetTapeBaselines(); Poller.start(); } });
async function refreshQuiet() {
try { await fetchAll(); } catch {}
}
/* ================= 启动 ================= */
async function startApp() {
await fetchAll();
dataVersion.overview = snapshotVersion('overview');
dataVersion.sources = snapshotVersion('sources');
dataVersion.lineage = snapshotVersion('lineage');
pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), 0);
renderPage(pageFromHash());
updateTape(); // 预装最近真实事件并开始滚动
updateClock();
setInterval(() => { if (document.hidden) return; updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏;页面隐藏时不做任何工作
await Poller.start();
}
boot();