// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。 // 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图, // 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。 // 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。 // 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点。 import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js"; const LS_TOKEN = "zcbot.token"; const REFRESH_MS = 10000; const PAGE_SIZE = 20; const $ = (id) => document.getElementById(id); const token = () => localStorage.getItem(LS_TOKEN) || ""; let timer = null; let userPage = 0; // 各用户用量表当前页(0-based);独立于 overview 轮询 // ───── 格式化 ───── function fmtCNY(n) { n = Number(n) || 0; if (n < 0.01 && n > 0) return "¥" + n.toFixed(4); return "¥" + n.toFixed(2); } // 相对热力底色:value 占 max 越高,accent 底色越深(占用多 → 有色差)。 function tint(value, max) { if (!max || max <= 0 || !value || value <= 0) return ""; const a = Math.min(1, value / max) * 0.30; return `background: rgba(192,57,43,${a.toFixed(3)});`; } // 阈值类:ratio>=1 危险,>=0.8 警告。 function levelClass(ratio) { if (ratio >= 1) return "danger"; if (ratio >= 0.8) return "warn"; return ""; } // ───── 渲染各 section ───── function statCard(k, v, sub, cls) { return `
${escapeHtml(k)}
` + `
${v}
` + (sub ? `
${sub}
` : "") + `
`; } function renderRuntime(r) { const active = r.active_runs || 0; const max = r.max_workers || 0; const ratio = max ? active / max : 0; const cls = levelClass(ratio); const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : ""; const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—"; return `

实时运行态

` + statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls) + statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接") + statCard("内存峰值", rss, "进程 RSS high-water") + `
`; } function renderTasks(t) { const order = ["active", "completed", "abandoned"]; const statusChips = Object.entries(t.by_status || {}) .sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0])) .map(([k, n]) => `${escapeHtml(k)} ${n}`) .join("") || ``; const runChips = Object.entries(t.by_run_status || {}) .map(([k, n]) => { const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : ""; return `${escapeHtml(k)} ${n}`; }).join("") || ``; return `

任务(共 ${t.total || 0})

` + `
status
${statusChips}
` + `
run_status
${runChips}
` + `
`; } function renderUsersAndUsage(users, usage) { const u = usage.total || {}; const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0; return `

用户与用量总览(all-time)

` + statCard("用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`) + statCard("总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`) + statCard("输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`) + statCard("输出 token", fmtTokens(u.tokens_out), "") + `
`; } function renderByDay(rows) { if (!rows || !rows.length) return `

近 7 天用量

无数据
`; const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); const body = rows.map(r => `` + `${escapeHtml(r.date)}` + `${fmtCNY(r.cost_cny)}` + `${fmtTokens(r.tokens_in)}` + `${fmtTokens(r.tokens_out)}` + ``).join(""); return `

近 7 天用量(按天)

` + `` + `${body}
日期成本输入输出
`; } function renderByModel(rows) { if (!rows || !rows.length) return `

按模型

无数据
`; const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); const body = rows.map(r => `` + `${escapeHtml(r.model_profile || "—")}` + `${fmtCNY(r.cost_cny)}` + `${fmtTokens(r.tokens_in)}` + `${fmtTokens(r.tokens_out)}` + `${r.n_events || 0}` + ``).join(""); return `

按模型(all-time)

` + `` + `${body}
模型成本输入输出事件
`; } // 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick // 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。 function renderUserUsage(d) { const c = $("user-usage"); if (!c) return; const rows = d.rows || []; const total = d.total_users || 0; const size = d.page_size || PAGE_SIZE; const page = d.page || 0; const maxPage = Math.max(0, Math.ceil(total / size) - 1); const from = total ? page * size + 1 : 0; const to = Math.min(total, (page + 1) * size); const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0)); const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0)); const body = rows.map(r => { const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0; return `` + `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + (r.role === "admin" ? ` admin` : "") + `` + `${fmtCNY(r.cost_cny)}` + `${fmtTokens(r.tokens_in)}` + `${fmtTokens(r.tokens_out)}` + `${hitRate}%` + `${r.n_events || 0}` + ``; }).join("") || `无数据`; c.innerHTML = `

各用户用量(按成本,all-time)

` + `
` + `` + `${body}
用户成本输入输出缓存命中事件
` + `
` + `` + `${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)` + `` + `
`; const prev = $("uu-prev"), next = $("uu-next"); if (prev) prev.onclick = () => loadUserUsage(userPage - 1); if (next) next.onclick = () => loadUserUsage(userPage + 1); } function renderStorage(st) { const quota = st.quota_bytes; const rows = st.users || []; const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限"; if (!rows.length) return `

存储用量(${quotaLabel})

无数据
`; const maxUsed = Math.max(...rows.map(r => r.bytes_used || 0)); const body = rows.map(r => { const ratio = quota && quota > 0 ? r.bytes_used / quota : 0; const cls = levelClass(ratio); const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—"; // 有配额时按配额占比上色(逼近上限变橙/红);无配额时按相对最大值热力上色 const cellStyle = quota && quota > 0 ? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);" : cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "") : tint(r.bytes_used, maxUsed); return `` + `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + `${humanSize(r.bytes_used)}` + `${pctTxt}` + `${r.file_count || 0}` + `${r.scanned_at ? fmtTime(r.scanned_at) : "—"}` + ``; }).join(""); return `

存储用量(${quotaLabel})

` + `` + `${body}
用户已用占配额文件数扫描于
`; } // #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表, // 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。 function ensureSkeleton() { if ($("metrics")) return; $("main").innerHTML = `
`; } function renderMetrics(d) { $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : ""; $("metrics").innerHTML = renderRuntime(d.runtime || {}) + renderTasks(d.tasks || {}) + renderUsersAndUsage(d.users || {}, d.usage || {}) + renderByDay((d.usage || {}).by_day_7d) + renderByModel((d.usage || {}).by_model) + renderStorage(d.storage || {}); } function showMsg(html) { $("main").innerHTML = `
${html}
`; // 清骨架,错误态独占 } // 处理鉴权/网络错误:命中返 true(调用方据此中止)。 function handleAuthError(r) { if (r.status === 401) { showMsg(`登录已失效。请回 控制台 重新登录。`); stopAuto(); return true; } if (r.status === 403) { showMsg(`无权限:管理后台仅限管理员(admin)访问。
` + `返回控制台`); stopAuto(); return true; } return false; } // ───── 各用户用量分页 ───── async function loadUserUsage(page) { const t = token(); if (!t) return; page = Math.max(0, page); try { const r = await fetch(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}`, { headers: { Authorization: "Bearer " + t }, }); if (handleAuthError(r)) return; if (!r.ok) return; const d = await r.json(); userPage = d.page || 0; // 以服务端回的页码为准(夹紧后) renderUserUsage(d); } catch (e) { /* 静默:overview 那边会报总错 */ } } // ───── 拉 overview ───── async function refresh() { const t = token(); if (!t) { showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`); stopAuto(); return; } try { const r = await fetch("/v1/admin/overview", { headers: { Authorization: "Bearer " + t }, }); if (handleAuthError(r)) return; if (!r.ok) { const d = await r.json().catch(() => ({})); showMsg(`加载失败:${escapeHtml(d.detail || (r.status + ""))}`); return; } ensureSkeleton(); renderMetrics(await r.json()); loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码) } catch (e) { showMsg(`加载失败:${escapeHtml(e.message || String(e))}`); } } // ───── 自动刷新 ───── function startAuto() { stopAuto(); if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS); } function stopAuto() { if (timer) { clearInterval(timer); timer = null; } } $("refresh").onclick = refresh; $("auto-refresh").onchange = startAuto; // 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求) document.addEventListener("visibilitychange", () => { if (document.hidden) stopAuto(); else { refresh(); startAuto(); } }); refresh(); startAuto();