diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index afaf599..ef37e34 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1,12 +1,25 @@ -const state = { csrf: "", page: "overview" }; +'use strict'; +/* ===================================================================== + 小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版 + 三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。 + 视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.md(v12 血缘压缩增补), + 但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口: + 既有 /admin/api/{overview,sources,jobs,batches,datasets,audit,...} + 与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog, + lineage,lineage/affected}。 + ===================================================================== */ +/* ================= 基础:DOM helper / API / CSRF ================= */ function $(id) { return document.getElementById(id); } +function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + +const state = { csrf: '', page: 'overview', calm: false, username: '' }; 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(); + 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; throw new Error(msg); @@ -14,286 +27,1071 @@ async function api(path, options = {}) { return body; } -function show(id) { - ["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; }); -} - -function esc(value) { - return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); -} - -function table(headers, rows) { - const thead = headers.map((h) => `${esc(h)}`).join(""); - const body = rows.length - ? rows.map((cols) => `${cols.map((c) => `${c}`).join("")}`).join("") - : `暂无数据`; - return `${thead}${body}
`; +/* ================= 认证:登录 / 改密 / 登出 ================= */ +function showView(id) { + ['login-view', 'change-view'].forEach((k) => { $(k).hidden = k !== id; }); + $('appRoot').hidden = id !== 'shell'; } async function boot() { try { - const session = await api("/admin/api/session"); + const session = await api('/admin/api/session'); state.csrf = session.csrf; - $("who").textContent = session.username; - if (session.must_change) { show("change-view"); return; } - show("shell"); - await render(); + state.username = session.username; + $('who').textContent = session.username; + if (session.must_change) { showView('change-view'); return; } + showView('shell'); + await startApp(); } catch { - show("login-view"); + showView('login-view'); } } -$("login-form").addEventListener("submit", async (event) => { +$('login-form').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); - $("login-error").hidden = true; + $('login-error').hidden = true; try { - const result = await api("/admin/api/login", { - method: "POST", - body: JSON.stringify({ username: form.get("username"), password: form.get("password") }), + const result = await api('/admin/api/login', { + method: 'POST', + body: JSON.stringify({ username: form.get('username'), password: form.get('password') }), }); state.csrf = result.csrf; - if (result.must_change) show("change-view"); - else { show("shell"); await render(); } + if (result.must_change) { showView('change-view'); return; } + const session = await api('/admin/api/session'); + state.username = session.username; + $('who').textContent = session.username; + showView('shell'); + await startApp(); } catch (err) { - $("login-error").hidden = false; - $("login-error").textContent = err.message; + $('login-error').hidden = false; + $('login-error').textContent = err.message; } }); -$("change-form").addEventListener("submit", async (event) => { +$('change-form').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); try { - await api("/admin/api/change-password", { - method: "POST", - body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }), + await api('/admin/api/change-password', { + method: 'POST', + body: JSON.stringify({ current: form.get('current'), new_password: form.get('new_password') }), }); - show("shell"); - await render(); + const session = await api('/admin/api/session'); + state.username = session.username; + $('who').textContent = session.username; + showView('shell'); + await startApp(); } catch (err) { - $("change-error").hidden = false; - $("change-error").textContent = err.message; + $('change-error').hidden = false; + $('change-error').textContent = err.message; } }); -$("logout-btn").addEventListener("click", async () => { - await api("/admin/api/logout", { method: "POST", body: "{}" }); - show("login-view"); +$('logout-btn').addEventListener('click', async () => { + try { await api('/admin/api/logout', { method: 'POST', body: '{}' }); } catch {} + Poller.stop(); + showView('login-view'); }); -$("theme-btn").addEventListener("click", () => { - const root = document.documentElement; - const next = root.getAttribute("data-theme") === "night" ? "" : "night"; - if (next) root.setAttribute("data-theme", next); - else root.removeAttribute("data-theme"); - $("theme-btn").textContent = next ? "日间" : "夜间"; -}); - -document.querySelectorAll("nav button").forEach((btn) => { - btn.addEventListener("click", () => { - document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active")); - btn.classList.add("active"); - state.page = btn.dataset.page; - render(); - }); -}); - -async function render() { - const page = $("page"); - if (state.page === "overview") { - const data = await api("/admin/api/overview"); - $("phase").textContent = data.session_phase; - const eod = data.eod_status || {}; - const rev = data.revision_status || {}; - const eodLabels = { - pending_first_attempt: "等待首次尝试", - waiting_upstream: "等待上游", - done: "已成功", - cutoff_failed: "已截止失败", - closed_day: "休市", - }; - const revLabels = { - waiting_review: "等待复核", - review_failed: "复核失败", - aligned: "已追平", - cutoff: "已截止", - pending_publish: "待发布", - closed_day: "休市", - }; - const eodExtra = []; - if (eod.state === "waiting_upstream") { - eodExtra.push(`已试 ${eod.attempts} 次`); - if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(String(eod.next_retry_at).replace("T", " ").slice(11, 16))}`); - if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`); - } - if (eod.state === "cutoff_failed" && eod.missing_datasets) { - eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`); - } - const revExtra = []; - if (rev.detail) revExtra.push(esc(String(rev.detail))); - if (rev.window) revExtra.push(esc(String(rev.window))); - page.innerHTML = ` -
-
交易日
${esc(data.trade_date)}
-
阶段
${esc(data.session_phase)}
-
今日发布
${data.publications.length}
-
盘后补跑
${esc(eodLabels[eod.state] || eod.state || "-")}
${eodExtra.join(" · ")}
-
估值复核
${esc(revLabels[rev.state] || rev.state || "-")}
${revExtra.join(" · ")}
-
异常批次
${data.anomalies.length}
-
-

最近调用

- ${table(["时间", "源", "端点", "结果", "耗时"], data.recent_calls.map((row) => [ - esc(row.created_at), esc(row.provider), esc(row.endpoint), - row.ok ? '成功' : `${esc(row.error)}`, - `${row.latency_ms ?? "-"} ms`, - ]))} - `; - return; - } - if (state.page === "sources") { - const data = await api("/admin/api/sources"); - page.innerHTML = `

数据源

` + table( - ["源", "角色", "状态", "凭据", "操作"], - data.items.map((item) => { - const cred = item.credential || {}; - const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置"; - return [ - esc(item.provider), - esc(item.role), - esc((item.health && (item.health.state || item.health.status)) || "-"), - credText, - ``, - ]; - }), - ); - page.querySelectorAll("[data-probe]").forEach((btn) => { - btn.addEventListener("click", async () => { - const result = await api(`/admin/api/sources/${btn.dataset.probe}/probe`, { method: "POST", body: "{}" }); - alert(JSON.stringify(result)); - render(); - }); - }); - return; - } - if (state.page === "jobs") { - const data = await api("/admin/api/jobs"); - page.innerHTML = ` -

调度任务

- ${table(["任务", "时刻", "操作"], data.jobs.map((job) => [ - `${esc(job.id)} · ${esc(job.title)}`, esc(job.at), - ``, - ]))} -

最近运行

- ${table(["ID", "任务", "状态", "开始", "结束", "错误"], data.runs.map((row) => [ - row.id, esc(row.job_id), esc(row.state), esc(row.started_at), esc(row.finished_at), esc(row.error), - ]))} - `; - page.querySelectorAll("[data-run]").forEach((btn) => { - btn.addEventListener("click", async () => { - const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || ""; - await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) }); - render(); - }); - }); - return; - } - if (state.page === "release") { - const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); - const data = await api(`/admin/api/batches?date=${date}`); - page.innerHTML = ` -

盘后发布 ${esc(data.trade_date)}

-
- - - -
-

当前映射

- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), - row.prev_batch ? `` : "-", - ]))} -

批次

- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ - esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), - ]))} - `; - $bindRelease(page); - return; - } - if (state.page === "datasets") { - const data = await api("/admin/api/datasets?date="); - page.innerHTML = ` -

数据集 / 质量 ${esc(data.trade_date)}

- ${table(["数据集", "批次", "状态", "发布时间"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at), - ]))} -

源间差异

- ${table(["指标", "左", "右", "偏差", "样本"], data.diff_reports.map((row) => [ - esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "", - ]))} - `; - return; - } - if (state.page === "audit") { - const data = await api("/admin/api/audit"); - page.innerHTML = `

审计

` + table( - ["时间", "操作者", "动作", "对象", "详情"], - data.items.map((row) => [esc(row.created_at), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail)]), - ); - } +/* ================= 状态归类(真实状态字符串 → 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: '#34d399' }, + slow: { label: '慢', led: 'slow', color: '#fbbf24' }, + fail: { label: '失败', led: 'fail', color: '#f87171' }, + plan: { label: '计划', led: 'plan', color: '#8b9bb4' }, + review: { label: '复核中', led: 'rev', color: '#22d3ee' }, + degrade: { label: '降级', led: 'slow', color: '#fbbf24' }, + off: { label: '未配置', led: 'off', color: '#54637e' }, +}; +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'; } -function $bindRelease(page) { - page.querySelector("#rel-load").addEventListener("click", async () => { - const date = page.querySelector("#rel-date").value; - const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); - state.page = "release"; - // re-render with fetched date by writing location hash - history.replaceState(null, "", `#release-${date}`); - $("page").innerHTML = renderRelease(data); - $bindRelease($("page")); - }); - page.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill")); - page.querySelectorAll("[data-rollback]").forEach((btn) => { - btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback)); - }); +/* ================= 渲染小工具(结构照抄 hub-kimi.html) ================= */ +function ledHtml(st, pulse) { return ``; } +function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}`; } +function tagHtml(tone, inner) { return `${inner}`; } +function panelHtml(title, right, body, pad) { + return `
//${title}${right ? `${right}` : ''}
${body}
`; +} +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 `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; +} +function radarBlipsHtml(pulseOk) { + return pulseOk + ? `
` + : ''; +} +function latHtml(ms, warn) { + if (ms == null) return ''; + const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0'); + return `${Number(ms).toLocaleString('en-US')}ms`; +} +function ageFromIso(iso) { + if (!iso) return null; + const t = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T')); + if (Number.isNaN(t)) return null; + return Math.max(0, Math.floor((Date.now() - t) / 1000)); +} +function ageHtml(iso) { + const s = ageFromIso(iso); + if (s == null) return ''; + const txt = s < 90 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`; + return `${txt}`; +} +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); +} +const p2 = (x) => String(x).padStart(2, '0'); + +/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */ +const RAW = { + overview: null, sources: null, providers: null, catalog: null, + lineage: null, jobs: null, batches: null, datasets: null, audit: null, +}; +async function fetchAll() { + const [overview, sources, providers, catalog, lineage, jobs, batches, datasets, 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/datasets?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.datasets = datasets; RAW.audit = audit; +} +function today() { return RAW.overview ? RAW.overview.trade_date : ''; } + +/* 按 provider(+interface) 索引 provider_health,供各页复用 */ +function healthIndex() { + const idx = {}; + const rows = (RAW.providers && RAW.providers.health) || []; + for (const row of rows) { + idx[row.provider] = idx[row.provider] || []; + idx[row.provider].push(row); + } + return idx; +} +function callsIndex() { + const idx = {}; + const rows = (RAW.providers && RAW.providers.recent_calls) || []; + for (const row of rows) { (idx[row.provider] = idx[row.provider] || []).push(row); } + return idx; } -function renderRelease(data) { - return ` -

盘后发布 ${esc(data.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: '股票主档', +}; + +/* ===================================================================== + 总览页 + ===================================================================== */ +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 hidx = healthIndex(); + 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 数据集 + let fallbackPages = 0; + (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') fallbackPages += 1; + }); + + 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 的真实 provider_health 行 + const catalogByProv = {}; + (RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; }); + const observers = []; + ['eastmoney', 'tencent'].forEach((prov) => { + const rows = hidx[prov] || []; + const cat = catalogByProv[prov]; + rows.forEach((row) => { + const iface = (cat && cat.interfaces.find((i) => i.interface === row.interface)) || {}; + observers.push({ + name: iface.capability || row.interface, + provider: prov, + interface: row.interface, + lastOk: row.last_ok_at, + lat: row.last_latency_ms, + bucket: bucketOf(row.state), + note: row.last_error || row.last_fallback_reason || '', + }); + }); + }); + + // 主站影响:lineage() 条目按 known_consumers 展开 + const siteImpact = []; + (RAW.lineage && RAW.lineage.items || []).forEach((it) => { + const rows = it.live_provider_health || []; + const bucket = it.tier === 'official' + ? (it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan')) + : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off'); + const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || ''; + (it.known_consumers || []).forEach((c) => { + siteImpact.push({ page: c, dataset: it.dataset, bucket, note }); + }); + }); + 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 }; + }); + + // 待处理异常:批次失败/停滞 + 主源连续失败 + 未配置授权源 + 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: `已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '' }); + } + 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, + }; +} + +function heroHtml(vm) { + const anomalyTxt = vm.anomalyCount ? `${vm.anomalyCount} 项异常` : `无异常`; + const tags = []; + if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `等待上游 · 已试 ${vm.eod.attempts || 0} 次`)); + if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布')); + const subParts = []; + if (vm.eod.attempts) subParts.push(`已试 ${vm.eod.attempts} 次`); + if (vm.eod.next_retry_at) subParts.push(`下次重试 ${hm(vm.eod.next_retry_at)}`); + if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 ${esc(vm.eod.missing_datasets.join(','))}`); + if (vm.rev.window) subParts.push(`复核窗口 ${esc(vm.rev.window)}`); + return `
` + + `
` + + `
${vm.anomalyCount ? '部分异常' : '整体可用'}·${anomalyTxt}
` + + `
${tags.join('')}
` + + (subParts.length ? `
${subParts.join('|')}
` : '') + + `
` + + `
` + + kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, null) + + kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + + kpiHtml('主站回退页面', `${vm.fallbackPages} 页`, null, vm.fallbackPages ? 'amb' : 'txt') + + kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') + + `
`; +} +function datasetStripHtml(vm) { + return `
${vm.datasetCards.map((d) => { + const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : ''; + let note = ''; + if (d.time) note += `${esc(d.time)} · `; + if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`; + if (d.err) note += `${esc(d.err)}`; + if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; + return `
` + + `
${esc(d.name)}${pillHtml(d.bucket)}
` + + `
${note}
` + + `
`; + }).join('')}
`; +} +function observatoryHtml(vm) { + const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' }; + const right = `${Object.keys(legendByProv).map((p) => `${ledHtml('ok')}${legendByProv[p]}`).join('')}`; + const rows = vm.observers.length ? vm.observers.map((o) => `` + + `${esc(o.name)}` + + `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}` + + `${ageHtml(o.lastOk)}` + + `${latHtml(o.lat, o.bucket !== 'ok')}` + + `${pillHtml(o.bucket)}${o.note ? `${esc(o.note)}` : ''}` + + ``).join('') : `尚无真实观测记录(等待首次探测/调用)`; + const body = `${rows}
观察项当前来源最近成功延迟状态 · 备注
`; + return panelHtml('实时观察层', right, body, false); +} +function siteImpactHtml(vm) { + const right = `${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`; + const rows = vm.siteImpact.length ? vm.siteImpact.map((s) => `` + + `${esc(s.page)}` + + `${pillHtml(s.bucket)}` + + `${s.note ? esc(s.note) : '—'}` + + `` + + ``).join('') : `尚无血缘记录`; + return panelHtml('主站影响', right, `${rows}
`, false); +} +function timelineHtml(vm) { + const nodes = vm.timeline; + if (!nodes.length) return panelHtml('今晚时间线', '', '
暂无可展示的调度任务
', 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 = `` + + `` + + `` + + ``; + 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' ? '#34d399' : n.state === 'fail' ? '#f87171' : '#54637e'; + const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle'; + s += ``; + if (n.state === 'fail') s += `×`; + else s += ``; + s += `${esc(n.t)}` + + `${esc(n.label)}`; + }); + s += `` + + `` + + `NOW`; + const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · SCHEDULER.PY`; + return panelHtml('今晚时间线', right, s, false); +} +function incidentsHtml(vm) { + if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '
当前无待处理异常
', 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)); + return `
${tag}` + + `
` + + `
${esc(it.title)}
` + + `
${esc(it.meta)}
` + + `
` + + `` + + `
`; + }).join(''); + return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `
${cards}
`, true); +} +function overviewHtml() { + const vm = buildOverviewVM(); + return `
${heroHtml(vm)}${datasetStripHtml(vm)}` + + `
${observatoryHtml(vm)}${siteImpactHtml(vm)}
` + + `
${timelineHtml(vm)}${incidentsHtml(vm)}
`; +} + +/* ===================================================================== + 数据源配置页 + ===================================================================== */ +const srcUiState = {}; +function buildSourcesVM() { + const catalog = (RAW.catalog && RAW.catalog.items) || []; + const legacy = {}; + (RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; }); + const hidx = healthIndex(); + const cidx = callsIndex(); + 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); + const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => (hidx[c.provider] || []).some((h) => h.interface === i.interface)).length, 0); + const cards = active.map((c) => { + const leg = legacy[c.provider] || {}; + const legState = (leg.health && (leg.health.state || leg.health.status)) || null; + const rows = hidx[c.provider] || []; + const worst = rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : (legState ? bucketOf(legState) : 'off'); + const cred = c.credential || {}; + const lastCall = (cidx[c.provider] || [])[0]; + if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null }; + const ifaceRows = c.interfaces.map((i) => { + const h = rows.find((r) => r.interface === i.interface); + return { + api: i.interface, use: i.capability, + ok: h ? (h.last_ok_at ? hm(h.last_ok_at) : '—') : '—', + lat: h ? h.last_latency_ms : null, + st: h ? bucketOf(h.state) : 'off', + err: h && h.state !== 'ok' ? (h.last_error || h.last_fallback_reason || '') : '', + }; + }); + return { + id: c.provider, name: c.label, role: c.role, tag: c.credential_key ? 'licensed' : 'free', + bucket: worst, credential: cred, usedCount: ifaceRows.filter((r) => r.st !== 'off').length, totalCount: ifaceRows.length, + lastProbe: lastCall ? `${hm(lastCall.created_at)} · ${lastCall.ok ? `✓ ${lastCall.latency_ms}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', + latency: rows.length ? (rows.map((r) => r.last_latency_ms).filter((v) => v != null)[0] || null) : null, + error: rows.find((r) => r.last_error)?.last_error || '', + ifaceRows, + }; + }); + 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:#243152' : ''; + const ledSt = HEALTH[card.bucket].led; + // source_catalog.py:credential_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, card.role]; + let h = `
`; + h += `
` + + ledHtml(ledSt, card.bucket === 'fail') + + `${esc(card.name)}` + + `${esc(card.tag)}` + + pillHtml(card.bucket) + + `${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}` + + `` + + `` + + `` + + `
`; + h += `
` + + `接口 ${card.usedCount}/${card.totalCount} 在用` + + `|最后探测 ${esc(card.lastProbe)}` + + `|延迟 ${latHtml(card.latency)}` + + (card.error ? `|最近错误 ${esc(card.error)}` : `|无最近错误`) + + `
`; + if (ui.open && card.ifaceRows.length) { + h += `
` + + `` + + card.ifaceRows.map((it) => `` + + `` + + `` + + `` + + `` + + `` + + ``).join('') + `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st)}${it.err ? `${esc(it.err)}` : ''}
`; + } else if (!card.ifaceRows.length) { + h += `
该来源尚无已登记接口
`; + } + return h + `
`; +} +function sourcesHtml() { + const vm = buildSourcesVM(); + const alerts = []; + vm.cards.forEach((c) => { + if (c.bucket === 'fail') alerts.push(`${ledHtml('fail', true)}${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}`); + else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`${ledHtml('slow')}${esc(c.name)} 降级/延迟`); + }); + return `
` + + `
` + + `数据源 ${vm.cards.length} 路 · 接口 ${vm.usedIfaces}/${vm.totalIfaces} 在用` + + alerts.join('') + + `` + + `凭证安全存储 · 探测只读 · 不影响盘中链路` + + `
` + + `
${vm.cards.map(sourceCardHtml).join('')}
` + + `
${vm.reserved.map((r) => `
${ledHtml('off')}${esc(r.label)}${tagHtml('dashed', '预留位 · 未接入')}
`).join('')}
` + + `
`; +} +function renderSourceCards() { + const el = $('srcCards'); + if (!el) return; + const vm = buildSourcesVM(); + el.innerHTML = vm.cards.map(sourceCardHtml).join(''); +} + +/* ===================================================================== + 数据血缘页 + ===================================================================== */ +function pageNameOf(consumer) { + const idx = consumer.indexOf('('); + return (idx > 0 ? consumer.slice(0, idx) : consumer).trim(); +} +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 pageDatasets = {}; + const edgesSD = []; + const edgesDP = []; + const rows = []; + items.forEach((it) => { + const rowsHealth = it.live_provider_health || []; + let bucket; + if (it.tier === 'official') { + bucket = it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'plan'); + } else { + bucket = rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'off'; + } + 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]); + (pageDatasets[pg] = pageDatasets[pg] || []).push(it); + }); + const lat = rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null); + const ok = it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at; + 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' ? '主(授权)' : '主(无备)'), + ok: ok ? hm(ok) : '—', lat, st: bucket, + note: rowsHealth.find((r) => r.last_error)?.last_error || rowsHealth.find((r) => r.last_fallback_reason)?.last_fallback_reason || (it.publication && it.publication.state !== 'published' ? '未正式发布' : ''), + }); + }); + 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 = ``; + const colStyle = 'font-size="10" letter-spacing="2" fill="#54637e"'; + if (vm.sources.length) h += `来源 · ${vm.sources.length}`; + if (vm.datasets.length) h += `数据集 · ${vm.datasets.length}`; + if (vm.pages.length) h += `主站页面 · ${vm.pages.length}`; + const dsBy = {}; vm.datasets.forEach((d) => { dsBy[d.id] = d.st; }); + vm.edgesSD.forEach(([s, d]) => { + if (!gpos['s:' + s] || !gpos['d:' + d]) return; + h += ``; + }); + vm.edgesDP.forEach(([d, p]) => { + if (!gpos['d:' + d] || !gpos['p:' + p]) return; + h += ``; + }); + vm.sources.forEach((s) => { + const p = gpos['s:' + s.id]; if (!p) return; + h += `` + + `` + + `` + + `${esc(s.name)}`; + }); + vm.datasets.forEach((d) => { + const p = gpos['d:' + d.id]; if (!p) return; + h += `` + + `` + + `${esc(d.id)}`; + }); + vm.pages.forEach((pg) => { + const p = gpos['p:' + pg.id]; if (!p) return; + h += `` + + `` + + `${esc(pg.name)}`; + }); + h += `
悬停节点 · 追踪上下游链路
`; + 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.setAttribute('fill', act && act.has(id) ? '#e8f1ff' : '#8b9bb4'); } + }); + 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' : ''; + body.innerHTML = rows.map((r) => `` + + `${esc(r.page)}` + + `${esc(r.item)}` + + `${esc(r.dataset)}` + + `${esc(r.via)}` + + `${esc(r.role)}` + + `${esc(r.ok)}` + + `${latHtml(r.lat)}` + + `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; + if (bodyN) bodyN.innerHTML = rows.map((r) => `` + + `${esc(r.page)}${esc(r.item)}` + + `${esc(r.dataset)}` + + `${esc(r.via)}` + + `${esc(r.ok)}${r.lat != null ? r.lat + 'ms' : '—'}` + + `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; +} +function lineageHtml() { + const vm = buildLineageVM(); + const anomalyPages = new Set(); + vm.rows.forEach((r) => { if (r.st === 'fail') (r.page || '').split(' / ').forEach((p) => anomalyPages.add(p)); }); + const html = `
` + + `
` + + `血缘地图` + + `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + + `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + + `` + + (anomalyPages.size ? tagHtml('rd', `${[...anomalyPages][0]} 断链影响 ${anomalyPages.size} 页`) : tagHtml('mint', '暂无断链影响')) + + `
` + + `
${lineageGraphHtml(vm)}
` + + `
` + + `
` + + `` + + `` + + `` + + `` + + `` + + `
` + + `
主站页面数据项数据集主/备最近成功延迟状态
` + + `
主站页面数据集最近成功状态
` + + `
` + + `
`; + window.__linVM = vm; + return html; +} + +/* ===================================================================== + 页面路由 / 顶栏 / EVENT TAPE / 抽屉 / 危险操作弹层 + ===================================================================== */ +const mainEl = $('mainEl'); +function pageFromHash() { + const h = location.hash.replace('#', ''); + return ['overview', 'sources', '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(); + else if (p === 'sources') mainEl.innerHTML = sourcesHtml(); + else { + mainEl.innerHTML = lineageHtml(); + renderLinRows(window.__linVM); + applyLinHover(window.__linVM); + const svg = $('linSvg'); + if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); + fitLineageTable(); + } + 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: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条) + 动态撑高;宽档(>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; + $('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`; +} + +mainEl.addEventListener('click', (e) => { + 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 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 === '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: '{}' }); } catch (err) { toast(err.message, 'err'); } + ui.probing = false; + await refreshQuiet(); + renderSourceCards(); +} + +/* ---------- EVENT TAPE:真实事件差量渲染 ---------- */ +const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0, first: true }; +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; +} +let tapeBuf = []; +function updateTape() { + const evs = tapeEventsFromRaw(); + if (tapeSeen.first) { tapeSeen.first = false; return; } // 首次加载只建基线,不补播历史事件 + if (!evs.length) return; + tapeBuf = tapeBuf.concat(evs).slice(-16); + const html = tapeBuf.map((e) => `${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` + + (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') + + `///`).join(''); + $('tapeA').innerHTML = html; $('tapeB').innerHTML = html; +} + +/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */ +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 = ` +
+
调度任务定义
+ + ${jobs.map((j) => ` + `).join('')} +
任务时刻操作
${esc(j.id)} · ${esc(j.title)}${esc(j.at)}
-

当前映射

- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), - row.prev_batch ? `` : "-", - ]))} -

批次

- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ - esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), - ]))} - `; +
+
最近运行(最新 20 条)
+ + ${runs.slice(0, 20).map((r) => ``).join('') || ''} +
ID任务状态开始结束错误
${r.id}${esc(r.job_id)}${pillHtml(bucketOf(r.state))}${esc(r.started_at || '')}${esc(r.finished_at || '')}${esc(r.error || '')}
暂无运行记录
+
`; + $('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 = ` +
+ +
+
`; + $('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 = ` +
发布映射
+ + ${data.publications.map((row) => ` + `).join('') || ''} +
数据集活跃批次上一批次状态发布时间操作
${esc(row.dataset)}${esc(row.active_batch)}${esc(row.prev_batch || '')}${pillHtml(bucketOf(row.state))}${esc(row.published_at || '')}${row.prev_batch ? `` : '—'}
当日暂无发布记录
+
批次
+ + ${data.batches.map((row) => ``).join('') || ''} +
batch_id数据集状态行数错误
${esc(row.batch_id)}${esc(row.dataset)}${pillHtml(bucketOf(row.state))}${row.rows_out ?? ''}${esc(row.error || '')}
当日暂无批次
`; + $('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 = ` + ${items.map((row) => ``).join('') || ''} +
时间操作者动作对象详情
${esc(row.created_at)}${esc(row.actor)}${esc(row.action)}${esc(row.target || '')}${esc(row.detail || '')}
暂无审计记录
`; } -async function dangerous(kind, dataset) { - const date = ($("rel-date") && $("rel-date").value) || ""; - const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference)", "daily"); - if (!ds) return; - const password = prompt("二次确认:输入管理密码"); - if (!password) return; - const confirmWord = `${ds}:${date}`; - const typed = prompt(`请输入确认词:${confirmWord}`); - const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill"; - await api(path, { - method: "POST", - body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }), - }); - render(); +/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */ +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); +} + +/* ================= 顶栏:时钟 / 减少动态 / 主题 ================= */ +function updateClock() { + const now = new Date(); + $('ckD').textContent = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`; + $('ckH').textContent = p2(now.getHours()); + $('ckM').textContent = p2(now.getMinutes()); + $('ckS').textContent = p2(now.getSeconds()); +} +function applyCalm(on) { + state.calm = on; + $('appRoot').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); +} + +/* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */ +const Poller = (() => { + let timer = null; + let running = false; + const INTERVAL = 4000; + async function tick() { + if (!running) return; + try { + await fetchAll(); + updateTape(); + if (state.page === 'overview') mainEl.innerHTML = overviewHtml(); + else if (state.page === 'sources') renderSourceCards(); + else if (state.page === 'lineage') { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl.innerHTML = lineageHtml(); renderLinRows(window.__linVM); applyLinHover(window.__linVM); fitLineageTable(); } + 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; }, + }; +})(); + +function resetTapeBaselines() { + // 恢复轮询时静默重建基线,不补播暂停期间错过的事件。 + tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER; + tapeSeen.first = true; +} +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() { + setInterval(updateClock, 1000); + updateClock(); + await fetchAll(); + tapeSeen.first = true; + renderPage(pageFromHash()); + await Poller.start(); } boot(); diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html index b7eb893..a9493c2 100644 --- a/xiaobai-datahub/admin/index.html +++ b/xiaobai-datahub/admin/index.html @@ -1,16 +1,18 @@ - - - xiaobai-datahub 管理后台 - + + +小白复盘 · 数据中枢 + -
-
+
+ + -
+ diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css index 1631ac1..df7ab2d 100644 --- a/xiaobai-datahub/admin/styles.css +++ b/xiaobai-datahub/admin/styles.css @@ -1,51 +1,527 @@ +/* JetBrains Mono (variable, latin subset) — 内嵌,零外部依赖 */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url(data:font/woff2;base64,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) format('woff2'); +} + +/* ================= index.css 逐行照抄(去掉 tailwind 指令) ================= */ :root { - color-scheme: light; - --bg: #f4f5f7; - --surface: #ffffff; - --text: #1f2329; - --muted: #646a73; - --line: #dee0e3; - --action: #3370ff; - --danger: #e04536; - --ok: #16a34a; - --warn: #b45309; - --radius: 8px; - --pad: 16px; - font-family: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif; + --ink: #050810; + --panel: #0c1220; + --panel2: #111a2b; + --line: #1a2540; + --line2: #243152; + --cy: #22d3ee; + --mint: #34d399; + --amb: #fbbf24; + --rd: #f87171; + --txt: #d7e1f0; + --mut: #8b9bb4; + --dim: #54637e; } -:root[data-theme="night"] { - color-scheme: dark; - --bg: #111318; - --surface: #1b1e24; - --text: #e8eaed; - --muted: #9aa0a6; - --line: #2a2f38; - --action: #5b8cff; + +html, body, #root { height: 100%; } +html, body { margin: 0; } + +/* HEL-529 生产实现补充:确保原生 [hidden] 属性不被下方任何 display 规则 + (如 .auth-wrap/.app 等 flex/relative 布局)覆盖——两者选择器优先级相同时 + 源码顺序在后的规则会赢,必须显式声明 !important 兜底。 */ +[hidden] { display: none !important; } + +body { + background: var(--ink); + color: var(--txt); + font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", monospace; + font-size: 13px; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* ---------- ambient background: radial depth + grid + scanlines ---------- */ +.bg-ambient { + background: + radial-gradient(1200px 500px at 70% -10%, rgba(34, 211, 238, 0.055), transparent 60%), + radial-gradient(900px 420px at 10% 110%, rgba(52, 211, 153, 0.04), transparent 60%), + radial-gradient(700px 500px at 100% 100%, rgba(248, 113, 113, 0.03), transparent 60%), + var(--ink); +} +.bg-gridlines { + background-image: + linear-gradient(rgba(26, 37, 64, 0.35) 1px, transparent 1px), + linear-gradient(90deg, rgba(26, 37, 64, 0.35) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%); + -webkit-mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%); +} +.scanlines { + background: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.022) 0px, + rgba(255, 255, 255, 0.022) 1px, + transparent 1px, + transparent 3px + ); +} + +/* ---------- typography helpers ---------- */ +.lab { + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--dim); + font-weight: 500; +} +.num { font-variant-numeric: tabular-nums; } + +/* ---------- panels ---------- */ +.panel { + background: linear-gradient(180deg, rgba(17, 26, 43, 0.6), rgba(12, 18, 32, 0.9)); + border: 1px solid var(--line); + border-radius: 6px; + position: relative; +} +.panel-hd { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + font-size: 11px; + letter-spacing: 0.14em; + color: var(--mut); +} +.panel-hd b { color: var(--txt); font-weight: 600; } + +/* ---------- glow status LED ---------- */ +.led { + width: 7px; height: 7px; border-radius: 9999px; + display: inline-block; flex: none; + position: relative; +} +.led.ok { background: var(--mint); box-shadow: 0 0 6px 1px rgba(52,211,153,.7); } +.led.slow { background: var(--amb); box-shadow: 0 0 6px 1px rgba(251,191,36,.7); } +.led.fail { background: var(--rd); box-shadow: 0 0 6px 1px rgba(248,113,113,.8); } +.led.rev { background: var(--cy); box-shadow: 0 0 6px 1px rgba(34,211,238,.7); } +.led.plan { background: #475569; box-shadow: none; } +.led.off { background: transparent; border: 1px dashed #475569; box-shadow: none; } +.led.pulse::after { + content: ""; position: absolute; inset: -4px; border-radius: 9999px; + border: 1px solid currentColor; opacity: .6; + animation: ledPing 1.6s ease-out infinite; +} +.led.ok.pulse { color: var(--mint); } +.led.fail.pulse { color: var(--rd); } +.led.rev.pulse { color: var(--cy); } +.led.slow.pulse { color: var(--amb); } +@keyframes ledPing { + 0% { transform: scale(.4); opacity: .7; } + 80%, 100% { transform: scale(1.4); opacity: 0; } +} + +/* ---------- text glow ---------- */ +.glow-cy { color: var(--cy); text-shadow: 0 0 12px rgba(34,211,238,.45); } +.glow-mint { color: var(--mint); text-shadow: 0 0 12px rgba(52,211,153,.45); } +.glow-amb { color: var(--amb); text-shadow: 0 0 12px rgba(251,191,36,.45); } +.glow-rd { color: var(--rd); text-shadow: 0 0 12px rgba(248,113,113,.5); } + +/* ---------- blinking / pulsing ---------- */ +.blink { animation: blink 1.1s steps(2, start) infinite; } +@keyframes blink { 50% { opacity: 0; } } +.pulse-dot { animation: pulseDot 1.5s ease-in-out infinite; } +@keyframes pulseDot { 0%,100% { opacity: 1; } 50% { opacity: .35; } } + +/* ---------- flash on value change ---------- */ +.flash { animation: flash .45s ease-out; } +@keyframes flash { + 0% { background: rgba(34, 211, 238, 0.22); color: #e8fbff; } + 100% { background: transparent; } +} + +/* ---------- ticker marquee ---------- */ +.marquee-track { + display: inline-flex; + white-space: nowrap; + animation: marquee 46s linear infinite; + will-change: transform; +} +.marquee-track:hover { animation-play-state: paused; } +@keyframes marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +/* ---------- flow lines (lineage / timeline) ---------- */ +.flowline { + stroke-dasharray: 4 6; + animation: flowDash 1.4s linear infinite; +} +@keyframes flowDash { to { stroke-dashoffset: -10; } } + +/* ---------- radar sweep ---------- */ +.radar-sweep { + background: conic-gradient(from 0deg, rgba(34,211,238,.55), transparent 22%, transparent); + animation: sweep 4.2s linear infinite; + border-radius: 9999px; +} +@keyframes sweep { to { transform: rotate(360deg); } } + +/* ---------- buttons ---------- */ +.tbtn { + font-size: 11px; letter-spacing: .1em; + border: 1px solid var(--line2); + color: var(--mut); + padding: 4px 12px; border-radius: 4px; + background: rgba(17,26,43,.5); + transition: all .15s ease; + cursor: pointer; + font-family: inherit; +} +.tbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); box-shadow: 0 0 10px rgba(34,211,238,.15); } +.tbtn:active { transform: translateY(1px); } +.tbtn:disabled { opacity: .45; cursor: default; } +.tbtn.on { color: var(--cy); border-color: rgba(34,211,238,.6); background: rgba(34,211,238,.08); } + +/* ---------- table ---------- */ +.dtable { width: 100%; border-collapse: collapse; } +.dtable th { + font-size: 10px; letter-spacing: .16em; text-transform: uppercase; + color: var(--dim); font-weight: 500; text-align: left; + padding: 7px 10px; border-bottom: 1px solid var(--line); +} +.dtable td { padding: 7px 10px; border-bottom: 1px solid rgba(26,37,64,.55); vertical-align: middle; } +.dtable tr:last-child td { border-bottom: none; } +.dtable tbody tr { transition: background .12s ease; } +.dtable tbody tr:hover { background: rgba(34,211,238,.04); } + +/* ---------- scrollbar ---------- */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--ink); } +::-webkit-scrollbar-thumb { background: var(--line2); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #31406b; } +* { scrollbar-width: thin; scrollbar-color: var(--line2) var(--ink); } + +/* ---------- reduced motion ---------- */ +.reduce-motion *, .reduce-motion *::before, .reduce-motion *::after { + animation-duration: .001s !important; + animation-iteration-count: 1 !important; + transition-duration: .001s !important; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .001s !important; + animation-iteration-count: 1 !important; + transition-duration: .001s !important; + } +} + +/* ================= 结构样式(对应 App.tsx / chrome.tsx 的 tailwind 类) ================= */ +#root { min-height: 100%; } +.app { min-height: 100%; position: relative; } +.bg-layer { position: fixed; inset: 0; pointer-events: none; } +.z0 { z-index: 0; } .z50 { z-index: 50; } +.scanlines.z50 { opacity: .7; } + +/* ---------- 顶栏 ---------- */ +.hdr { position: sticky; top: 0; z-index: 30; border-bottom: 1px solid #1a2540; background: rgba(5,8,16,.88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } +.hdr-in { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 48px; } +.hdr-in > * { flex: none; } +.hdr-in > .flex1 { flex: 1; } +.brand { display: flex; flex-direction: column; line-height: 1; flex: none; white-space: nowrap; } +.brand .b1 { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .06em; } +.brand .b2 { font-size: 9px; color: #54637e; letter-spacing: .26em; margin-top: 4px; } +.vsep { width: 1px; height: 16px; background: #1a2540; margin: 0 4px; display: inline-block; flex: none; } +.nav { display: flex; align-items: center; gap: 4px; margin-left: 8px; flex: none; } +.navbtn { position: relative; padding: 6px 14px; font-size: 12px; letter-spacing: .08em; border-radius: 4px; transition: all .15s; background: none; border: none; cursor: pointer; color: #8b9bb4; font-family: inherit; } +.navbtn:hover { color: #d7e1f0; background: rgba(34,211,238,.04); } +.navbtn.act { color: #22d3ee; background: rgba(34,211,238,.08); box-shadow: inset 0 0 0 1px rgba(34,211,238,.35), 0 0 14px rgba(34,211,238,.12); } +.navbtn .tri { margin-right: 4px; font-size: 9px; display: none; } +.navbtn.act .tri { display: inline; } +.navbtn .en { margin-left: 6px; font-size: 9px; letter-spacing: .14em; color: #3d4c66; } +.navbtn.act .en { color: rgba(34,211,238,.6); } +.flex1 { flex: 1; } +.mdtag { display: none; align-items: center; gap: 8px; border-radius: 3px; border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.07); padding: 4px 8px; font-size: 10px; letter-spacing: .14em; color: #22d3ee; } +@media (min-width: 768px) { .mdtag { display: flex; } } +@media (max-width: 1150px) { .mdtag { display: none; } } +.livespan { display: none; align-items: center; gap: 6px; font-size: 10px; letter-spacing: .18em; color: #34d399; } +@media (min-width: 640px) { .livespan { display: flex; } } +.livedot { width: 6px; height: 6px; border-radius: 9999px; background: #34d399; box-shadow: 0 0 6px 1px rgba(52,211,153,.7); } +.clock { font-size: 12px; color: #8b9bb4; letter-spacing: .05em; white-space: nowrap; } +.clock .csep { margin: 0 6px; color: #243152; } +.clock .ct { color: #d7e1f0; } +.clock .cc { color: #22d3ee; } +.demotag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid rgba(251,191,36,.45); background: rgba(251,191,36,.06); color: #fbbf24; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; white-space: nowrap; } + +/* ---------- 雷达 logo ---------- */ +.radar { position: relative; flex: none; border-radius: 9999px; border: 1px solid rgba(34,211,238,.5); overflow: hidden; box-shadow: 0 0 12px rgba(34,211,238,.25), inset 0 0 8px rgba(34,211,238,.15); } +.radar .sweep { position: absolute; inset: 0; } +.radar .ring1 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.3); } +.radar .ring2 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.2); } +.radar .center { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 3px; height: 3px; border-radius: 9999px; background: #22d3ee; box-shadow: 0 0 6px 2px rgba(34,211,238,.8); } +.radar .blip { position: absolute; width: 2.5px; height: 2.5px; border-radius: 9999px; } + +/* ---------- EVENT TAPE ---------- */ +.tape { position: fixed; bottom: 0; left: 0; right: 0; height: 32px; z-index: 40; display: flex; align-items: center; border-top: 1px solid #1a2540; background: rgba(5,8,16,.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow: hidden; } +.tape-label { flex: none; display: flex; align-items: center; gap: 6px; padding: 0 12px; height: 100%; border-right: 1px solid #1a2540; font-size: 10px; letter-spacing: .18em; color: #54637e; } +.tape-view { flex: 1; overflow: hidden; position: relative; } +.tape-item { display: inline-flex; align-items: center; gap: 8px; margin: 0 20px; font-size: 10.5px; letter-spacing: .04em; } +.tape-item .tt { color: #54637e; } +.tape-item .ts { color: #22d3ee; } +.tape-item .tm { color: #8b9bb4; } +.tape-item .tok { color: #34d399; } +.tape-item .tbad { color: #f87171; } +.tape-item .tsep { color: #1a2540; margin-left: 12px; } + +/* ---------- 主区 ---------- */ +.main { position: relative; z-index: 10; padding: 12px 16px 48px; max-width: 1720px; margin: 0 auto; } +.col { display: flex; flex-direction: column; gap: 12px; } + +/* ---------- tag / pill ---------- */ +.tag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid #243152; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; line-height: 1.5; color: #8b9bb4; white-space: nowrap; } +.tag.cy { border-color: rgba(34,211,238,.45); color: #22d3ee; background: rgba(34,211,238,.06); } +.tag.amb { border-color: rgba(251,191,36,.45); color: #fbbf24; background: rgba(251,191,36,.06); } +.tag.rd { border-color: rgba(248,113,113,.45); color: #f87171; background: rgba(248,113,113,.07); } +.tag.mint { border-color: rgba(52,211,153,.45); color: #34d399; background: rgba(52,211,153,.06); } +.tag.dashed { border-style: dashed; border-color: #243152; color: #54637e; } +.minidot { width: 6px; height: 6px; border-radius: 9999px; display: inline-block; } +.spill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; } + +/* ---------- 总览 Hero ---------- */ +.hero { padding: 16px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 16px 32px; } +.hero-l { display: flex; flex-direction: column; gap: 8px; min-width: 300px; } +.hero-title { font-size: 22px; font-weight: 600; letter-spacing: .025em; } +.hero-title .dot { color: #54637e; margin: 0 8px; } +.hero-tags { display: flex; align-items: center; gap: 8px; } +.hero-sub { font-size: 11px; color: #54637e; } +.hero-sub .sep { margin: 0 8px; color: #243152; } +.kpis { display: flex; align-items: flex-start; gap: 32px; padding-right: 8px; } +.kpi { display: flex; flex-direction: column; gap: 4px; } +.kpi .kv { font-size: 26px; line-height: 1; font-weight: 600; letter-spacing: -.025em; color: #e8f1ff; } +.kpi .ks { font-size: 10px; color: #54637e; } + +/* ---------- 数据集卡 ---------- */ +.ds-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +@media (min-width: 640px) { .ds-grid { grid-template-columns: repeat(5, 1fr); } } +@media (min-width: 1280px) { .ds-grid { grid-template-columns: repeat(10, 1fr); } } +.dsc { border-radius: 5px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; transition: all .15s; cursor: default; border: 1px solid #1a2540; } +.dsc:hover { transform: translateY(-1px); border-color: #243152; } +.dsc.fail { border-color: rgba(248,113,113,.55); box-shadow: 0 0 18px rgba(248,113,113,.14); } +.dsc.fail:hover { border-color: rgba(248,113,113,.55); } +.dsc.slow { border-color: rgba(251,191,36,.4); } +.dsc.slow:hover { border-color: rgba(251,191,36,.4); } +.dsc.review { border-color: rgba(34,211,238,.45); box-shadow: 0 0 14px rgba(34,211,238,.1); } +.dsc.review:hover { border-color: rgba(34,211,238,.45); } +.dsc-top { display: flex; align-items: center; justify-content: space-between; } +.dsc-name { font-size: 12px; font-weight: 500; color: #e8f1ff; } +.dsc-note { font-size: 10px; color: #54637e; line-height: 1.6; min-height: 28px; } + +/* ---------- 总览下半网格 ---------- */ +.ov-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 1280px) { .ov-grid { grid-template-columns: repeat(12, 1fr); } .ov-l { grid-column: span 7; } .ov-r { grid-column: span 5; } } +.panel-b { padding: 12px; } +.legend { display: flex; align-items: center; gap: 12px; font-size: 10px; letter-spacing: .1em; } +.legend span { display: flex; align-items: center; gap: 4px; } +.jit { padding: 0 4px; border-radius: 3px; } +.obs-note { font-size: 10px; color: #fbbf24; } +.site-foot { padding: 10px 12px; border-top: 1px solid #1a2540; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: 10px; color: #54637e; } +.tbtn.mini { padding: 3px 8px; } + +/* ---------- 时间线 ---------- */ +@keyframes nowPulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } } +.now-dot { animation: nowPulse 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes nowR { 0%,100% { r: 3.6px; } 50% { r: 5.2px; } } +.now-r { animation: nowR 1.6s ease-in-out infinite; } + +/* ---------- 异常卡 ---------- */ +.inc { display: flex; align-items: center; gap: 12px; border-radius: 4px; border: 1px solid #1a2540; border-left-width: 3px; padding: 10px 12px; } +.inc.fail { border-left-color: #f87171; background: rgba(248,113,113,.05); } +.inc.slow { border-left-color: #fbbf24; background: rgba(251,191,36,.04); } +.inc.off { border-left-color: #334155; background: transparent; border-style: dashed; border-color: #243152; border-left-width: 3px; border-left-style: solid; border-left-color: #334155; } +.inc-title { font-size: 12px; color: #e8f1ff; } +.inc-meta { font-size: 10px; color: #54637e; margin-top: 2px; } + +/* ---------- Sources 页 ---------- */ +.strip { padding: 12px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 24px; } +.src-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 16px; border-bottom: 1px solid #1a2540; } +.src-name { font-size: 15px; font-weight: 600; color: #e8f1ff; letter-spacing: .025em; } +.src-role { font-size: 10px; color: #54637e; letter-spacing: .1em; } +.src-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; border-bottom: 1px solid #1a2540; font-size: 11px; color: #8b9bb4; } +.src-meta .msep { color: #243152; } +.mx-grid { display: grid; grid-template-columns: 1fr; gap: 16px 24px; padding: 16px; } +@media (min-width: 768px) { .mx-grid { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1280px) { .mx-grid { grid-template-columns: repeat(3, 1fr); } } +.mx-grid > div { min-width: 0; } +.mx-grid table { table-layout: auto; max-width: 100%; } +.offbox { border-radius: 4px; border: 1px dashed #243152; padding: 24px 16px; text-align: center; font-size: 11px; color: #54637e; } +.rsv-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 768px) { .rsv-grid { grid-template-columns: repeat(3, 1fr); } } +.rsv { border-radius: 6px; border: 1px dashed #243152; padding: 16px; display: flex; align-items: center; gap: 12px; color: #54637e; } +@keyframes spin { to { transform: rotate(360deg); } } +.animate-spin { display: inline-block; animation: spin 1s linear infinite; } + +/* ---------- Lineage 页 ---------- */ +.lin-cols { display: flex; justify-content: space-between; padding: 4px 24px 0; font-size: 10px; letter-spacing: .2em; color: #54637e; } +.lin-hint { position: absolute; left: 24px; bottom: 4px; font-size: 10px; color: #54637e; letter-spacing: .1em; } +@keyframes gnodePulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } } +.gnode-pulse { animation: gnodePulse 1.8s ease-in-out infinite; } +.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; } +.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; } +.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; } +.lin-q::placeholder { color: #3d4c66; } +.lin-sel:focus, .lin-q:focus { border-color: rgba(34,211,238,.5); } +.lin-scroll { overflow-x: auto; } +/* HEL-529:宽档下由 app.js 的 fitLineageTable() 按剩余可视高度写入 + max-height(真实数据集 17 条 > 打样假设的 13 条),此处只声明纵向滚动本身; + 未设置 max-height(窄档 <=1100px)时 overflow-y:auto 不产生任何视觉影响。 */ +.lin-scroll { overflow-y: auto; } +.lin-table { min-width: 1080px; } +.lin-table-n { display: none; } +.lin-table-n .sub { display: block; font-size: 10px; color: #54637e; margin-top: 2px; } +/* ---------- Lineage 页压缩(仅血缘页,其他页不受影响) ---------- */ +#mainEl[data-page="lineage"] .col { gap: 8px; } +#mainEl[data-page="lineage"] { padding-bottom: 36px; } +#mainEl[data-page="lineage"] .strip { padding: 7px 16px; gap: 6px 20px; } +#mainEl[data-page="lineage"] .lin-filter { padding: 6px 12px; } +#mainEl[data-page="lineage"] .dtable th { padding: 5px 10px; } +#mainEl[data-page="lineage"] .dtable td { padding: 4px 10px; } +@media (max-width: 1100px) { + .hdr-in { flex-wrap: wrap; height: auto; padding-top: 6px; padding-bottom: 6px; row-gap: 6px; } + .hdr-in > .flex1 { flex: 1 1 100%; height: 0; } + .mdtag { display: flex; } + .lin-table { display: none; } + .lin-table-n { display: table; width: 100%; } +} +.row-fail { background: rgba(248,113,113,.045); } +.row-slow { background: rgba(251,191,36,.03); } +.row-off { opacity: .55; } + +/* ---------- sparkline ---------- */ +@keyframes sparkBlink { 0%,100% { opacity: 1; } 50% { opacity: .3; } } +.spark-end { animation: sparkBlink 1.4s ease-in-out infinite; } + +/* ================================================================= + 以下为 HEL-529 第十二版生产实现补充样式(非 Kimi 打样内容): + 登录/改密视图、抽屉(调度任务/盘后发布/审计合并入口)、危险操作弹层。 + 全部复用上方 token(--ink/--panel/--line/--cy/--mint/--amb/--rd 等), + 不引入新色值、不新增动效缓动函数。 + ================================================================= */ + +/* ---------- 登录 / 改密(保留原有表单结构,套用新 token) ---------- */ +.auth-wrap { + min-height: 100%; display: flex; align-items: center; justify-content: center; + padding: 24px; +} +.auth-panel { + width: 100%; max-width: 380px; padding: 28px 26px 24px; +} +.auth-panel h1 { font-size: 16px; font-weight: 600; color: #e8f1ff; margin: 0 0 6px; letter-spacing: .02em; } +.auth-panel .muted { font-size: 11px; color: var(--dim); line-height: 1.6; margin: 0 0 18px; } +.auth-panel form { display: flex; flex-direction: column; gap: 12px; } +.auth-panel label { display: flex; flex-direction: column; gap: 6px; font-size: 11px; color: var(--mut); letter-spacing: .04em; } +.auth-panel input { + background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; + color: var(--txt); font-family: inherit; font-size: 13px; padding: 8px 10px; outline: none; +} +.auth-panel input:focus { border-color: rgba(34,211,238,.55); box-shadow: 0 0 0 2px rgba(34,211,238,.12); } +.auth-panel button[type="submit"] { + margin-top: 4px; font-size: 12px; letter-spacing: .08em; border: 1px solid rgba(34,211,238,.5); + color: #0a1420; background: var(--cy); padding: 9px 12px; border-radius: 4px; cursor: pointer; + font-family: inherit; font-weight: 600; transition: filter .15s ease; +} +.auth-panel button[type="submit"]:hover { filter: brightness(1.08); } +.auth-panel .error { color: var(--rd); font-size: 11px; margin: 2px 0 0; } + +/* ---------- 抽屉(调度任务 / 盘后发布 / 审计) ---------- */ +.drawer-mask { + position: fixed; inset: 0; background: rgba(2,4,10,.55); z-index: 60; + opacity: 0; pointer-events: none; transition: opacity .2s ease-out; +} +.drawer-mask.open { opacity: 1; pointer-events: auto; } +.drawer { + position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw); + background: linear-gradient(180deg, rgba(17,26,43,.98), rgba(9,13,23,.99)); + border-left: 1px solid var(--line); z-index: 61; display: flex; flex-direction: column; + transform: translateX(100%); transition: transform .28s cubic-bezier(.16,1,.3,1); + box-shadow: -18px 0 40px rgba(0,0,0,.4); +} +.drawer-mask.open .drawer { transform: translateX(0); } +.drawer-hd { + display: flex; align-items: center; gap: 10px; padding: 14px 18px; + border-bottom: 1px solid var(--line); flex: none; +} +.drawer-hd .ttl { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .04em; } +.drawer-hd .sub { font-size: 10px; color: var(--dim); } +.drawer-tabs { display: flex; gap: 4px; padding: 10px 18px 0; flex: none; } +.drawer-tab { + font-size: 11px; letter-spacing: .06em; color: var(--mut); background: none; border: none; + padding: 6px 12px; border-radius: 4px 4px 0 0; cursor: pointer; font-family: inherit; + border-bottom: 2px solid transparent; +} +.drawer-tab.act { color: var(--cy); border-bottom-color: var(--cy); background: rgba(34,211,238,.06); } +.drawer-body { flex: 1; overflow-y: auto; padding: 14px 18px 24px; } +.drawer-close { + margin-left: auto; background: none; border: 1px solid var(--line2); color: var(--mut); + width: 26px; height: 26px; border-radius: 4px; cursor: pointer; font-size: 13px; line-height: 1; + font-family: inherit; +} +.drawer-close:hover { color: var(--cy); border-color: rgba(34,211,238,.5); } +.drawer-section { margin-bottom: 18px; } +.drawer-section .lab { margin-bottom: 8px; } +.dtoolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; } +.dtoolbar input { + background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt); + font-family: inherit; font-size: 11px; padding: 5px 8px; outline: none; +} + +/* ---------- 危险操作确认弹层 ---------- */ +.modal-mask { + position: fixed; inset: 0; background: rgba(2,4,10,.6); z-index: 70; + display: none; align-items: center; justify-content: center; padding: 20px; +} +.modal-mask.open { display: flex; } +.modal-box { + width: 100%; max-width: 380px; background: var(--panel2); border: 1px solid rgba(248,113,113,.4); + border-radius: 6px; padding: 18px 20px; box-shadow: 0 0 28px rgba(248,113,113,.12); +} +.modal-box h3 { margin: 0 0 4px; font-size: 13px; color: var(--rd); letter-spacing: .04em; } +.modal-box p { margin: 0 0 12px; font-size: 11px; color: var(--mut); line-height: 1.7; } +.modal-box label { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--mut); margin-bottom: 10px; } +.modal-box input { + background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt); + font-family: inherit; font-size: 12px; padding: 7px 9px; outline: none; +} +.modal-box input:focus { border-color: rgba(248,113,113,.55); } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } +.modal-actions .tbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.5); } +.modal-actions .tbtn.danger:hover { color: #fff; background: rgba(248,113,113,.18); box-shadow: 0 0 10px rgba(248,113,113,.2); } +.modal-err { color: var(--rd); font-size: 11px; margin: 6px 0 0; min-height: 14px; } + +/* ---------- 小工具 ---------- */ +.opbtn { + font-size: 10px; letter-spacing: .06em; border: 1px solid var(--line2); color: var(--mut); + background: rgba(17,26,43,.5); padding: 3px 9px; border-radius: 3px; cursor: pointer; font-family: inherit; +} +.opbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); } +.opbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.4); } +.opbtn.danger:hover { color: #fff; background: rgba(248,113,113,.14); } +.empty-hint { text-align: center; padding: 22px 0; color: var(--dim); font-size: 11px; } +.toast { + position: fixed; left: 50%; bottom: 44px; transform: translate(-50%, 8px); z-index: 80; + background: var(--panel2); border: 1px solid var(--line2); border-radius: 5px; padding: 9px 16px; + font-size: 12px; color: var(--txt); opacity: 0; pointer-events: none; transition: opacity .18s ease-out, transform .18s ease-out; + box-shadow: 0 8px 24px rgba(0,0,0,.35); +} +.toast.show { opacity: 1; transform: translate(-50%, 0); } +.toast.err { border-color: rgba(248,113,113,.5); color: #fbd2d2; } +.toast.ok { border-color: rgba(52,211,153,.5); color: #cdf5e4; } + +/* 减少动态:抽屉/弹层的滑入过渡也一并归零,保持与其余过渡同一门禁 */ +.reduce-motion .drawer, +.reduce-motion .drawer-mask, +.reduce-motion .toast { transition-duration: .001s !important; } +@media (prefers-reduced-motion: reduce) { + .drawer, .drawer-mask, .toast { transition-duration: .001s !important; } } -* { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--text); } -.panel, header.top, nav, main { background: var(--surface); } -.auth-panel { max-width: 420px; margin: 12vh auto; padding: 28px; border-radius: var(--radius); border: 1px solid var(--line); } -label { display: block; margin: 12px 0; } -input, select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--text); } -button { background: var(--action); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; } -button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); } -button.danger { background: var(--danger); } -.muted { color: var(--muted); } -.error { color: var(--danger); } -.top { display: flex; gap: 12px; align-items: center; padding: 10px var(--pad); border-bottom: 1px solid var(--line); } -nav { display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); } -nav button { background: transparent; color: var(--muted); } -nav button.active { color: var(--action); background: transparent; font-weight: 600; } -main { padding: var(--pad); min-height: calc(100vh - 96px); } -.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; } -.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; } -table { width: 100%; border-collapse: collapse; font-size: 13px; } -th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; } -.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); } -.ok { color: var(--ok); } -.warn { color: var(--warn); } -.fail { color: var(--danger); } -.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: end; } -.toolbar label { margin: 0; } -dialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 20px; }