// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";

const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000;
const PAGE_SIZE = 20;

const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [
  ["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
  ["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
  ["s-toolfail", "工具失败"],
];

const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || "";

// 用户显示名兜底链:name → user_name → email → uid8。监控页各处共用同一规则。
// userCellHTML 给表格单元格:主文本走兜底链;name 与 user_name 都有时,name 后跟一个
// 浅灰 user_name;title 悬浮给完整 name/账号/邮箱/user_id。userLabelText 给概览迷你表(纯文本)。
function userLabelText(r) {
  return r.name || r.user_name || r.email || (r.user_id || "").slice(0, 8);
}
function userTitle(r) {
  const parts = [];
  if (r.name) parts.push(`姓名 ${r.name}`);
  if (r.user_name) parts.push(`账号 ${r.user_name}`);
  if (r.email) parts.push(`邮箱 ${r.email}`);
  parts.push(`ID ${r.user_id}`);
  return parts.join("\n");
}
function userCellHTML(r) {
  const primary = escapeHtml(userLabelText(r));
  // name 与 user_name 同时存在 → 主显 name,后缀浅灰 user_name(满足"name 和 user_name 都显")
  const sub = (r.name && r.user_name)
    ? ` <span style="color:var(--muted);font-size:.85em;">${escapeHtml(r.user_name)}</span>`
    : "";
  return `${primary}${sub}`;
}

let timer = null;
// 各表独立状态(不随 overview 轮询重置)
let modelRange = "7d", modelSort = "cost";
let userRange = "7d", userSort = "cost", userPage = 0;
let storagePage = 0;
let tiersData = null;   // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用)

// ───── 格式化 ─────
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)});`;
}
function levelClass(ratio) {
  if (ratio >= 1) return "danger";
  if (ratio >= 0.8) return "warn";
  return "";
}
// range/sort 下拉一组(prefix 区分 m=模型 / u=用户);值取当前 state。
function ctrlHTML(prefix, range, sort) {
  const opt = (cur, list) => list.map(
    ([v, l]) => `<option value="${v}" ${v === cur ? "selected" : ""}>${l}</option>`
  ).join("");
  return `<div class="ctrl">`
    + `<select id="${prefix}-range">${opt(range, RANGE_OPTS)}</select>`
    + `<select id="${prefix}-sort">${opt(sort, SORT_OPTS)}</select>`
    + `</div>`;
}
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }

// ───── 渲染各 section ─────
function statCard(k, v, sub, cls) {
  return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
    + `<div class="v">${v}</div>`
    + (sub ? `<div class="sub">${sub}</div>` : "") + `</div>`;
}

function renderRuntime(r) {
  const active = r.active_runs || 0;
  const max = r.max_workers || 0;
  const ratio = max ? active / max : 0;
  const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : "";
  const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
  return `<div class="card"><h2>实时运行态</h2><div class="grid">`
    + statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio))
    + statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
    + statCard("内存峰值", rss, "进程 RSS high-water")
    + `</div></div>`;
}

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]) => `<span class="chip ${k === "completed" ? "ok" : ""}">${escapeHtml(k)} <b>${n}</b></span>`)
    .join("") || `<span class="empty">无</span>`;
  const runChips = Object.entries(t.by_run_status || {})
    .map(([k, n]) => {
      const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
      return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
    }).join("") || `<span class="empty">无</span>`;
  return `<div class="card"><h2>任务（共 ${t.total || 0}）</h2>`
    + `<div style="margin-bottom:10px;"><div class="sublabel">status</div><div class="chips">${statusChips}</div></div>`
    + `<div><div class="sublabel">run_status</div><div class="chips">${runChips}</div></div>`
    + `</div>`;
}

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 `<div class="card"><h2>用户与用量总览（all-time）</h2><div class="grid">`
    + 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), "")
    + `</div></div>`;
}

function renderByDay(rows) {
  rows = rows || [];
  const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
  const body = rows.map(r => `<tr>`
    + `<td>${escapeHtml(r.date)}</td>`
    + `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
    + `<td class="num">${fmtTokens(r.tokens_in)}</td>`
    + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
    + `</tr>`).join("") || `<tr><td colspan="4" class="empty">无数据</td></tr>`;
  const sum = rows.reduce((a, r) => {
    a.cost_cny += r.cost_cny || 0;
    a.tokens_in += r.tokens_in || 0;
    a.tokens_out += r.tokens_out || 0;
    return a;
  }, { cost_cny: 0, tokens_in: 0, tokens_out: 0 });
  const foot = rows.length ? `<tfoot><tr class="total-row">`
    + `<td>合计</td>`
    + `<td class="num">${fmtCNY(sum.cost_cny)}</td>`
    + `<td class="num">${fmtTokens(sum.tokens_in)}</td>`
    + `<td class="num">${fmtTokens(sum.tokens_out)}</td>`
    + `</tr></tfoot>` : "";
  return `<div class="card"><h2>近 7 天用量（按天）</h2><div class="scroll-x"><table>`
    + `<thead><tr><th>日期</th><th>成本</th><th>输入</th><th>输出</th></tr></thead>`
    + `<tbody>${body}</tbody>${foot}</table></div></div>`;
}

// 按模型(时间筛选 + 排序)。d = {range, sort, rows}
function renderModels(d) {
  const rows = d.rows || [];
  const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
  const maxTok = Math.max(0, ...rows.map(r => (r.tokens_in || 0) + (r.tokens_out || 0)));
  const byTok = d.sort === "tokens";
  const body = rows.map(r => {
    const tok = (r.tokens_in || 0) + (r.tokens_out || 0);
    return `<tr>`
      + `<td class="email">${escapeHtml(r.model_profile || "—")}</td>`
      + `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
      + `<td class="num bar-cell" style="${byTok ? tint(tok, maxTok) : ""}">${fmtTokens(r.tokens_in)}</td>`
      + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
      + `<td class="num">${r.n_events || 0}</td>`
      + `</tr>`;
  }).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
  $("s-models").innerHTML = `<div class="card">`
    + `<div class="card-head"><h2>按模型（${rangeLabel(d.range)}）</h2>${ctrlHTML("m", d.range, d.sort)}</div>`
    + `<div class="scroll-x"><table>`
    + `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>`
    + `<tbody>${body}</tbody></table></div></div>`;
  $("m-range").onchange = (e) => { modelRange = e.target.value; loadModels(); };
  $("m-sort").onchange = (e) => { modelSort = e.target.value; loadModels(); };
}

// 各用户用量(时间筛选 + 排序 + 分页)。d 含 range/sort/page/page_size/total_users/rows
function renderUserUsage(d) {
  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 byTok = d.sort === "tokens";
  const body = rows.map(r => {
    const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
    return `<tr>`
      + `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}`
      + (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
      + `<td>${planSelectHTML(r)}</td>`
      + `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
      + `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
      + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
      + `<td class="num">${hitRate}%</td>`
      + `<td class="num">${r.n_events || 0}</td>`
      + `<td title="${escapeHtml(fmtTime(r.last_used_at))}">${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}</td>`
      + `</tr>`;
  }).join("") || `<tr><td colspan="8" class="empty">无数据</td></tr>`;
  $("s-users").innerHTML = `<div class="card">`
    + `<div class="card-head"><h2>各用户用量（${rangeLabel(d.range)}）</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
    + tierLegendHTML()
    + `<div class="scroll-x"><table>`
    + `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th><th>最近使用</th></tr></thead>`
    + `<tbody>${body}</tbody></table></div>`
    + pagerHTML("uu", page, maxPage, from, to, total)
    + `</div>`;
  $("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); };
  $("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); };
  // 档位下拉:选中即 PATCH(admin 看到全部模型,改档不影响 admin 自己的可见性)
  $("s-users").querySelectorAll(".plan-sel").forEach(sel => {
    sel.onchange = (e) => setUserPlan(e.target.dataset.uid, e.target.value, e.target);
  });
  wirePager("uu", page, maxPage, (p) => loadUserUsage(p));
}

// 档位下拉(每行一个);plan 为空 → 选中 default 档。tiers 未加载好 → 退化为纯文本。
function planSelectHTML(r) {
  const tiers = (tiersData && tiersData.tiers) || {};
  const names = Object.keys(tiers);
  if (!names.length) return escapeHtml(r.plan || "default");
  const def = (tiersData && tiersData.default_tier) || "default";
  const cur = r.plan || def;
  const opts = names.map(n =>
    `<option value="${escapeHtml(n)}" ${n === cur ? "selected" : ""}>${escapeHtml(n)}${n === def ? "（默认）" : ""}</option>`
  ).join("");
  return `<select class="plan-sel" data-uid="${escapeHtml(r.user_id)}">${opts}</select>`;
}

// 档位图例:每档含哪些模型(id → 显示名)。tiersData 未加载 → 空。
function tierLegendHTML() {
  if (!tiersData || !tiersData.tiers) return "";
  const cat = {};
  (tiersData.catalog || []).forEach(m => { cat[m.id] = m.display_name; });
  const def = tiersData.default_tier || "default";
  const rows = Object.keys(tiersData.tiers).map(name => {
    const members = (tiersData.tiers[name] || []).map(id => id === "*" ? "全部模型" : (cat[id] || id));
    return `<div style="margin:2px 0;"><b>${escapeHtml(name)}${name === def ? "（默认）" : ""}</b>：`
      + `<span style="color:var(--muted);">${members.map(escapeHtml).join("、") || "（空）"}</span></div>`;
  }).join("");
  return `<div class="tier-legend" style="font-size:.85em;margin:0 0 10px;padding:8px 10px;`
    + `background:var(--bg-soft,#f6f6f6);border-radius:6px;">`
    + `<div style="color:var(--muted);margin-bottom:4px;">档位说明（改 config/agent.yaml model_tiers；admin 始终全开）</div>`
    + rows + `</div>`;
}

// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
function renderStorage(d) {
  const quota = d.quota_bytes;
  const rows = d.rows || [];
  const total = d.total || 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 quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
  const maxUsed = Math.max(0, ...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 `<tr>`
      + `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}</td>`
      + `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
      + `<td class="num">${pctTxt}</td>`
      + `<td class="num">${r.file_count || 0}</td>`
      + `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
      + `</tr>`;
  }).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
  $("s-storage").innerHTML = `<div class="card"><h2>存储用量（${quotaLabel}）</h2>`
    + `<div class="scroll-x"><table>`
    + `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
    + `<tbody>${body}</tbody></table></div>`
    + pagerHTML("st", page, maxPage, from, to, total)
    + `</div>`;
  wirePager("st", page, maxPage, (p) => loadStorage(p));
}

// 工具失败聚集(近7天,低阈值看全量;巡检邮件走 core/toolfail 高阈值)。
// 签名已在后端归一(路径/数字抹平),悬浮 title 给最近一次的原始样例。
// 后端已按活跃度排序(近24h 有发生的在前);近24h=0 的行淡化 —— 修复部署后
// 看对应行是否变灰 + 趋势尾格归零,即知修没修好(旧记录会挂满窗口,行不消失)。
function trendBar(daily) {
  if (!daily || !daily.length) return "";
  const max = Math.max(...daily, 1);
  const blocks = "▁▂▃▄▅▆▇█";
  return daily.map(v => v === 0 ? "·"
    : blocks[Math.min(blocks.length - 1, Math.ceil(v / max * blocks.length) - 1)]).join("");
}
function renderToolFailures(d) {
  const rows = d.clusters || [];
  const body = rows.map(c => `<tr${c.count_24h ? "" : ` class="quiet"`}>`
    + `<td>${escapeHtml(c.tool)}</td>`
    + `<td>${escapeHtml(c.kind)}</td>`
    + `<td class="email" title="${escapeHtml(c.sample || "")}">${escapeHtml(c.signature)}</td>`
    + `<td class="num">${c.count}</td>`
    + `<td class="num${c.count_24h ? " hot" : ""}">${c.count_24h || 0}</td>`
    + `<td class="trend" title="每格 24h，旧 → 新：${(c.daily || []).join(" / ")}">${trendBar(c.daily)}</td>`
    + `<td class="num">${c.task_count}</td>`
    + `<td class="num">${c.user_count}</td>`
    + `<td>${c.last_at ? fmtTime(c.last_at) : "—"}</td>`
    + `</tr>`).join("")
    || `<tr><td colspan="9" class="empty">近 ${d.days || 7} 天无失败聚集</td></tr>`;
  $("s-toolfail").innerHTML = `<div class="card"><h2>工具失败聚集（近 ${d.days || 7} 天，同签名 ≥3 次；近 24h 活跃在前，灰行 = 已安静）</h2>`
    + `<div class="scroll-x"><table>`
    + `<thead><tr><th>工具</th><th>类型</th><th>签名（悬浮看样例）</th><th>次数</th><th>近24h</th><th>趋势</th><th>任务数</th><th>用户数</th><th>最近</th></tr></thead>`
    + `<tbody>${body}</tbody></table></div></div>`;
}

function pagerHTML(prefix, page, maxPage, from, to, total) {
  return `<div class="pager">`
    + `<button id="${prefix}-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
    + `<span class="pginfo">${from}–${to} / ${total}（第 ${page + 1}/${maxPage + 1} 页）</span>`
    + `<button id="${prefix}-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
    + `</div>`;
}
function wirePager(prefix, page, maxPage, go) {
  const prev = $(`${prefix}-prev`), next = $(`${prefix}-next`);
  if (prev) prev.onclick = () => go(page - 1);
  if (next) next.onclick = () => go(page + 1);
}

// ───── 骨架 + 目录 ─────
function ensureSkeleton() {
  if ($("layout")) return;
  $("main").innerHTML = `<div id="layout">`
    + `<nav id="toc">` + SECTIONS.map(([id, label]) =>
        `<a href="#${id}" data-target="${id}">${label}</a>`).join("") + `</nav>`
    + `<div id="content">` + SECTIONS.map(([id]) =>
        `<div id="${id}" class="anchor"></div>`).join("") + `</div>`
    + `</div>`;
  document.querySelectorAll("#toc a").forEach(a => {
    a.onclick = (e) => {
      e.preventDefault();
      const el = $(a.dataset.target);
      if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
    };
  });
  setupScrollSpy();
}

// 滚动高亮当前目录项(IntersectionObserver,粗粒度即可)
function setupScrollSpy() {
  const links = {};
  document.querySelectorAll("#toc a").forEach(a => { links[a.dataset.target] = a; });
  const obs = new IntersectionObserver((entries) => {
    entries.forEach(en => {
      if (!en.isIntersecting) return;
      Object.values(links).forEach(l => l.classList.remove("active"));
      if (links[en.target.id]) links[en.target.id].classList.add("active");
    });
  }, { rootMargin: "-70px 0px -65% 0px", threshold: 0 });
  SECTIONS.forEach(([id]) => { const el = $(id); if (el) obs.observe(el); });
}

function renderMetrics(d) {
  $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
  $("s-runtime").innerHTML = renderRuntime(d.runtime || {});
  $("s-tasks").innerHTML = renderTasks(d.tasks || {});
  $("s-usage").innerHTML =
    renderUsersAndUsage(d.users || {}, d.usage || {})
    + renderByDay((d.usage || {}).by_day_7d);
}

function showMsg(html) {
  $("main").innerHTML = `<div class="msg">${html}</div>`;  // 清骨架,错误态独占
}

// ───── 拉数据 ─────
// 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。
async function apiGet(path) {
  const t = token();
  if (!t) {
    showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
    stopAuto();
    throw Object.assign(new Error("no token"), { code: "auth" });
  }
  const r = await fetch(path, { headers: { Authorization: "Bearer " + t } });
  if (r.status === 401) {
    showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
    stopAuto();
    throw Object.assign(new Error("401"), { code: "auth" });
  }
  if (r.status === 403) {
    showMsg(`无权限：管理后台仅限管理员（admin）访问。<br/><a href="/static/dev.html">返回控制台</a>`);
    stopAuto();
    throw Object.assign(new Error("403"), { code: "auth" });
  }
  if (!r.ok) {
    const d = await r.json().catch(() => ({}));
    throw new Error(d.detail || String(r.status));
  }
  return r.json();
}

// 写操作(PATCH/POST):带 JSON body;401/403 同 apiGet 提示;其余抛错由调用方 alert。
async function apiSend(method, path, body) {
  const t = token();
  if (!t) {
    showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
    stopAuto();
    throw Object.assign(new Error("no token"), { code: "auth" });
  }
  const r = await fetch(path, {
    method,
    headers: { Authorization: "Bearer " + t, "Content-Type": "application/json" },
    body: JSON.stringify(body || {}),
  });
  if (r.status === 401 || r.status === 403) {
    throw Object.assign(new Error(String(r.status)), { code: "auth" });
  }
  if (!r.ok) {
    const d = await r.json().catch(() => ({}));
    throw new Error(d.detail || String(r.status));
  }
  return r.json();
}

async function loadModels() {
  try {
    renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`));
  } catch (e) { /* auth 已提示;其它静默,overview 那边兜底 */ }
}
async function loadUserUsage(page) {
  page = Math.max(0, page);
  try {
    await loadTiers();  // 渲染档位下拉 / 图例前确保 tiers 在手(只拉一次)
    const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`);
    userPage = d.page || 0;
    renderUserUsage(d);
  } catch (e) { /* 同上 */ }
}
// 档位定义 + 模型目录:加载一次缓存(管理动作 / 图例用);失败退化为空(下拉降级纯文本)。
async function loadTiers() {
  if (tiersData) return tiersData;
  try { tiersData = await apiGet("/v1/admin/tiers"); }
  catch (e) { tiersData = { tiers: {}, default_tier: "default", catalog: [] }; }
  return tiersData;
}
// 设置某用户档位(PATCH);成功后刷新当前页。失败 alert 并复位下拉。
async function setUserPlan(uid, plan, selectEl) {
  if (selectEl) selectEl.disabled = true;
  try {
    await apiSend("PATCH", `/v1/admin/users/${uid}/plan`, { plan });
    loadUserUsage(userPage);
  } catch (e) {
    if (e.code !== "auth") alert("设置档位失败：" + (e.message || String(e)));
    if (selectEl) selectEl.disabled = false;
  }
}
async function loadStorage(page) {
  page = Math.max(0, page);
  try {
    const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
    storagePage = d.page || 0;
    renderStorage(d);
  } catch (e) { /* 同上 */ }
}

async function loadToolFailures() {
  try {
    const d = await apiGet("/v1/admin/tool-failures?days=7&min_count=3&min_tasks=1");
    renderToolFailures(d);
  } catch (e) { /* 同上 */ }
}

// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态)
async function refresh() {
  try {
    const d = await apiGet("/v1/admin/overview");
    ensureSkeleton();
    renderMetrics(d);
    loadModels();
    loadUserUsage(userPage);
    loadStorage(storagePage);
    loadToolFailures();
  } catch (e) {
    if (e.code !== "auth") showMsg(`加载失败：${escapeHtml(e.message || String(e))}`);
  }
}

// ───── 导出 PDF(客户端打印;列表取前 10)─────
async function exportPdf() {
  const btn = $("export");
  btn.disabled = true; const old = btn.textContent; btn.textContent = "生成中…";
  try {
    const [ov, models, users, storage, health] = await Promise.all([
      apiGet("/v1/admin/overview"),
      apiGet("/v1/admin/usage/models?range=all&sort=cost"),
      apiGet("/v1/admin/usage/users?range=all&sort=cost&page=0&page_size=10"),
      apiGet("/v1/admin/storage/users?page=0&page_size=10"),
      fetch("/healthz").then(r => r.json()).catch(() => ({})),
    ]);
    buildReport(ov, models, users, storage, health);
    window.print();
  } catch (e) {
    if (e.code !== "auth") alert("导出失败：" + (e.message || String(e)));
  } finally {
    btn.disabled = false; btn.textContent = old;
  }
}

function buildReport(ov, models, users, storage, health) {
  const rt = ov.runtime || {}, tk = ov.tasks || {}, us = ov.users || {}, u = (ov.usage || {}).total || {};
  const byDay = (ov.usage || {}).by_day_7d || [];
  const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
  const tbl = (headers, body) =>
    `<table class="rpt"><thead><tr>${headers.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table>`;
  const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无";

  const dayBody = byDay.map(r => `<tr><td>${escapeHtml(r.date)}</td><td>${fmtCNY(r.cost_cny)}</td>`
    + `<td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td></tr>`).join("")
    || `<tr><td colspan="4">无数据</td></tr>`;
  const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>`
    + `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
    + `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
  const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(userLabelText(r))}</td>`
    + `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
    + `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
  const quota = storage.quota_bytes;
  const stBody = (storage.rows || []).slice(0, 10).map(r => {
    const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
    return `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
      + `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
  }).join("") || `<tr><td colspan="4">无数据</td></tr>`;

  $("print-report").innerHTML =
    `<h1>zcbot 管理后台报告</h1>`
    + `<div class="rpt-meta">生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}`
    + `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10</div>`
    + `<h2>实时运行态</h2>`
    + `<div class="rpt-kv">活跃 run ${rt.active_runs || 0}${rt.max_workers ? " / " + rt.max_workers : ""}`
    + ` &nbsp;|&nbsp; SSE 订阅 ${rt.sse_subs || 0}`
    + ` &nbsp;|&nbsp; 内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}</div>`
    + `<h2>任务（共 ${tk.total || 0}）</h2>`
    + `<div class="rpt-kv">status：${dist(tk.by_status)}<br/>run_status：${dist(tk.by_run_status)}</div>`
    + `<h2>用户</h2><div class="rpt-kv">总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}</div>`
    + `<h2>用量总览（all-time）</h2>`
    + `<div class="rpt-kv">总成本 ${fmtCNY(u.cost_cny)} &nbsp;|&nbsp; 输入 ${fmtTokens(u.tokens_in)}`
    + ` &nbsp;|&nbsp; 输出 ${fmtTokens(u.tokens_out)} &nbsp;|&nbsp; 缓存命中 ${hitRate}%`
    + ` &nbsp;|&nbsp; 事件 ${u.n_events || 0}</div>`
    + `<h2>近 7 天用量（按天）</h2>` + tbl(["日期", "成本", "输入", "输出"], dayBody)
    + `<h2>按模型（all-time，Top 10）</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
    + `<h2>各用户用量（all-time，Top 10）</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
    + `<h2>存储用量（${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人，" : ""}Top 10）</h2>`
    + tbl(["用户", "已用", "占配额", "文件数"], stBody);
}

// ───── 自动刷新 ─────
function startAuto() {
  stopAuto();
  if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
}
function stopAuto() {
  if (timer) { clearInterval(timer); timer = null; }
}

$("refresh").onclick = refresh;
$("export").onclick = exportPdf;
$("auto-refresh").onchange = startAuto;
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {
  if (document.hidden) stopAuto();
  else { refresh(); startAuto(); }
});

refresh();
startAuto();
