xtc-backend / app /admin_html.py
a3216's picture
sync from GitHub c6e6ba1: feat: 修复后端模型黑白名单配置
63b125b verified
Raw
History Blame Contribute Delete
105 kB
"""管理后台 HTML(现代化 Tab 版)。
Tab 切换:概览 / 厂商 / 令牌 / 文件 / 会话 / 用量 / Webhook / 审计 / 测试
所有交互通过 fetch 调 /admin/api/* 接口。
"""
from __future__ import annotations
import secrets
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from .api._common import CORS_HEADERS
from .config import get_settings
router = APIRouter(tags=["admin-html"])
@router.get("/admin", response_class=HTMLResponse)
async def admin_page(
request: Request,
k: str | None = Query(default=None),
) -> Response:
settings = get_settings()
# 页面门禁:若配置了 XTC_ADMIN_ACCESS_TOKEN,则必须携带匹配的 ?k=<token>
# 否则返回 404 伪装该路径不存在(避免攻击者探测 /admin 是否存在)
if settings.has_admin_access_token:
provided = (k or "").strip()
expected = settings.xtc_admin_access_token
if not provided or not secrets.compare_digest(provided, expected):
return JSONResponse(
status_code=404,
content={"detail": "Not Found"},
headers=CORS_HEADERS,
)
disabled = not settings.is_admin_enabled
return HTMLResponse(content=_render_html(disabled), headers=CORS_HEADERS)
def _render_html(disabled: bool) -> str:
disabled_attr = "true" if disabled else "false"
return _HTML_TEMPLATE.replace("__DISABLED_ATTR__", disabled_attr)
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>XTC 后台管理</title>
<style>
:root {
--bg: #0f1117; --panel: #1a1d27; --panel2: #232734; --border: #2d3142;
--text: #e4e6ed; --muted: #8b8fa3; --accent: #4f8cff; --accent2: #6aa1ff;
--ok: #4ade80; --warn: #fbbf24; --err: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
}
header {
padding: 14px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header .badge { font-size: 12px; color: var(--muted); }
header .svc-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--panel2); }
.layout { display: flex; min-height: calc(100vh - 56px); }
nav.tabs {
width: 180px; background: var(--panel); border-right: 1px solid var(--border);
padding: 12px 0; flex-shrink: 0;
}
nav.tabs button {
display: block; width: 100%; text-align: left; padding: 10px 20px;
background: transparent; border: none; color: var(--muted); cursor: pointer;
font-size: 14px; border-left: 3px solid transparent;
}
nav.tabs button:hover { background: var(--panel2); color: var(--text); }
nav.tabs button.active { color: var(--accent); border-left-color: var(--accent); background: var(--panel2); }
main { flex: 1; padding: 24px; overflow: auto; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 16px 20px; margin-bottom: 16px;
}
.card h2 { margin: 0 0 12px; font-size: 15px; }
input, button, select, textarea {
font: inherit; color: var(--text); background: var(--panel2);
border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px;
}
input, textarea { width: 100%; }
button { cursor: pointer; background: var(--accent); border-color: var(--accent); }
button:hover { background: var(--accent2); }
button.ghost { background: transparent; }
button.danger { background: var(--err); border-color: var(--err); }
button.small { padding: 4px 10px; font-size: 12px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.row > input, .row > select { flex: 1; }
.row > button { flex: 0 0 auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
th { color: var(--muted); font-weight: 500; }
td .mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 12px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.tag.ok { background: rgba(74,222,128,0.15); color: var(--ok); }
.tag.off { background: rgba(139,143,163,0.15); color: var(--muted); }
.tag.warn { background: rgba(251,191,36,0.15); color: var(--warn); }
.tag.err { background: rgba(248,113,113,0.15); color: var(--err); }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--panel2); border: 1px solid var(--border); padding: 10px 16px;
border-radius: 6px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 100;
}
.toast.show { opacity: 1; }
pre { background: var(--panel2); padding: 12px; border-radius: 6px; overflow: auto; font-size: 12px; max-height: 400px; }
.muted { color: var(--muted); font-size: 12px; }
.hidden { display: none; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.stat-box { background: var(--panel2); padding: 12px 16px; border-radius: 6px; }
.stat-box .label { font-size: 12px; color: var(--muted); }
.stat-box .value { font-size: 22px; font-weight: 600; margin-top: 4px; }
.bar-chart { display: flex; align-items: flex-end; gap: 2px; height: 80px; margin-top: 8px; }
.bar { flex: 1; background: var(--accent); min-height: 2px; border-radius: 2px 2px 0 0; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.msg-bubble { padding: 8px 12px; border-radius: 8px; margin: 6px 0; max-width: 80%; }
.msg-bubble.user { background: var(--panel2); }
.msg-bubble.assistant { background: rgba(79,140,255,0.15); }
.msg-bubble.system { background: rgba(139,143,163,0.15); }
.msg-bubble .role { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.msg-bubble .thought { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }
/* ===== 增强样式 ===== */
tr:hover td { background: rgba(79,140,255,0.06); }
th { white-space: nowrap; }
td.mono, .mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 12px; word-break: break-all; }
/* 分段选择器(请求类别) */
.seg { display: inline-flex; background: var(--panel2); border: 1px solid var(--border); border-radius: 6px; padding: 2px; gap: 2px; }
.seg button { background: transparent; border: none; color: var(--muted); padding: 5px 12px; font-size: 12px; border-radius: 4px; }
.seg button:hover { color: var(--text); }
.seg button.active { background: var(--accent); color: #fff; }
/* KPI 卡片:左侧强调色条 */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.kpi { position: relative; background: var(--panel2); padding: 12px 16px 12px 18px; border-radius: 8px; overflow: hidden; }
.kpi::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
.kpi.ok::before { background: var(--ok); }
.kpi.err::before { background: var(--err); }
.kpi.warn::before { background: var(--warn); }
.kpi .label { font-size: 12px; color: var(--muted); }
.kpi .value { font-size: 22px; font-weight: 600; margin-top: 4px; }
.kpi .sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
/* 流量趋势卡片 */
.traffic-card { background: var(--panel2); border-radius: 8px; padding: 14px 16px; }
.traffic-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 10px; }
.traffic-head .big { font-size: 20px; font-weight: 600; }
.traffic-head .delta { font-size: 12px; }
.bar-chart { gap: 3px; height: 90px; }
.bar { background: linear-gradient(180deg, var(--accent2), var(--accent)); min-height: 2px; border-radius: 2px 2px 0 0; transition: height .2s; }
.bar.fail { background: linear-gradient(180deg, #f87171, #b91c1c); }
.bar-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 4px; }
/* 概览-流量分析顶部:环图 + 关键指标 */
.ov-traffic-top { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.ov-mini-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; flex: 1; min-width: 260px; }
.ov-mini { background: var(--panel2); padding: 8px 12px; border-radius: 6px; border-left: 2px solid var(--accent); }
.ov-mini .l { font-size: 11px; color: var(--muted); }
.ov-mini .v { font-size: 16px; font-weight: 600; margin-top: 2px; }
.ov-mini .muted { font-size: 10px; margin-top: 2px; }
/* 图例 */
.ov-legend { display: flex; gap: 14px; align-items: center; margin-bottom: 8px; font-size: 12px; color: var(--muted); }
.ov-legend .lg-item { display: inline-flex; align-items: center; gap: 5px; }
.ov-legend .dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.ov-legend .dot.ok { background: var(--ok); }
.ov-legend .dot.err { background: var(--err); }
/* 堆叠柱状图 */
.bar-chart.stacked { align-items: flex-end; }
.bar-stack { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; min-width: 4px; gap: 1px; }
.bar-seg { width: 100%; border-radius: 1px; transition: height .2s; }
.bar-seg.ok { background: linear-gradient(180deg, var(--accent2), var(--accent)); }
.bar-seg.fail { background: linear-gradient(180deg, #f87171, #b91c1c); }
/* 成功率环 */
.ring-wrap { display: flex; align-items: center; gap: 14px; }
.ring { position: relative; width: 84px; height: 84px; }
.ring svg { transform: rotate(-90deg); }
.ring .pct { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; }
/* 请求列表行:状态药丸、流式标记 */
.pill { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.pill.s2 { background: rgba(74,222,128,0.16); color: var(--ok); }
.pill.s3 { background: rgba(139,143,163,0.16); color: var(--muted); }
.pill.s4 { background: rgba(251,191,36,0.16); color: var(--warn); }
.pill.s5 { background: rgba(248,113,113,0.16); color: var(--err); }
.pill.stream { background: rgba(106,161,255,0.16); color: var(--accent2); margin-left: 4px; }
.filter-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.filter-row input, .filter-row select { flex: 1; min-width: 110px; }
.filter-row .grow { flex: 1; }
.filter-row .shrink { flex: 0 0 auto; }
.hint { font-size: 11px; color: var(--muted); margin-top: 6px; }
/* 详情区折叠块 */
details.req-block { background: var(--panel2); border: 1px solid var(--border); border-radius: 6px; margin: 8px 0; }
details.req-block summary { padding: 8px 12px; cursor: pointer; font-size: 13px; font-weight: 500; }
details.req-block[open] summary { border-bottom: 1px solid var(--border); }
details.req-block pre { margin: 0; border: none; border-radius: 0; max-height: 320px; }
/* ===== 设备视图 ===== */
.dev-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-top: 12px; }
.dev-card {
position: relative; background: var(--panel2); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 16px; cursor: pointer; transition: border-color .15s, transform .15s;
}
.dev-card:hover { border-color: var(--accent); transform: translateY(-1px); }
.dev-card .dev-id { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 13px; color: var(--accent); word-break: break-all; margin-bottom: 8px; font-weight: 600; }
.dev-card .dev-stats { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; }
.dev-card .dev-stats .s-item { display: flex; flex-direction: column; }
.dev-card .dev-stats .s-item .l { color: var(--muted); font-size: 11px; }
.dev-card .dev-stats .s-item .v { font-weight: 600; font-size: 15px; }
.dev-card .dev-stats .v.ok { color: var(--ok); }
.dev-card .dev-stats .v.fail { color: var(--err); }
.dev-card .dev-meta { margin-top: 10px; font-size: 11px; color: var(--muted); line-height: 1.6; }
.dev-card .dev-meta .mono { color: var(--text); opacity: .85; }
.dev-card .dev-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; border-radius: 10px 0 0 10px; background: var(--accent); }
.dev-card.is-fail .dev-bar { background: var(--err); }
.dev-card.is-warn .dev-bar { background: var(--warn); }
/* 设备请求记录条目 */
.dev-rec {
background: var(--panel2); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; margin-bottom: 10px; border-left: 3px solid var(--accent);
}
.dev-rec.fail { border-left-color: var(--err); }
.dev-rec .dev-rec-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; font-size: 12px; }
.dev-rec .dev-rec-path { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 11px; color: var(--muted); word-break: break-all; margin-bottom: 6px; }
.dev-rec .dev-rec-meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: 11px; color: var(--muted); margin-bottom: 6px; }
.dev-rec .dev-rec-meta .mono { color: var(--text); opacity: .85; }
/* 可折叠的请求/响应区 */
.dev-rec details { background: rgba(0,0,0,0.2); border-radius: 6px; margin-top: 6px; border: 1px solid var(--border); }
.dev-rec details summary { padding: 6px 10px; cursor: pointer; font-size: 12px; font-weight: 500; color: var(--accent); user-select: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.dev-rec details summary::-webkit-details-marker { color: var(--muted); }
.dev-rec details[open] summary { border-bottom: 1px solid var(--border); }
.dev-rec details .dev-rec-body { padding: 8px 10px; font-size: 13px; white-space: pre-wrap; word-break: break-word; max-height: 360px; overflow: auto; }
.dev-rec details.req details summary { color: #7dd3fc; }
.dev-rec details.resp details summary { color: #86efac; }
.dev-rec details.err summary { color: var(--err); }
.dev-rec details.err { background: rgba(248,113,113,0.06); border-color: rgba(248,113,113,0.2); }
/* 小工具按钮(下载等) */
.icon-btn {
display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; font-size: 11px;
background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 4px;
color: var(--text); cursor: pointer; line-height: 1.4;
}
.icon-btn:hover { background: rgba(255,255,255,0.12); border-color: var(--accent); }
.icon-btn .ic { font-size: 12px; }
/* ===== 响应式:手机适配 ===== */
/* 移动端菜单按钮(默认隐藏,窄屏显示) */
.menu-toggle { display: none; }
/* 表格横向滚动容器(窄屏防溢出) */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-wrap table { min-width: 480px; }
/* 窄屏(<768px):侧边栏改为可折叠抽屉,主内容全宽 */
@media (max-width: 768px) {
header { padding: 10px 12px; gap: 8px; }
header h1 { font-size: 16px; }
.menu-toggle {
display: inline-block;
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
padding: 6px 10px; border-radius: 6px; font-size: 18px; line-height: 1;
cursor: pointer; flex: 0 0 auto;
}
.layout { flex-direction: column; }
nav.tabs {
width: 100%; flex-shrink: 1; flex-wrap: wrap; gap: 4px;
padding: 0 8px 8px; border-right: none; border-bottom: 1px solid var(--border);
display: none; /* 默认折叠,点菜单按钮展开 */
}
nav.tabs.open { display: flex; }
nav.tabs button { flex: 1 1 auto; text-align: center; }
main { padding: 12px; }
/* KPI/stats 网格窄屏降为 2 列 */
.kpi-grid, .stats-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.kpi .value { font-size: 18px; }
/* filter-row 控件窄屏单列堆叠 */
.filter-row { gap: 6px; }
.filter-row input, .filter-row select, .filter-row .grow, .filter-row .shrink {
flex: 1 1 100%; min-width: 0;
}
.filter-row button { flex: 1 1 calc(50% - 3px); }
/* 分段选择器窄屏允许换行 */
.seg { flex-wrap: wrap; width: 100%; }
.seg button { flex: 1 1 auto; }
/* 流量分析横排窄屏换行 */
.ring-wrap, .traffic-head { flex-wrap: wrap; }
.bar-chart { height: 70px; }
.msg-bubble { max-width: 95%; }
/* 卡片内边距收紧 */
.card { padding: 12px; }
/* 设备卡片窄屏单列 */
.dev-grid { grid-template-columns: 1fr; }
.dev-card .dev-stats { gap: 10px; }
}
/* 极窄屏(<400px):KPI 单列 */
@media (max-width: 400px) {
.kpi-grid, .stats-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body data-disabled="__DISABLED_ATTR__">
<header>
<button class="menu-toggle" onclick="toggleNav()" aria-label="菜单">≡</button>
<h1>XTC 后台管理</h1>
<div class="row" style="gap:8px">
<span class="svc-status" id="svcStatus">服务: 检测中</span>
<span class="badge">Hugging Face 版</span>
</div>
</header>
<div class="layout">
<nav class="tabs">
<button class="active" data-tab="overview">概览</button>
<button data-tab="providers">厂商</button>
<button data-tab="tokens">令牌</button>
<button data-tab="sessions">会话</button>
<button data-tab="users">用户</button>
<button data-tab="files">文件</button>
<button data-tab="backups">备份</button>
<button data-tab="usage">用量</button>
<button data-tab="webhooks">Webhook</button>
<button data-tab="audit">审计</button>
<button data-tab="requests">请求历史</button>
<button data-tab="devices">设备</button>
<button data-tab="tts">TTS</button>
<button data-tab="test">测试</button>
</nav>
<main>
<!-- 登录 -->
<div class="card" id="loginCard">
<h2>登录</h2>
<div class="row">
<input id="adminKey" type="password" placeholder="XTC_ADMIN_KEY">
<button onclick="doLogin()">登录</button>
</div>
<p class="muted">登录后才能使用以下功能。admin key 会保存在 localStorage。</p>
</div>
<div id="appBody" class="hidden">
<!-- 概览 -->
<div class="tab-content active" data-tab="overview">
<div class="card">
<h2>系统概览</h2>
<div class="kpi-grid" id="overviewStats"><div class="muted">加载中...</div></div>
</div>
<div class="card">
<h2>最近 24 小时流量请求分析</h2>
<div id="overviewTraffic"><div class="muted">加载中...</div></div>
</div>
</div>
<!-- 厂商 -->
<div class="tab-content" data-tab="providers">
<div class="card">
<h2>厂商列表</h2>
<div id="providersList">加载中...</div>
</div>
<div class="card hidden" id="providerEditCard">
<h2>编辑厂商 <span id="epIdTag" class="mono" style="color:var(--accent)"></span>
<button class="ghost small" onclick="cancelProviderEdit()">取消</button>
</h2>
<div class="row">
<input id="epId" type="hidden">
<input id="epName" placeholder="名称">
<select id="epType"><option value="gemini">gemini</option><option value="openai">openai</option></select>
<label class="shrink" style="display:flex;align-items:center;gap:4px"><input type="checkbox" id="epEnabled" style="width:auto"> 启用</label>
</div>
<div class="row" style="margin-top:8px">
<input id="epBaseUrl" placeholder="base_url(openai 类型必填)">
<input id="epPrefix" placeholder="model_prefix(可空)">
</div>
<div class="row" style="margin-top:8px">
<input id="epKeys" placeholder="api_keys(逗号分隔;留空则不修改密钥)">
<button onclick="saveProviderEdit()">保存</button>
</div>
<div class="card" style="margin-top:12px;background:rgba(255,255,255,0.03);padding:10px 12px">
<h3 style="margin:0 0 6px">模型策略(黑名单 / 白名单)</h3>
<p class="muted" style="margin:0 0 8px">支持 <code>*</code> 通配(如 <code>gpt-4o*</code>)。黑名单=默认允许、禁用列出的;白名单=默认禁用、仅允许列出的。两者都留空则不限制。</p>
<div class="row">
<label class="shrink" style="display:flex;align-items:center;gap:6px">模式
<select id="epPolicyMode"><option value="denylist">黑名单(默认允许)</option><option value="allowlist">白名单(仅允许)</option></select>
</label>
</div>
<div class="row" style="margin-top:8px">
<textarea id="epAllow" placeholder="允许模型(每行一个,支持 * 通配;白名单模式生效,黑名单模式仅作参考)" style="min-height:72px;width:100%"></textarea>
</div>
<div class="row" style="margin-top:8px">
<textarea id="epDeny" placeholder="禁用模型(每行一个,支持 * 通配;黑名单模式生效,白名单模式仅作参考)" style="min-height:72px;width:100%"></textarea>
</div>
</div>
</div>
<div class="card">
<h2>添加厂商</h2>
<div class="row">
<input id="pId" placeholder="id(如 gemini)">
<input id="pName" placeholder="名称">
<select id="pType"><option value="gemini">gemini</option><option value="openai">openai</option></select>
</div>
<div class="row" style="margin-top:8px">
<input id="pBaseUrl" placeholder="base_url(openai 类型必填)">
<input id="pPrefix" placeholder="model_prefix(可空)">
</div>
<div class="row" style="margin-top:8px">
<input id="pKeys" placeholder="api_keys(逗号分隔多个)">
</div>
<div class="card" style="margin-top:8px;background:rgba(255,255,255,0.03);padding:8px 12px">
<h3 style="margin:0 0 6px">模型策略(可选,添加后可随时在编辑页修改)</h3>
<div class="row">
<label class="shrink" style="display:flex;align-items:center;gap:6px">模式
<select id="pPolicyMode"><option value="denylist">黑名单(默认允许)</option><option value="allowlist">白名单(仅允许)</option></select>
</label>
</div>
<div class="row" style="margin-top:8px">
<textarea id="pAllow" placeholder="允许模型(每行一个,支持 * 通配)" style="min-height:60px;width:100%"></textarea>
</div>
<div class="row" style="margin-top:8px">
<textarea id="pDeny" placeholder="禁用模型(每行一个,支持 * 通配)" style="min-height:60px;width:100%"></textarea>
</div>
</div>
<div class="row" style="margin-top:8px">
<button onclick="addProvider()">添加</button>
</div>
</div>
<div class="card">
<h2>导入配置</h2>
<p class="muted">从导出的 JSON 文件导入完整配置(会覆盖当前配置)。</p>
<div class="row">
<input type="file" id="importConfigFile" accept=".json,application/json" style="flex:1">
<button onclick="importConfig()">导入</button>
</div>
</div>
</div>
<!-- 令牌 -->
<div class="tab-content" data-tab="tokens">
<div class="card">
<h2>临时访问令牌</h2>
<div class="row">
<button onclick="issueToken()">签发新令牌</button>
<button class="ghost" onclick="loadTokens()">刷新</button>
</div>
<div id="tokensList" class="muted" style="margin-top:12px">加载中...</div>
</div>
</div>
<!-- 会话 -->
<div class="tab-content" data-tab="sessions">
<div class="card">
<h2>会话管理(全用户)</h2>
<div class="row" style="margin-bottom:12px">
<button class="ghost" onclick="loadSessions()">刷新</button>
</div>
<div id="sessionsList">加载中...</div>
</div>
<div class="card hidden" id="sessionDetailCard">
<h2>会话详情 <button class="ghost small" onclick="closeSessionDetail()">关闭</button></h2>
<div id="sessionMessages"></div>
</div>
</div>
<!-- 用户 -->
<div class="tab-content" data-tab="users">
<div class="card">
<h2>注册用户管理</h2>
<div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
<input id="usersQ" placeholder="按用户名搜索(可空)" style="flex:1;min-width:160px">
<button onclick="loadUsers()">查询</button>
<button class="ghost" onclick="loadUsers()">刷新</button>
<button class="ghost" onclick="syncFromHub('users', loadUsers)" title="从 Hub 远端强制全量恢复用户列表(解决 Space 重建后 SQLite 为空的问题)">从 Hub 同步</button>
</div>
<p class="hint">说明:HF Space 重启后 SQLite 内存数据会丢失,列表自动从 Hub 备份恢复(60 秒内只触发一次)。若数据不全,点"从 Hub 同步"强制全量恢复。</p>
<div id="usersList">加载中...</div>
</div>
</div>
<!-- 文件 -->
<div class="tab-content" data-tab="files">
<div class="card">
<h2>文件管理(全部用户)</h2>
<div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
<input id="filesQ" placeholder="搜索别名/文件名/账号" style="flex:1;min-width:160px">
<input id="filesUser" placeholder="用户ID过滤" style="width:120px">
<button onclick="loadFiles()">查询</button>
<button class="ghost" onclick="loadFiles()">刷新</button>
<button class="ghost" onclick="syncFromHub('files', loadFiles)" title="从 Hub 远端强制全量恢复文件元信息">从 Hub 同步</button>
</div>
<p class="hint">说明:Space 重启后首次访问会自动从 Hub 同步,60 秒内不重复拉取。若列表为空且确认有数据,点"从 Hub 同步"强制刷新。</p>
<div id="filesList">点查询开始</div>
</div>
</div>
<!-- 备份 -->
<div class="tab-content" data-tab="backups">
<div class="card">
<h2>备份管理(全部用户)</h2>
<div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
<input id="backupsQ" placeholder="搜索别名/账号" style="flex:1;min-width:160px">
<input id="backupsUser" placeholder="用户ID过滤" style="width:120px">
<button onclick="loadBackups()">查询</button>
<button class="ghost" onclick="loadBackups()">刷新</button>
<button class="ghost" onclick="syncFromHub('backups', loadBackups)" title="从 Hub 远端强制全量恢复备份元信息">从 Hub 同步</button>
</div>
<p class="hint">说明:Space 重启后首次访问会自动从 Hub 同步,60 秒内不重复拉取。若列表为空且确认有数据,点"从 Hub 同步"强制刷新。</p>
<div id="backupsList">点查询开始</div>
</div>
</div>
<!-- 用量 -->
<div class="tab-content" data-tab="usage">
<div class="card">
<h2>用量统计</h2>
<div class="row" style="margin-bottom:12px">
<label>最近</label>
<select id="usageHours">
<option value="1">1 小时</option>
<option value="24" selected>24 小时</option>
<option value="168">7 天</option>
<option value="720">30 天</option>
</select>
<button onclick="loadUsage()">查询</button>
</div>
<div class="stats-grid" id="usageStats"><div class="muted">点查询开始</div></div>
</div>
<div class="card">
<h2>按厂商</h2>
<div id="usageByProvider"></div>
</div>
<div class="card">
<h2>按模型(Top 10)</h2>
<div id="usageByModel"></div>
</div>
<div class="card">
<h2>错误码分布</h2>
<div id="usageByError"></div>
</div>
<div class="card">
<h2>时间线</h2>
<div id="usageTimeline"></div>
</div>
</div>
<!-- Webhook -->
<div class="tab-content" data-tab="webhooks">
<div class="card">
<h2>Webhook 配置</h2>
<div class="row" style="margin-bottom:12px">
<button class="ghost" onclick="loadWebhooks()">刷新</button>
</div>
<div id="webhooksList">加载中...</div>
</div>
<div class="card">
<h2>添加 Webhook</h2>
<div class="row">
<input id="whName" placeholder="名称">
<input id="whUrl" placeholder="https://...">
</div>
<div class="row" style="margin-top:8px">
<input id="whEvents" placeholder="事件(逗号分隔,如 file.uploaded,chat.completed)">
<label><input type="checkbox" id="whEnabled" checked style="width:auto"> 启用</label>
</div>
<div class="row" style="margin-top:8px">
<button onclick="addWebhook()">添加</button>
</div>
<p class="muted">可选事件:file.uploaded / session.created / session.deleted / config.updated / chat.completed / chat.failed / test.ping / *</p>
</div>
</div>
<!-- 审计 -->
<div class="tab-content" data-tab="audit">
<div class="card">
<h2>审计日志</h2>
<div class="row" style="margin-bottom:12px">
<label>最近</label>
<select id="auditHours">
<option value="1">1 小时</option>
<option value="24" selected>24 小时</option>
<option value="168">7 天</option>
<option value="720">30 天</option>
</select>
<input id="auditAction" placeholder="action 过滤(可空)" style="flex:1">
<button onclick="loadAudit()">查询</button>
</div>
<div id="auditList">点查询开始</div>
</div>
</div>
<!-- 请求历史 -->
<div class="tab-content" data-tab="requests">
<div class="card">
<h2>请求历史 <span id="reqLogLimit" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
<div class="filter-row">
<div class="seg shrink" id="reqCategorySeg" role="group" aria-label="请求类别">
<button class="active" data-cat="business" onclick="setReqCategory('business')">业务请求 /v1/*</button>
<button data-cat="admin" onclick="setReqCategory('admin')">管理面板 /admin/api/*</button>
<button data-cat="all" onclick="setReqCategory('all')">全部</button>
</div>
<input id="reqPath" class="grow" placeholder="路径模糊匹配">
<select id="reqMethod" class="shrink">
<option value="">所有方法</option>
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>OPTIONS</option>
</select>
<select id="reqStatus" class="shrink">
<option value="">所有状态</option>
<option value="2xx">2xx 成功</option>
<option value="3xx">3xx 重定向</option>
<option value="4xx">4xx 客户端错误</option>
<option value="5xx">5xx 服务端错误</option>
</select>
<select id="reqOk" class="shrink">
<option value="">全部</option>
<option value="1">仅成功</option>
<option value="0">仅失败</option>
</select>
<input id="reqKeyword" class="grow" placeholder="关键词(路径/错误)">
<button class="shrink" onclick="loadRequests(1)">查询</button>
<button class="ghost shrink" onclick="loadRequests(1)">刷新</button>
<button class="ghost shrink" onclick="clearRequests()">清空</button>
</div>
<div class="hint" id="reqCategoryHint">默认只看业务请求(对话 / 伪流式 / OpenAI 兼容等 <code>/v1/*</code> 接口),管理面板自身的访问不在其中。切换上方分段可查看全部。</div>
<div id="reqList">点查询开始</div>
<div class="row" style="margin-top:12px;justify-content:space-between">
<div class="muted" id="reqPageInfo"></div>
<div class="row" style="gap:6px">
<button class="ghost small" onclick="reqPrev()">上一页</button>
<button class="ghost small" onclick="reqNext()">下一页</button>
</div>
</div>
</div>
<div class="card hidden" id="reqDetailCard">
<h2>请求详情 #<span id="reqDetailId"></span>
<button class="ghost small" onclick="closeReqDetail()">关闭</button>
<button class="danger small" onclick="delReqDetail()">删除此条</button>
</h2>
<div id="reqDetailBody">加载中...</div>
</div>
<div class="card">
<h2>容量设置</h2>
<div class="row" style="gap:6px">
<span class="muted">保留最近</span>
<select id="reqLimitSetting">
<option value="0">关闭记录</option>
<option value="100">100 条</option>
<option value="200">200 条</option>
<option value="500">500 条</option>
<option value="1000">1000 条</option>
<option value="2000">2000 条</option>
</select>
<span class="muted">条请求</span>
<button onclick="saveReqLimit()">保存</button>
</div>
<p class="muted">0 = 完全关闭请求历史记录;超过上限的旧记录会被自动轮转删除。</p>
</div>
<div class="card">
<h2>统计(最近 24 小时)<span id="reqStatsCatLabel" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
<div id="reqStats"><div class="muted">加载中...</div></div>
</div>
</div>
<!-- 设备 -->
<div class="tab-content" data-tab="devices">
<div class="card">
<h2>设备列表 <span id="devCount" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
<div class="filter-row">
<label class="shrink">最近</label>
<select id="devHours" class="shrink">
<option value="">全部</option>
<option value="1">1 小时</option>
<option value="24" selected>24 小时</option>
<option value="168">7 天</option>
<option value="720">30 天</option>
</select>
<input id="devKeyword" class="grow" placeholder="设备 ID 模糊搜索">
<button class="shrink" onclick="loadDevices(1)">查询</button>
<button class="ghost shrink" onclick="loadDevices(_devPage)">刷新</button>
</div>
<div class="hint">客户端在请求头携带 <code>x-xtc-device-id</code>(或 query 参数 <code>device_id</code>)即会被记录。下方按设备聚合统计。</div>
<div id="devList" class="dev-grid"><div class="muted">点查询开始</div></div>
<div class="row" style="margin-top:12px;justify-content:space-between">
<div class="muted" id="devPageInfo"></div>
<div class="row" style="gap:6px">
<button class="ghost small" onclick="devPrev()">上一页</button>
<button class="ghost small" onclick="devNext()">下一页</button>
</div>
</div>
</div>
<div class="card hidden" id="deviceDetailCard">
<h2>设备 <span id="devDetailId" class="mono" style="color:var(--accent)"></span>
<button class="ghost small" onclick="closeDeviceDetail()">关闭</button>
<button class="ghost small" onclick="copyDeviceId()">复制 ID</button>
<button class="danger small" onclick="clearDeviceRecords()">清空该设备记录</button>
</h2>
<div id="devDetailSummary" class="kpi-grid" style="margin-bottom:14px"></div>
<h3 style="margin:8px 0;font-size:14px">最近请求</h3>
<div class="filter-row">
<label class="shrink">最近</label>
<select id="devDetailHours" class="shrink" onchange="loadDeviceRecords(_curDeviceId, 1)">
<option value="">全部</option>
<option value="1">1 小时</option>
<option value="24" selected>24 小时</option>
<option value="168">7 天</option>
</select>
<select id="devDetailScope" class="shrink" title="筛选范围">
<option value="both">全部文本</option>
<option value="req">仅请求文本</option>
<option value="resp">仅响应文本</option>
</select>
<input id="devDetailKeyword" class="grow" placeholder="关键词搜索(请求/响应文本)" onkeydown="if(event.key==='Enter'){loadDeviceRecords(_curDeviceId,1)}">
<button class="shrink" onclick="loadDeviceRecords(_curDeviceId, 1)">搜索</button>
<button class="ghost shrink" onclick="clearDevRecFilter()">重置筛选</button>
<span class="shrink muted" id="devRecPageInfo"></span>
<button class="ghost small shrink" onclick="devRecPrev()">上一页</button>
<button class="ghost small shrink" onclick="devRecNext()">下一页</button>
</div>
<div id="devRecList"><div class="muted">加载中...</div></div>
</div>
</div>
<!-- 测试 -->
<div class="tab-content" data-tab="tts">
<div class="card">
<h2>TTS 朗读设置</h2>
<p class="muted" style="margin:0 0 10px;font-size:13px">基于 edge-tts(微软 Edge 在线 TTS,免费无需 Key)。此处为默认参数,手表端调用 /v1/xtc/tts/speech 时可覆盖。</p>
<div class="row">
<label class="shrink" style="display:flex;align-items:center;gap:6px;min-width:80px">音色</label>
<select id="ttsVoice" class="grow">
<option value="zh-CN-XiaoxiaoNeural">晓晓(女,常用)</option>
<option value="zh-CN-XiaoyiNeural">晓伊(女)</option>
<option value="zh-CN-YunxiNeural">云希(男)</option>
<option value="zh-CN-YunyangNeural">云扬(男)</option>
<option value="zh-CN-YunjianNeural">云健(男)</option>
<option value="zh-CN-YunxiaNeural">云夏(男)</option>
<option value="zh-CN-liaoning-XiaobeiNeural">晓贝(女,东北话)</option>
<option value="zh-CN-shaanxi-XiaoniNeural">晓妮(女,陕西话)</option>
</select>
</div>
<div class="row" style="margin-top:8px">
<label class="shrink" style="display:flex;align-items:center;gap:6px;min-width:80px">语速</label>
<input id="ttsRate" class="grow" placeholder="如 +10% / -20%(-100% ~ +100%)">
</div>
<div class="row" style="margin-top:8px">
<label class="shrink" style="display:flex;align-items:center;gap:6px;min-width:80px">响度</label>
<input id="ttsVolume" class="grow" placeholder="如 +20% / -10%(-100% ~ +100%)">
</div>
<div class="row" style="margin-top:8px">
<label class="shrink" style="display:flex;align-items:center;gap:6px;min-width:80px">音调</label>
<input id="ttsPitch" class="grow" placeholder="如 +10Hz / -5Hz(-50 ~ +50Hz)">
</div>
<div class="row" style="margin-top:10px">
<button onclick="saveTtsSettings()">保存设置</button>
</div>
</div>
<div class="card">
<h2>TTS 测试</h2>
<p class="muted" style="margin:0 0 10px;font-size:13px">输入文本试听效果(用上方设置的默认参数;留空的参数用默认值)。生成的音频仅本次播放,不存储。</p>
<textarea id="ttsTestText" rows="4" placeholder="输入要朗读的文本,例如:你好,这是语音测试。"></textarea>
<div class="row" style="margin-top:8px">
<label class="shrink" style="display:flex;align-items:center;gap:6px;min-width:80px">音色</label>
<select id="ttsTestVoice" class="grow">
<option value="">(用默认设置)</option>
<option value="zh-CN-XiaoxiaoNeural">晓晓(女)</option>
<option value="zh-CN-XiaoyiNeural">晓伊(女)</option>
<option value="zh-CN-YunxiNeural">云希(男)</option>
<option value="zh-CN-YunyangNeural">云扬(男)</option>
<option value="zh-CN-YunjianNeural">云健(男)</option>
<option value="zh-CN-YunxiaNeural">云夏(男)</option>
</select>
</div>
<div class="row" style="margin-top:8px">
<button onclick="runTtsTest()" id="ttsTestBtn">生成并试听</button>
<button class="ghost" onclick="stopTtsTest()">停止</button>
</div>
<div id="ttsTestStatus" class="muted" style="margin-top:8px;font-size:13px"></div>
<audio id="ttsTestAudio" controls style="width:100%;margin-top:8px;display:none"></audio>
</div>
</div>
<div class="tab-content" data-tab="test">
<div class="card">
<h2>对话测试</h2>
<div class="row">
<input id="tProvider" placeholder="provider id(可空)">
<input id="tModel" placeholder="model">
<select id="tMode"><option value="xtc">xtc</option><option value="openai">openai</option></select>
</div>
<div class="row" style="margin-top:8px">
<input id="tDeviceId" placeholder="device_id(可空,测试设备统计)">
</div>
<textarea id="tMessages" rows="6" style="margin-top:8px" placeholder='[{"role":"user","content":"你好"}]'></textarea>
<div class="row" style="margin-top:8px">
<button onclick="runTest()">测试</button>
</div>
<pre id="tResult" class="hidden"></pre>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
let ADMIN_KEY = localStorage.getItem('xtc_admin_key') || '';
function toast(msg, isErr) {
const t = $('toast');
t.textContent = msg;
t.style.borderColor = isErr ? 'var(--err)' : 'var(--border)';
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2500);
}
async function api(path, opts={}) {
opts.headers = opts.headers || {};
opts.headers['x-xtc-admin-key'] = ADMIN_KEY;
if (opts.body && typeof opts.body !== 'string') {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
const r = await fetch(path, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok === false) {
const msg = (data.error && data.error.message) || ('HTTP ' + r.status);
throw new Error(msg);
}
return data;
}
function fmtBytes(n) {
if (!n) return '0 B';
const u = ['B','KB','MB','GB','TB'];
let i = 0; let v = n;
while (v >= 1024 && i < u.length-1) { v /= 1024; i++; }
return v.toFixed(i?1:0) + ' ' + u[i];
}
function fmtTime(ts) {
if (!ts) return '-';
return new Date(ts*1000).toLocaleString('zh-CN', {hour12:false});
}
function esc(s) {
return String(s==null?'':s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// 手机端侧边栏抽屉开关
function toggleNav() {
document.querySelector('nav.tabs').classList.toggle('open');
}
// 自动给裸 table 包裹横向滚动容器(窄屏防溢出)。
// 用 MutationObserver 监听动态插入的表格,避免逐处改 innerHTML。
(function wrapTables() {
function wrapAll(root) {
root.querySelectorAll('table').forEach(t => {
if (t.parentElement && t.parentElement.classList.contains('table-wrap')) return;
const w = document.createElement('div');
w.className = 'table-wrap';
t.parentNode.insertBefore(w, t);
w.appendChild(t);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
wrapAll(document);
new MutationObserver(muts => muts.forEach(m => m.addedNodes.forEach(n => {
if (n.nodeType === 1) wrapAll(n);
}))).observe(document.body, { childList: true, subtree: true });
});
} else {
wrapAll(document);
}
})();
document.querySelectorAll('nav.tabs button').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('nav.tabs button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.dataset.tab === tab));
// 手机端选完 tab 后自动收起侧边栏
document.querySelector('nav.tabs').classList.remove('open');
if (tab === 'overview') loadOverview();
else if (tab === 'providers') loadProviders();
else if (tab === 'tokens') loadTokens();
else if (tab === 'sessions') loadSessions();
else if (tab === 'users') loadUsers();
else if (tab === 'files') loadFiles();
else if (tab === 'backups') loadBackups();
else if (tab === 'webhooks') loadWebhooks();
else if (tab === 'requests') { loadReqLimit(); loadRequests(1); loadReqStats(); }
else if (tab === 'devices') loadDevices(1);
else if (tab === 'tts') loadTtsSettings();
};
});
async function doLogin() {
ADMIN_KEY = $('adminKey').value.trim();
if (!ADMIN_KEY) return toast('请输入 admin key', true);
try {
await api('/admin/api/login', { method: 'POST', body: { admin_key: ADMIN_KEY } });
localStorage.setItem('xtc_admin_key', ADMIN_KEY);
$('loginCard').classList.add('hidden');
$('appBody').classList.remove('hidden');
checkSvcStatus();
loadOverview();
toast('登录成功');
} catch (e) {
toast('登录失败:' + e.message, true);
}
}
async function checkSvcStatus() {
try {
const r = await fetch('/health');
const d = await r.json();
$('svcStatus').textContent = '服务运行中';
} catch (e) {
$('svcStatus').textContent = '健康检查失败';
}
}
// ===== 概览 =====
async function loadOverview() {
try {
const [usage, files, backups, sessions, providers, users] = await Promise.all([
api('/admin/api/usage?hours=24'),
api('/admin/api/files?limit=1&namespace=user_files'),
api('/admin/api/backups?limit=1'),
api('/admin/api/sessions?limit=1'),
api('/admin/api/providers'),
api('/admin/api/users?limit=1'),
]);
const u = usage;
const successRate = u.total_requests ? Math.round(u.success / u.total_requests * 100) : 0;
const kpi = (cls, label, value, sub) => `<div class="kpi ${cls||''}"><div class="label">${label}</div><div class="value">${value}</div>${sub?`<div class="sub">${sub}</div>`:''}</div>`;
$('overviewStats').innerHTML = [
kpi('', '24h 请求', u.total_requests, '成功 ' + u.success + ' / 失败 ' + u.failed),
kpi(successRate>=95?'ok':successRate>=80?'warn':'err', '成功率', successRate + '%'),
kpi('ok', '成功', u.success),
kpi(u.failed>0?'err':'', '失败', u.failed),
kpi('', '输入 Token', u.prompt_tokens, 'Prompt'),
kpi('', '输出 Token', u.completion_tokens, 'Completion'),
kpi('', 'Token 总量', u.total_tokens, '输入 + 输出'),
kpi('', '文件数', files.count),
kpi('', '备份数', backups.count),
kpi('', '会话数', sessions.count),
kpi('', '用户数', users.count),
kpi('', '厂商数', providers.count),
].join('');
loadOverviewTraffic();
} catch (e) { $('overviewStats').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function loadOverviewTraffic() {
const el = $('overviewTraffic');
try {
const [timeline, usage] = await Promise.all([
api('/admin/api/usage/timeline?hours=24&granularity=hour'),
api('/admin/api/usage?hours=24'),
]);
const items = timeline.items || [];
if (!items.length) { el.innerHTML = '<div class="muted">最近 24 小时无请求</div>'; return; }
const maxReq = Math.max(...items.map(i => i.requests), 1);
const total = items.reduce((s,i) => s + (i.requests||0), 0);
const promptTokens = items.reduce((s,i) => s + (i.prompt_tokens||0), 0);
const completionTokens = items.reduce((s,i) => s + (i.completion_tokens||0), 0);
const totalTokens = items.reduce((s,i) => s + (i.tokens||0), 0);
const okTotal = items.reduce((s,i) => s + (i.success||0), 0);
const failTotal = items.reduce((s,i) => s + (i.failed||0), 0);
const rate = total ? Math.round(okTotal / total * 100) : 0;
// 找峰值小时
let peak = items[0];
for (const it of items) { if ((it.requests||0) > (peak.requests||0)) peak = it; }
const peakTime = new Date(peak.bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
// 顶部汇总区:成功率环 + 关键指标
const R = 36, C = 2 * Math.PI * R, off = C * (1 - rate/100);
const ringCls = rate >= 95 ? 'var(--ok)' : rate >= 80 ? 'var(--warn)' : 'var(--err)';
let html = '<div class="ov-traffic-top">';
html += '<div class="ring-wrap">';
html += '<div class="ring"><svg width="84" height="84"><circle cx="42" cy="42" r="' + R + '" fill="none" stroke="var(--border)" stroke-width="8"/><circle cx="42" cy="42" r="' + R + '" fill="none" stroke="' + ringCls + '" stroke-width="8" stroke-linecap="round" stroke-dasharray="' + C + '" stroke-dashoffset="' + off + '"/></svg><div class="pct">' + rate + '%</div></div>';
html += '<div><div class="muted">24h 成功率</div><div style="font-size:13px;margin-top:4px">成功 <span style="color:var(--ok)">' + okTotal + '</span> · 失败 <span style="color:var(--err)">' + failTotal + '</span></div><div class="muted" style="margin-top:4px">共 ' + total + ' 次请求</div></div>';
html += '</div>';
html += '<div class="ov-mini-stats">';
html += '<div class="ov-mini"><div class="l">峰值小时</div><div class="v">' + peakTime + '</div><div class="muted">' + (peak.requests||0) + ' 次</div></div>';
html += '<div class="ov-mini"><div class="l">平均/小时</div><div class="v">' + (total / items.length).toFixed(1) + '</div><div class="muted">次请求</div></div>';
html += '<div class="ov-mini"><div class="l">输入 Token</div><div class="v" style="color:var(--accent2)">' + promptTokens.toLocaleString() + '</div></div>';
html += '<div class="ov-mini"><div class="l">输出 Token</div><div class="v" style="color:var(--ok)">' + completionTokens.toLocaleString() + '</div></div>';
html += '<div class="ov-mini"><div class="l">Token 总量</div><div class="v">' + totalTokens.toLocaleString() + '</div></div>';
html += '</div>';
html += '</div>';
// 图例(成功 / 失败)
html += '<div class="ov-legend"><span class="lg-item"><i class="dot ok"></i>成功</span><span class="lg-item"><i class="dot err"></i>失败</span><span class="muted" style="margin-left:auto">悬停柱条查看详情</span></div>';
// 堆叠柱状图:每根柱由「成功(下) + 失败(上)」两段组成
const bars = items.map(i => {
const req = i.requests || 0;
const ok = i.success || 0;
const fail = i.failed || 0;
const totalH = Math.max(2, Math.round(req / maxReq * 90));
const okH = req ? Math.round(ok / req * totalH) : 0;
const failH = Math.max(0, totalH - okH);
const t = new Date(i.bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
const tip = t + ' | 共 ' + req + ' 次(成功 ' + ok + ' / 失败 ' + fail + ')\\nToken: ' + (i.tokens||0) + '(输入 ' + (i.prompt_tokens||0) + ' / 输出 ' + (i.completion_tokens||0) + ')';
return '<div class="bar-stack" title="' + esc(tip) + '">' +
(failH > 0 ? '<div class="bar-seg fail" style="height:' + failH + 'px"></div>' : '') +
'<div class="bar-seg ok" style="height:' + okH + 'px"></div>' +
'</div>';
}).join('');
const first = new Date(items[0].bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
const last = new Date(items[items.length-1].bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
html += '<div class="traffic-card"><div class="traffic-head"><span class="big">' + total + '</span><span class="delta">次请求 / 峰值 ' + (peak.requests||0) + ' @ ' + peakTime + '</span></div><div class="bar-chart stacked">' + bars + '</div><div class="bar-axis"><span>' + first + '</span><span>' + last + '</span></div></div>';
// 每小时明细表(折叠,默认显示前 12 行)
const rows = items.slice().reverse().map(i => {
const t = new Date(i.bucket_start*1000).toLocaleString('zh-CN',{hour:'2-digit',minute:'2-digit',month:'2-digit',day:'2-digit'});
const req = i.requests || 0;
const ok = i.success || 0;
const fail = i.failed || 0;
const r = req ? Math.round(ok/req*100) : 0;
const rcls = r >= 95 ? 'ok' : r >= 80 ? 'warn' : 'err';
return '<tr><td class="mono">' + t + '</td><td>' + req + '</td><td style="color:var(--ok)">' + ok + '</td><td style="color:var(--err)">' + fail + '</td><td><span class="pill s2 ' + (rcls==='ok'?'s2':rcls==='warn'?'s4':'s5') + '">' + r + '%</span></td><td>' + (i.prompt_tokens||0).toLocaleString() + '</td><td>' + (i.completion_tokens||0).toLocaleString() + '</td><td>' + (i.tokens||0).toLocaleString() + '</td></tr>';
}).join('');
html += '<details class="req-block" style="margin-top:10px"><summary>每小时明细(共 ' + items.length + ' 个时间桶)</summary><table><tr><th>时间</th><th>请求</th><th>成功</th><th>失败</th><th>成功率</th><th>输入 Token</th><th>输出 Token</th><th>Token 总量</th></tr>' + rows + '</table></details>';
el.innerHTML = html;
} catch (e) { el.innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function loadTimeline(elId, hours, gran) {
try {
const data = await api(`/admin/api/usage/timeline?hours=${hours}&granularity=${gran}`);
const items = data.items || [];
if (!items.length) { $(elId).innerHTML = '<div class="muted">无数据</div>'; return; }
const maxReq = Math.max(...items.map(i => i.requests), 1);
const totalReq = items.reduce((s,i) => s + (i.requests||0), 0);
const totalTok = items.reduce((s,i) => s + (i.tokens||0), 0);
const bars = items.map(i => {
const req = i.requests || 0;
const ok = i.success || 0;
const fail = i.failed || 0;
const totalH = Math.max(2, Math.round(req / maxReq * 80));
const okH = req ? Math.round(ok / req * totalH) : 0;
const failH = Math.max(0, totalH - okH);
const t = new Date(i.bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
const tip = t + ' | 共 ' + req + ' 次(成功 ' + ok + ' / 失败 ' + fail + ')\\nToken: ' + (i.tokens||0) + '(输入 ' + (i.prompt_tokens||0) + ' / 输出 ' + (i.completion_tokens||0) + ')';
return '<div class="bar-stack" title="' + esc(tip) + '">' +
(failH > 0 ? '<div class="bar-seg fail" style="height:' + failH + 'px"></div>' : '') +
'<div class="bar-seg ok" style="height:' + okH + 'px"></div>' +
'</div>';
}).join('');
const first = new Date(items[0].bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
const last = new Date(items[items.length-1].bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
$(elId).innerHTML = '<div class="ov-legend"><span class="lg-item"><i class="dot ok"></i>成功</span><span class="lg-item"><i class="dot err"></i>失败</span><span class="muted" style="margin-left:auto">共 ' + totalReq + ' 次 · ' + totalTok.toLocaleString() + ' tokens</span></div><div class="bar-chart stacked">' + bars + '</div><div class="bar-axis"><span>' + first + '</span><span>' + last + '</span></div><div class="muted" style="margin-top:4px">最近 ' + items.length + ' 个时间桶(' + gran + ')</div>';
} catch (e) { $(elId).innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
// ===== 厂商 =====
// 把 textarea 的内容拆成数组:兼容换行 / 逗号 / 分号,去重去空
function splitModelList(text) {
return [...new Set(String(text || '')
.replace(/\\r/g, '')
.split(/[\\n,;]+/)
.map(s => s.trim())
.filter(Boolean))];
}
function joinModelList(arr) {
return (Array.isArray(arr) ? arr : []).join('\\n');
}
function policyModeLabel(mode) {
if (mode === 'allowlist') return '<span class="tag ok">白名单</span>';
if (mode === 'denylist') return '<span class="tag">黑名单</span>';
return '<span class="muted">—</span>';
}
async function loadProviders() {
try {
const data = await api('/admin/api/providers');
const items = data.providers || [];
const defId = data.defaultProviderId || '';
if (!items.length) { $('providersList').innerHTML = '<div class="muted">暂无厂商,请在下方添加</div>'; return; }
$('providersList').innerHTML = '<table><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>模型策略</th><th>base_url</th><th>prefix</th><th>密钥</th><th>操作</th></tr>' +
items.map(p => {
const allowN = (p.allow_models || []).length;
const denyN = (p.deny_models || []).length;
const policyTxt = policyModeLabel(p.model_policy_mode) +
(allowN ? ` <span class="muted">白${allowN}</span>` : '') +
(denyN ? ` <span class="muted">黑${denyN}</span>` : '');
return `<tr><td class="mono">${esc(p.id)}${defId===p.id?' <span class="tag ok">默认</span>':''}</td><td>${esc(p.name)}</td><td>${esc(p.type)}</td><td>${p.enabled?'<span class="tag ok">启用</span>':'<span class="tag off">停用</span>'}</td><td nowrap>${policyTxt}</td><td class="mono" style="max-width:220px;overflow:hidden;text-overflow:ellipsis">${esc(p.base_url||'—')}</td><td class="mono">${esc(p.model_prefix||'—')}</td><td class="mono">${esc((p.api_keys||[]).join(', '))||'—'}</td><td nowrap><button class="ghost small" onclick="editProvider('${esc(p.id)}')">编辑</button> <button class="ghost small" onclick="toggleProvider('${esc(p.id)}', ${!p.enabled})">${p.enabled?'停用':'启用'}</button> ${defId===p.id?'':`<button class="ghost small" onclick="setDefaultProvider('${esc(p.id)}')">设默认</button> `}<button class="danger small" onclick="delProvider('${esc(p.id)}')">删除</button></td></tr>`;
}).join('') +
'</table>';
} catch (e) { $('providersList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function addProvider() {
const keys = $('pKeys').value.split(',').map(s=>s.trim()).filter(Boolean);
const body = {
id: $('pId').value.trim(), name: $('pName').value.trim(),
type: $('pType').value, base_url: $('pBaseUrl').value.trim() || undefined,
model_prefix: $('pPrefix').value.trim(), api_keys: keys,
model_policy_mode: $('pPolicyMode').value,
allow_models: splitModelList($('pAllow').value),
deny_models: splitModelList($('pDeny').value),
};
if (!body.id) return toast('请填 id', true);
try { await api('/admin/api/providers', { method: 'POST', body }); toast('已添加'); loadProviders(); }
catch (e) { toast(e.message, true); }
}
async function toggleProvider(id, enabled) {
try { await api('/admin/api/provider-settings', { method: 'POST', body: { id, enabled } }); toast('已更新'); loadProviders(); }
catch (e) { toast(e.message, true); }
}
async function setDefaultProvider(id) {
try { await api('/admin/api/default-provider', { method: 'POST', body: { id } }); toast('已设为默认'); loadProviders(); }
catch (e) { toast(e.message, true); }
}
async function delProvider(id) {
if (!confirm('确认删除厂商 ' + id + '?')) return;
try { await api('/admin/api/providers?id=' + encodeURIComponent(id), { method: 'DELETE' }); toast('已删除'); loadProviders(); }
catch (e) { toast(e.message, true); }
}
// 编辑厂商:从列表数据中取当前值填入表单
function editProvider(id) {
api('/admin/api/providers').then(data => {
const p = (data.providers || []).find(x => x.id === id);
if (!p) return toast('厂商不存在', true);
$('epId').value = p.id;
$('epIdTag').textContent = p.id;
$('epName').value = p.name || '';
$('epType').value = p.type || 'gemini';
$('epBaseUrl').value = p.base_url || '';
$('epPrefix').value = p.model_prefix || '';
$('epEnabled').checked = !!p.enabled;
$('epKeys').value = ''; // 密钥脱敏,留空表示不修改
$('epPolicyMode').value = (p.model_policy_mode === 'allowlist') ? 'allowlist' : 'denylist';
$('epAllow').value = joinModelList(p.allow_models || []);
$('epDeny').value = joinModelList(p.deny_models || []);
$('providerEditCard').classList.remove('hidden');
$('providerEditCard').scrollIntoView({ behavior: 'smooth', block: 'center' });
}).catch(e => toast(e.message, true));
}
function cancelProviderEdit() {
$('providerEditCard').classList.add('hidden');
}
async function saveProviderEdit() {
const id = $('epId').value.trim();
if (!id) return toast('无效厂商', true);
const body = { id, name: $('epName').value.trim(), type: $('epType').value,
base_url: $('epBaseUrl').value.trim() || null, model_prefix: $('epPrefix').value.trim(),
enabled: $('epEnabled').checked,
model_policy_mode: $('epPolicyMode').value,
allow_models: splitModelList($('epAllow').value),
deny_models: splitModelList($('epDeny').value) };
const keys = $('epKeys').value.split(',').map(s=>s.trim()).filter(Boolean);
if (keys.length) body.api_keys = keys; // 仅在输入了新密钥时才覆盖
try { await api('/admin/api/providers', { method: 'PUT', body }); toast('已保存'); cancelProviderEdit(); loadProviders(); }
catch (e) { toast(e.message, true); }
}
async function importConfig() {
const file = $('importConfigFile').files[0];
if (!file) return toast('请选择配置文件', true);
try {
const text = await file.text();
const data = JSON.parse(text);
if (!confirm('导入会覆盖当前所有配置,确认继续?')) return;
const d = await api('/admin/api/import-config', { method: 'POST', body: data });
toast('导入成功,共 ' + d.count + ' 个厂商');
$('importConfigFile').value = '';
loadProviders();
} catch (e) { toast('导入失败:' + e.message, true); }
}
// ===== 令牌 =====
async function issueToken() {
try {
const data = await api('/admin/api/xtc-access-token', { method: 'POST', body: {} });
toast('已签发:' + data.token);
loadTokens();
} catch (e) { toast(e.message, true); }
}
async function loadTokens() {
try {
const data = await api('/admin/api/tokens');
const items = data.tokens || [];
if (!items.length) { $('tokensList').textContent = '暂无令牌'; return; }
$('tokensList').innerHTML = '<table><tr><th>jti</th><th>签发时间</th><th>过期时间</th><th>状态</th><th>操作</th></tr>' +
items.map(t => `<tr><td class="mono">${esc(t.jti)}</td><td>${fmtTime(t.issued_at)}</td><td>${fmtTime(t.expires_at)}</td><td>${t.revoked?'<span class="tag off">已吊销</span>':t.expired?'<span class="tag warn">已过期</span>':'<span class="tag ok">有效</span>'}</td><td><button class="ghost small" onclick="revokeToken('${esc(t.jti)}')">吊销</button></td></tr>`).join('') +
'</table>';
} catch (e) { $('tokensList').textContent = e.message; }
}
async function revokeToken(jti) {
try { await api('/admin/api/tokens/revoke', { method: 'POST', body: { jti } }); toast('已吊销'); loadTokens(); }
catch (e) { toast(e.message, true); }
}
// ===== 会话 =====
async function loadSessions() {
try {
const data = await api('/admin/api/sessions?limit=100');
const items = data.items || [];
if (!items.length) { $('sessionsList').innerHTML = '<div class="muted">暂无会话</div>'; return; }
$('sessionsList').innerHTML = '<table><tr><th>标题</th><th>厂商</th><th>模型</th><th>所有者</th><th>更新时间</th><th>操作</th></tr>' +
items.map(s => `<tr><td>${esc(s.title)}</td><td>${esc(s.provider||'-')}</td><td>${esc(s.model||'-')}</td><td class="mono">${esc(s.access_key||'-')}</td><td>${fmtTime(s.updated_at)}</td><td><button class="ghost small" onclick="viewSession('${esc(s.id)}')">查看</button> <button class="danger small" onclick="delSession('${esc(s.id)}')">删除</button></td></tr>`).join('') +
'</table>';
} catch (e) { $('sessionsList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function viewSession(id) {
try {
const data = await api('/admin/api/sessions/' + id + '/messages');
const msgs = data.messages || [];
$('sessionDetailCard').classList.remove('hidden');
if (!msgs.length) { $('sessionMessages').innerHTML = '<div class="muted">无消息</div>'; return; }
$('sessionMessages').innerHTML = msgs.map(m => {
let html = `<div class="msg-bubble ${m.role}"><div class="role">${esc(m.role)} · ${fmtTime(m.ts)} · ${esc(m.provider||'')} ${esc(m.model||'')}</div><div>${esc(m.content)}</div>`;
if (m.thought) html += `<div class="thought">思考:${esc(m.thought)}</div>`;
html += '</div>';
return html;
}).join('');
$('sessionDetailCard').scrollIntoView({behavior:'smooth'});
} catch (e) { toast(e.message, true); }
}
function closeSessionDetail() { $('sessionDetailCard').classList.add('hidden'); }
async function delSession(id) {
if (!confirm('确认删除会话 ' + id + '?')) return;
try { await api('/admin/api/sessions/' + id, { method: 'DELETE' }); toast('已删除'); loadSessions(); closeSessionDetail(); }
catch (e) { toast(e.message, true); }
}
// ===== 用户账号 =====
async function loadUsers() {
const q = $('usersQ') ? $('usersQ').value.trim() : '';
const qs = new URLSearchParams({ limit: '200' });
if (q) qs.set('q', q);
try {
const data = await api('/admin/api/users?' + qs.toString());
const items = data.items || [];
if (!items.length) {
$('usersList').innerHTML = '<div class="muted">暂无注册用户。若刚重启过 Space,首次加载会自动从 Hub 同步;如仍为空,点上方"从 Hub 同步"按钮。</div>';
return;
}
$('usersList').innerHTML = '<table><tr><th>ID</th><th>用户名</th><th>文件数</th><th>备份数</th><th>注册时间</th><th>操作</th></tr>' +
items.map(u => `<tr><td class="mono">${esc(String(u.id))}</td><td>${esc(u.username)}</td><td>${u.file_count || 0}</td><td>${u.backup_count || 0}</td><td>${fmtTime(u.created_at)}</td><td><button class="danger small" onclick="delUser(${u.id}, '${esc(u.username)}')">删除</button></td></tr>`).join('') +
'</table><p class="muted" style="margin-top:8px">本页 ' + items.length + ' 个 / 总计 ' + (data.count||items.length) + '</p>';
} catch (e) { $('usersList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function syncFromHub(kind, afterFn) {
if (!confirm('确认从 Hub 远端强制全量同步' + (kind==='users'?'用户':kind==='files'?'文件':'备份') + '到本地 SQLite?\\n这会向 Hub 发起多次 API 请求,可能耗时数十秒。')) return;
toast('正在从 Hub 同步...');
try {
const data = await api('/admin/api/sync-from-hub', { method: 'POST', body: { kind } });
const u = data.users || 0, f = data.files || 0, b = data.backups || 0;
if (kind === 'all') {
toast('已同步:用户 ' + u + ' / 文件 ' + f + ' / 备份 ' + b);
} else if (kind === 'users') {
toast('已恢复 ' + u + ' 个用户');
} else if (kind === 'files') {
toast('已恢复 ' + f + ' 条文件元信息');
} else if (kind === 'backups') {
toast('已恢复 ' + b + ' 条备份元信息');
}
if (typeof afterFn === 'function') afterFn();
} catch (e) { toast('同步失败:' + e.message, true); }
}
async function delUser(id, username) {
if (!confirm('确认删除用户 ' + username + '(ID: ' + id + ')?\\n该操作会级联删除其所有文件与备份,不可恢复!')) return;
try {
const data = await api('/admin/api/users/' + id, { method: 'DELETE' });
toast('已删除用户 ' + username + '(清理 ' + (data.files_removed || 0) + ' 项)');
loadUsers();
} catch (e) { toast(e.message, true); }
}
// ===== 文件 / 备份管理 =====
let _dataItemsCache = {}; // key -> item,避免把用户输入的别名塞进 onclick 属性
function _dataQs(qId, userVal) {
const p = new URLSearchParams();
const q = $(qId) ? $(qId).value.trim() : '';
if (q) p.set('q', q);
if (userVal) p.set('user_id', userVal);
p.set('limit', '200');
return p.toString();
}
async function _loadDataItems(endpoint, elId, qId, userVal, kindLabel) {
const el = $(elId);
el.innerHTML = '<div class="muted">加载中...</div>';
try {
const qs = _dataQs(qId, userVal);
const data = await api(endpoint + (qs ? ('?' + qs) : ''));
const items = data.items || [];
if (!items.length) {
el.innerHTML = '<div class="muted">暂无数据。若刚重启过 Space,首次加载会自动从 Hub 同步(约需数十秒);如仍为空,点上方"从 Hub 同步"按钮强制恢复。</div>';
return;
}
items.forEach(it => { _dataItemsCache[it.key] = it; });
el.innerHTML = '<table><tr><th>别名</th><th>文件名</th><th>用户</th><th>大小</th><th>类型</th><th>上传时间</th><th>操作</th></tr>' +
items.map(it => `<tr>
<td>${esc(it.alias || it.filename || it.key)}</td>
<td class="mono" style="max-width:160px;overflow:hidden;text-overflow:ellipsis" title="${esc(it.filename||'')}">${esc(it.filename || '-')}</td>
<td class="mono">${esc(it.access_key || it.uploaded_by || '-')}</td>
<td>${fmtBytes(it.size)}</td>
<td class="mono">${esc((it.mime||'').split(';')[0] || '-')}</td>
<td>${fmtTime(it.uploaded_at)}</td>
<td>
<button class="ghost small" onclick="downloadAnyFile('${esc(it.key)}')">下载</button>
<button class="ghost small" onclick="renameAnyAlias('${esc(it.key)}')">改名</button>
<button class="danger small" onclick="delAnyFile('${esc(it.key)}','${kindLabel}')">删除</button>
</td></tr>`).join('') +
'</table><p class="muted" style="margin-top:8px">本页 ' + items.length + ' 项 / 总计 ' + (data.count||items.length) + '</p>';
} catch (e) { el.innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function loadFiles() {
return _loadDataItems('/admin/api/files', 'filesList', 'filesQ', $('filesUser').value.trim(), '文件');
}
async function loadBackups() {
return _loadDataItems('/admin/api/backups', 'backupsList', 'backupsQ', $('backupsUser').value.trim(), '备份');
}
function _refreshActiveDataTab() {
if ($('filesList').innerHTML && $('filesList').innerHTML.indexOf('加载中') < 0) loadFiles();
if ($('backupsList').innerHTML && $('backupsList').innerHTML.indexOf('加载中') < 0) loadBackups();
}
async function downloadAnyFile(key) {
try {
const r = await fetch('/admin/api/files/' + encodeURIComponent(key) + '/download', { headers: { 'x-xtc-admin-key': ADMIN_KEY } });
if (!r.ok) { const d = await r.json().catch(()=>({})); throw new Error((d.error&&d.error.message)||('HTTP '+r.status)); }
const blob = await r.blob();
const it = _dataItemsCache[key] || {};
const name = (it.alias || it.filename || key).replace(/[\\/:*?"<>|]/g, '_');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(url), 4000);
toast('已开始下载');
} catch (e) { toast(e.message, true); }
}
async function renameAnyAlias(key) {
const it = _dataItemsCache[key] || {};
const oldAlias = it.alias || it.filename || '';
const alias = prompt('输入新别名', oldAlias);
if (alias === null) return;
const v = String(alias).trim();
if (!v) return toast('别名不能为空', true);
try {
await api('/admin/api/files/' + encodeURIComponent(key) + '/alias', { method: 'PUT', body: { alias: v } });
toast('已重命名');
_refreshActiveDataTab();
} catch (e) { toast(e.message, true); }
}
async function delAnyFile(key, kindLabel) {
if (!confirm('确认删除该' + kindLabel + '? 不可恢复!')) return;
try {
await api('/admin/api/files/' + encodeURIComponent(key), { method: 'DELETE' });
toast('已删除');
_refreshActiveDataTab();
} catch (e) { toast(e.message, true); }
}
// ===== 用量 =====
async function loadUsage() {
const hours = $('usageHours').value;
try {
const u = await api('/admin/api/usage?hours=' + hours);
const rate = u.total_requests ? Math.round(u.success / u.total_requests * 100) : 0;
const kpi = (cls, label, value, sub) => `<div class="kpi ${cls||''}"><div class="label">${label}</div><div class="value">${value}</div>${sub?`<div class="sub">${sub}</div>`:''}</div>`;
$('usageStats').innerHTML = [
kpi('', '总请求', u.total_requests, '成功 ' + u.success + ' / 失败 ' + u.failed),
kpi(rate>=95?'ok':rate>=80?'warn':'err', '成功率', rate + '%'),
kpi('ok', '成功', u.success),
kpi(u.failed>0?'err':'', '失败', u.failed),
kpi('', '输入 Token', (u.prompt_tokens||0).toLocaleString(), 'Prompt'),
kpi('', '输出 Token', (u.completion_tokens||0).toLocaleString(), 'Completion'),
kpi('', 'Token 总量', (u.total_tokens||0).toLocaleString(), '输入 + 输出'),
].join('');
const bp = u.by_provider || [];
$('usageByProvider').innerHTML = bp.length ? '<table><tr><th>厂商</th><th>请求</th><th>成功</th><th>失败</th><th>输入 Token</th><th>输出 Token</th><th>Token 总量</th></tr>' +
bp.map(p => `<tr><td class="mono">${esc(p.provider)}</td><td>${p.requests}</td><td style="color:var(--ok)">${p.success||0}</td><td style="color:var(--err)">${p.failed||0}</td><td>${(p.prompt_tokens||0).toLocaleString()}</td><td>${(p.completion_tokens||0).toLocaleString()}</td><td>${(p.tokens||0).toLocaleString()}</td></tr>`).join('') + '</table>' : '<div class="muted">无数据</div>';
const bm = u.by_model || [];
$('usageByModel').innerHTML = bm.length ? '<table><tr><th>模型</th><th>请求</th><th>成功</th><th>失败</th><th>输入 Token</th><th>输出 Token</th><th>Token 总量</th></tr>' +
bm.map(m => `<tr><td class="mono">${esc(m.model)}</td><td>${m.requests}</td><td style="color:var(--ok)">${m.success||0}</td><td style="color:var(--err)">${m.failed||0}</td><td>${(m.prompt_tokens||0).toLocaleString()}</td><td>${(m.completion_tokens||0).toLocaleString()}</td><td>${(m.tokens||0).toLocaleString()}</td></tr>`).join('') + '</table>' : '<div class="muted">无数据</div>';
const be = u.by_error || [];
$('usageByError').innerHTML = be.length ? '<table><tr><th>错误码</th><th>次数</th></tr>' +
be.map(e => `<tr><td class="mono"><span class="tag err">${esc(e.code)}</span></td><td>${e.count}</td></tr>`).join('') + '</table>' : '<div class="muted">无错误</div>';
loadTimeline('usageTimeline', hours, hours >= 168 ? 'day' : 'hour');
} catch (e) { toast(e.message, true); }
}
// ===== Webhook =====
async function loadWebhooks() {
try {
const data = await api('/admin/api/webhooks');
const items = data.items || [];
if (!items.length) { $('webhooksList').innerHTML = '<div class="muted">暂无 Webhook</div>'; return; }
$('webhooksList').innerHTML = '<table><tr><th>ID</th><th>名称</th><th>URL</th><th>事件</th><th>状态</th><th>操作</th></tr>' +
items.map(w => `<tr><td>${w.id}</td><td>${esc(w.name)}</td><td class="mono">${esc(w.url)}</td><td>${(w.events||[]).map(e=>'<span class="tag off">'+esc(e)+'</span>').join(' ')}</td><td>${w.enabled?'<span class="tag ok">启用</span>':'<span class="tag off">停用</span>'}</td><td><button class="ghost small" onclick="testWebhook(${w.id})">测试</button> <button class="ghost small" onclick="toggleWebhook(${w.id}, ${!w.enabled})">${w.enabled?'停用':'启用'}</button> <button class="danger small" onclick="delWebhook(${w.id})">删除</button></td></tr>`).join('') +
'</table>';
} catch (e) { $('webhooksList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
async function addWebhook() {
const events = $('whEvents').value.split(',').map(s=>s.trim()).filter(Boolean);
const body = { name: $('whName').value.trim(), url: $('whUrl').value.trim(), events, enabled: $('whEnabled').checked };
if (!body.name || !body.url) return toast('请填名称和 URL', true);
try { await api('/admin/api/webhooks', { method: 'POST', body }); toast('已添加'); loadWebhooks(); }
catch (e) { toast(e.message, true); }
}
async function testWebhook(id) {
try { await api('/admin/api/webhooks/' + id + '/test', { method: 'POST' }); toast('已发送测试事件'); }
catch (e) { toast(e.message, true); }
}
async function toggleWebhook(id, enabled) {
try { await api('/admin/api/webhooks/' + id, { method: 'PUT', body: { enabled } }); toast('已更新'); loadWebhooks(); }
catch (e) { toast(e.message, true); }
}
async function delWebhook(id) {
if (!confirm('确认删除 Webhook #' + id + '?')) return;
try { await api('/admin/api/webhooks/' + id, { method: 'DELETE' }); toast('已删除'); loadWebhooks(); }
catch (e) { toast(e.message, true); }
}
// ===== 请求历史 =====
let _reqPage = 1, _reqPageSize = 50, _reqTotal = 0;
let _reqCategory = 'business'; // 默认只看业务请求 /v1/*
function setReqCategory(cat) {
_reqCategory = cat;
document.querySelectorAll('#reqCategorySeg button').forEach(b =>
b.classList.toggle('active', b.dataset.cat === cat));
loadRequests(1);
loadReqStats();
}
function _catLabel(cat) {
return cat === 'admin' ? '(管理面板 /admin/api/*)' : cat === 'all' ? '(全部)' : '(业务请求 /v1/*)';
}
async function loadReqLimit() {
try {
const d = await api('/admin/api/request-log/settings');
$('reqLimitSetting').value = String(d.limit);
$('reqLogLimit').textContent = '(当前保留 ' + d.limit + ' 条)';
} catch (e) {}
}
async function saveReqLimit() {
const limit = parseInt($('reqLimitSetting').value);
try {
const d = await api('/admin/api/request-log/settings', { method: 'PUT', body: { limit } });
toast('已设置:保留 ' + d.limit + ' 条');
$('reqLogLimit').textContent = '(当前保留 ' + d.limit + ' 条)';
} catch (e) { toast(e.message, true); }
}
async function loadRequests(page) {
_reqPage = page || 1;
const offset = (_reqPage - 1) * _reqPageSize;
const params = new URLSearchParams({ limit: _reqPageSize, offset, category: _reqCategory });
const path = $('reqPath').value.trim();
const method = $('reqMethod').value;
const status = $('reqStatus').value;
const ok = $('reqOk').value;
const kw = $('reqKeyword').value.trim();
if (path) params.set('path', path);
if (method) params.set('method', method);
if (status) params.set('status', status);
if (ok !== '') params.set('ok', ok);
if (kw) params.set('keyword', kw);
try {
const d = await api('/admin/api/request-log?' + params.toString());
_reqTotal = d.total;
const items = d.items || [];
if (!items.length) { $('reqList').innerHTML = '<div class="muted">无记录</div>'; }
else {
$('reqList').innerHTML = '<table><tr><th>ID</th><th>时间</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th><th>Provider/Model</th><th>Key</th><th>错误</th><th>操作</th></tr>' +
items.map(r => {
const sc = r.status_code || 0;
const scCls = sc >= 500 ? 's5' : sc >= 400 ? 's4' : sc >= 300 ? 's3' : 's2';
const elapsed = r.elapsed_ms == null ? '-' : (r.elapsed_ms + 'ms');
const errHtml = r.ok ? '<span class="pill s2">成功</span>' : (r.error_code ? '<span class="pill s5" title="' + esc(r.error_message||'') + '">' + esc(r.error_code) + '</span>' : '<span class="pill s5">失败</span>');
const pm = (r.provider || r.model) ? esc(r.provider||'') + '/' + esc(r.model||'') : '<span class="muted">-</span>';
const stream = r.stream ? '<span class="pill stream">stream</span>' : '';
return '<tr><td>' + r.id + '</td><td class="muted">' + fmtTime(r.ts) + '</td><td>' + esc(r.method) + '</td><td class="mono">' + esc(r.path) + (r.query ? '?' + esc(r.query).slice(0,30) : '') + stream + '</td><td><span class="pill ' + scCls + '">' + sc + '</span></td><td>' + elapsed + '</td><td class="mono">' + pm + '</td><td class="mono">' + esc(r.access_key||'-').slice(0,12) + '</td><td>' + errHtml + '</td><td><button class="ghost small" onclick="viewReq(' + r.id + ')">详情</button></td></tr>';
}).join('') + '</table>';
}
const totalPages = Math.max(1, Math.ceil(_reqTotal / _reqPageSize));
$('reqPageInfo').textContent = '共 ' + _reqTotal + ' 条,第 ' + _reqPage + '/' + totalPages + ' 页';
} catch (e) { $('reqList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
function reqPrev() { if (_reqPage > 1) loadRequests(_reqPage - 1); }
function reqNext() { if (_reqPage * _reqPageSize < _reqTotal) loadRequests(_reqPage + 1); }
async function viewReq(id) {
try {
const d = await api('/admin/api/request-log/' + id);
const r = d.record;
$('reqDetailId').textContent = id;
$('reqDetailCard').classList.remove('hidden');
const scCls = (r.status_code||0) >= 500 ? 's5' : (r.status_code||0) >= 400 ? 's4' : (r.status_code||0) >= 300 ? 's3' : 's2';
let html = '<div class="kpi-grid" style="margin-bottom:12px">';
html += '<div class="kpi"><div class="label">方法</div><div class="value" style="font-size:14px">' + esc(r.method) + '</div></div>';
html += '<div class="kpi ' + ((r.status_code||0)>=500?'err':(r.status_code||0)>=400?'warn':'ok') + '"><div class="label">状态码</div><div class="value" style="font-size:14px"><span class="pill ' + scCls + '">' + r.status_code + '</span></div></div>';
html += '<div class="kpi"><div class="label">耗时</div><div class="value" style="font-size:14px">' + (r.elapsed_ms == null ? '-' : r.elapsed_ms + 'ms') + '</div></div>';
html += '<div class="kpi"><div class="label">Provider/Model</div><div class="value" style="font-size:14px">' + esc(r.provider||'-') + ' / ' + esc(r.model||'-') + '</div></div>';
html += '<div class="kpi"><div class="label">客户端 IP</div><div class="value mono" style="font-size:14px">' + esc(r.client_ip||'-') + '</div></div>';
html += '<div class="kpi"><div class="label">Access Key</div><div class="value mono" style="font-size:14px">' + esc(r.access_key||'-') + '</div></div>';
html += '</div>';
if (r.error_code || r.error_message) {
html += '<div class="card" style="background:rgba(248,113,113,0.1);border-color:var(--err);margin-bottom:12px"><strong>错误:</strong> <span class="pill s5">' + esc(r.error_code||'') + '</span> ' + esc(r.error_message||'') + '</div>';
}
html += '<details class="req-block" open><summary>完整路径</summary><pre>' + esc(r.method) + ' ' + esc(r.path) + (r.query ? '?' + esc(r.query) : '') + '</pre></details>';
html += '<details class="req-block"><summary>请求标头(已脱敏)</summary><pre>' + esc(JSON.stringify(r.request_headers, null, 2)) + '</pre></details>';
html += '<details class="req-block"><summary>请求体(已脱敏)</summary><pre>' + esc(typeof r.request_body === 'string' ? r.request_body : JSON.stringify(r.request_body, null, 2)) + '</pre></details>';
html += '<details class="req-block"><summary>响应标头</summary><pre>' + esc(JSON.stringify(r.response_headers, null, 2)) + '</pre></details>';
const rb = r.response_body;
html += '<details class="req-block"><summary>响应体' + (rb && typeof rb === 'object' && rb.type === 'streaming' ? '(流式,' + rb.total_chunks + ' chunks / ' + rb.total_bytes + ' B)' : '') + '</summary>';
if (rb && typeof rb === 'object' && rb.type === 'streaming') {
html += '<pre>' + esc((rb.sampled_chunks || []).join('\\n--- chunk ---\\n')) + '</pre>';
} else {
html += '<pre>' + esc(typeof rb === 'string' ? rb : JSON.stringify(rb, null, 2)) + '</pre>';
}
html += '</details>';
html += '<details class="req-block"><summary>User-Agent</summary><pre>' + esc(r.user_agent || '-') + '</pre></details>';
$('reqDetailBody').innerHTML = html;
$('reqDetailCard').scrollIntoView({behavior:'smooth'});
} catch (e) { toast(e.message, true); }
}
function closeReqDetail() { $('reqDetailCard').classList.add('hidden'); }
async function delReqDetail() {
const id = $('reqDetailId').textContent;
if (!confirm('确认删除请求 #' + id + '?')) return;
try {
await api('/admin/api/request-log/' + id, { method: 'DELETE' });
toast('已删除');
closeReqDetail();
loadRequests(_reqPage);
} catch (e) { toast(e.message, true); }
}
async function clearRequests() {
if (!confirm('确认清空全部请求历史?此操作不可恢复。')) return;
try {
const d = await api('/admin/api/request-log', { method: 'DELETE' });
toast('已清空 ' + d.cleared + ' 条');
loadRequests(1);
loadReqStats();
} catch (e) { toast(e.message, true); }
}
async function loadReqStats() {
try {
$('reqStatsCatLabel').textContent = _catLabel(_reqCategory);
const d = await api('/admin/api/request-log/stats?hours=24&category=' + _reqCategory);
// 成功率
const okC = (d.by_status['2xx']||0) + (d.by_status['3xx']||0);
const rate = d.total ? Math.round(okC / d.total * 100) : 0;
let html = '<div class="kpi-grid" style="margin-bottom:12px">';
html += '<div class="kpi"><div class="label">24h 总请求</div><div class="value">' + d.total + '</div><div class="sub">成功 ' + okC + ' / 失败 ' + (d.total - okC) + '</div></div>';
for (const [k, v] of Object.entries(d.by_status || {})) {
const cls = k.startsWith('5') ? 'err' : k.startsWith('4') ? 'warn' : 'ok';
html += '<div class="kpi ' + cls + '"><div class="label">' + k + '</div><div class="value">' + v + '</div></div>';
}
html += '<div class="kpi ' + (rate>=95?'ok':rate>=80?'warn':'err') + '"><div class="label">成功率</div><div class="value">' + rate + '%</div></div>';
html += '</div>';
const bp = d.by_path || [];
if (bp.length) {
html += '<h3 style="margin:12px 0 6px;font-size:14px">Top 路径</h3><table><tr><th>路径</th><th>次数</th><th>平均耗时</th><th>成功</th><th>成功率</th></tr>';
html += bp.map(p => '<tr><td class="mono">' + esc(p.path) + '</td><td>' + p.count + '</td><td>' + p.avg_ms + 'ms</td><td>' + p.ok + '</td><td>' + (p.count ? Math.round(p.ok/p.count*100) : 0) + '%</td></tr>').join('');
html += '</table>';
}
const be = d.by_error || [];
if (be.length) {
html += '<h3 style="margin:12px 0 6px;font-size:14px">错误码 Top</h3><table><tr><th>错误码</th><th>次数</th></tr>';
html += be.map(e => '<tr><td class="mono"><span class="pill s5">' + esc(e.code) + '</span></td><td>' + e.count + '</td></tr>').join('');
html += '</table>';
}
const slow = d.slowest || [];
if (slow.length) {
html += '<h3 style="margin:12px 0 6px;font-size:14px">最慢 Top 10</h3><table><tr><th>ID</th><th>路径</th><th>耗时</th><th>状态</th></tr>';
html += slow.map(s => '<tr><td>' + s.id + '</td><td class="mono">' + esc(s.path) + '</td><td>' + s.elapsed_ms + 'ms</td><td>' + s.status_code + '</td></tr>').join('');
html += '</table>';
}
$('reqStats').innerHTML = html;
} catch (e) { $('reqStats').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
// ===== 审计 =====
async function loadAudit() {
const hours = $('auditHours').value;
const action = $('auditAction').value.trim();
try {
const data = await api('/admin/api/audit?hours=' + hours + '&limit=100' + (action ? '&action=' + encodeURIComponent(action) : ''));
const items = data.items || [];
if (!items.length) { $('auditList').innerHTML = '<div class="muted">无审计记录</div>'; return; }
$('auditList').innerHTML = '<table><tr><th>时间</th><th>Action</th><th>Actor</th><th>Target</th><th>详情</th></tr>' +
items.map(a => `<tr><td>${fmtTime(a.ts)}</td><td><span class="tag off">${esc(a.action)}</span></td><td class="mono">${esc(a.actor||'-')}</td><td class="mono">${esc(a.target||'-')}</td><td><pre style="margin:0;max-height:120px">${esc(JSON.stringify(a.detail,null,2))}</pre></td></tr>`).join('') +
'</table>';
} catch (e) { $('auditList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
// ===== 测试 =====
async function runTest() {
let messages;
try { messages = JSON.parse($('tMessages').value || '[]'); }
catch (e) { return toast('messages JSON 解析失败', true); }
const deviceId = $('tDeviceId').value.trim();
const opts = { method: 'POST', body: {
provider: $('tProvider').value.trim() || undefined,
model: $('tModel').value.trim(),
messages, mode: $('tMode').value,
} };
if (deviceId) {
opts.headers = { 'x-xtc-device-id': deviceId };
}
try {
const data = await api('/admin/api/test-chat', opts);
$('tResult').classList.remove('hidden');
$('tResult').textContent = JSON.stringify(data, null, 2);
} catch (e) { toast(e.message, true); }
}
// ===== TTS 设置 / 测试 =====
async function loadTtsSettings() {
try {
const data = await api('/admin/api/tts-settings');
const s = data.settings || {};
if (s.voice) $('ttsVoice').value = s.voice;
$('ttsRate').value = s.rate || '+0%';
$('ttsVolume').value = s.volume || '+0%';
$('ttsPitch').value = s.pitch || '+0Hz';
} catch (e) { toast('加载 TTS 设置失败: ' + e.message, true); }
}
async function saveTtsSettings() {
const body = {
voice: $('ttsVoice').value,
rate: $('ttsRate').value.trim(),
volume: $('ttsVolume').value.trim(),
pitch: $('ttsPitch').value.trim(),
};
try {
await api('/admin/api/tts-settings', { method: 'PUT', body });
toast('TTS 设置已保存');
} catch (e) { toast('保存失败: ' + e.message, true); }
}
let _ttsTestObjUrl = null;
async function runTtsTest() {
const text = $('ttsTestText').value.trim();
if (!text) { toast('请输入要朗读的文本', true); return; }
const btn = $('ttsTestBtn');
const status = $('ttsTestStatus');
const audio = $('ttsTestAudio');
btn.disabled = true;
btn.textContent = '生成中…';
status.textContent = '正在生成音频,请稍候…';
try {
const r = await fetch('/admin/api/tts-test', {
method: 'POST',
headers: { 'x-xtc-admin-key': ADMIN_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: $('ttsTestVoice').value || undefined }),
});
if (!r.ok) {
let msg = 'HTTP ' + r.status;
try { const d = await r.json(); if (d && d.error && d.error.message) msg = d.error.message; } catch {}
throw new Error(msg);
}
const blob = await r.blob();
if (_ttsTestObjUrl) URL.revokeObjectURL(_ttsTestObjUrl);
_ttsTestObjUrl = URL.createObjectURL(blob);
audio.src = _ttsTestObjUrl;
audio.style.display = 'block';
status.textContent = '生成成功,正在播放…(' + Math.round(blob.size / 1024) + ' KB)';
await audio.play();
} catch (e) {
status.textContent = '失败:' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '生成并试听';
}
}
function stopTtsTest() {
const audio = $('ttsTestAudio');
audio.pause();
audio.removeAttribute('src');
audio.load();
audio.style.display = 'none';
$('ttsTestStatus').textContent = '已停止';
}
// ===== 设备 =====
let _devPage = 1, _devPageSize = 60, _devTotal = 0;
let _curDeviceId = '', _devRecPage = 1, _devRecPageSize = 30, _devRecTotal = 0;
// 缓存当前设备记录的完整文本/原文,供下载按钮使用(key = 记录 id)
let _devRecCache = {};
function _fmtRelative(ts) {
if (!ts) return '-';
const diff = Math.floor(Date.now()/1000) - ts;
if (diff < 60) return diff + ' 秒前';
if (diff < 3600) return Math.floor(diff/60) + ' 分前';
if (diff < 86400) return Math.floor(diff/3600) + ' 小时前';
return Math.floor(diff/86400) + ' 天前';
}
async function loadDevices(page) {
_devPage = page || 1;
// 事件委托:只绑定一次,避免每次渲染重复绑定
const listEl = $('devList');
if (!listEl.__devBound) {
listEl.__devBound = true;
listEl.addEventListener('click', function(e) {
const card = e.target.closest ? e.target.closest('.dev-card') : null;
if (!card) return;
const id = card.getAttribute('data-device-id');
if (id) viewDevice(id);
});
}
const params = new URLSearchParams({ limit: _devPageSize, offset: (_devPage-1)*_devPageSize });
const hours = $('devHours').value;
const kw = $('devKeyword').value.trim();
if (hours) params.set('hours', hours);
if (kw) params.set('keyword', kw);
try {
const d = await api('/admin/api/request-log/devices?' + params.toString());
_devTotal = d.total;
$('devCount').textContent = '(共 ' + d.total + ' 个设备)';
const items = d.items || [];
if (!items.length) { $('devList').innerHTML = '<div class="muted">无设备记录。客户端需在请求头携带 x-xtc-device-id。</div>'; }
else {
$('devList').innerHTML = items.map(dev => {
const rate = dev.total ? Math.round(dev.ok / dev.total * 100) : 0;
const cls = dev.fail > 0 ? (rate >= 80 ? 'is-warn' : 'is-fail') : '';
const pm = (dev.last_provider || dev.last_model) ? esc(dev.last_provider||'-') + ' / ' + esc(dev.last_model||'-') : '<span class="muted">-</span>';
return '<div class="dev-card ' + cls + '" data-device-id="' + esc(dev.device_id) + '">' +
'<div class="dev-bar"></div>' +
'<div class="dev-id" title="' + esc(dev.device_id) + '">' + esc(dev.device_id) + '</div>' +
'<div class="dev-stats">' +
'<div class="s-item"><span class="l">总请求</span><span class="v">' + dev.total + '</span></div>' +
'<div class="s-item"><span class="l">成功</span><span class="v ok">' + dev.ok + '</span></div>' +
'<div class="s-item"><span class="l">失败</span><span class="v fail">' + dev.fail + '</span></div>' +
'<div class="s-item"><span class="l">成功率</span><span class="v">' + rate + '%</span></div>' +
'</div>' +
'<div class="dev-meta">' +
'<div>最近:' + fmtTime(dev.last_seen) + ' <span class="muted">(' + _fmtRelative(dev.last_seen) + ')</span></div>' +
'<div>厂商/模型:<span class="mono">' + pm + '</span></div>' +
'<div>IP:<span class="mono">' + esc(dev.last_client_ip||'-') + '</span></div>' +
(dev.last_user_agent ? '<div>UA:<span class="mono">' + esc(dev.last_user_agent).slice(0,80) + '</span></div>' : '') +
'</div>' +
'</div>';
}).join('');
}
const totalPages = Math.max(1, Math.ceil(_devTotal / _devPageSize));
$('devPageInfo').textContent = '共 ' + _devTotal + ' 个设备,第 ' + _devPage + '/' + totalPages + ' 页';
} catch (e) { $('devList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
function devPrev() { if (_devPage > 1) loadDevices(_devPage - 1); }
function devNext() { if (_devPage * _devPageSize < _devTotal) loadDevices(_devPage + 1); }
function viewDevice(deviceId) {
_curDeviceId = deviceId;
_devRecPage = 1;
$('devDetailId').textContent = deviceId;
$('deviceDetailCard').classList.remove('hidden');
// 概要 KPI
$('devDetailSummary').innerHTML = '<div class="kpi"><div class="label">设备 ID</div><div class="value mono" style="font-size:12px;word-break:break-all">' + esc(deviceId) + '</div></div><div class="kpi"><div class="label">提示</div><div class="value" style="font-size:13px">见下方最近请求</div></div>';
loadDeviceRecords(deviceId, 1);
$('deviceDetailCard').scrollIntoView({behavior:'smooth'});
}
function closeDeviceDetail() { $('deviceDetailCard').classList.add('hidden'); _curDeviceId = ''; }
function copyDeviceId() {
const id = $('devDetailId').textContent;
if (!id) return;
navigator.clipboard.writeText(id).then(() => toast('已复制:' + id), () => toast('复制失败', true));
}
async function loadDeviceRecords(deviceId, page) {
_devRecPage = page || 1;
if (!deviceId) return;
const params = new URLSearchParams({ limit: _devRecPageSize, offset: (_devRecPage-1)*_devRecPageSize });
const hours = $('devDetailHours').value;
if (hours) params.set('hours', hours);
const kw = ($('devDetailKeyword').value || '').trim();
if (kw) {
params.set('keyword', kw);
params.set('scope', $('devDetailScope').value || 'both');
}
try {
const d = await api('/admin/api/request-log/devices/' + encodeURIComponent(deviceId) + '?' + params.toString());
_devRecTotal = d.total;
const items = d.items || [];
// 缓存完整文本/原文供下载使用
_devRecCache = {};
for (const r of items) {
_devRecCache[r.id] = {
text_full: r.text_full || '',
response_full: r.response_full || '',
request_raw: r.request_raw || '',
response_raw: r.response_raw || '',
ts: r.ts,
};
}
if (!items.length) { $('devRecList').innerHTML = '<div class="muted">该设备在范围内无请求记录</div>'; }
else {
$('devRecList').innerHTML = items.map(r => {
const sc = r.status_code || 0;
const scCls = sc >= 500 ? 's5' : sc >= 400 ? 's4' : sc >= 300 ? 's3' : 's2';
const elapsed = r.elapsed_ms == null ? '-' : (r.elapsed_ms + 'ms');
const pm = (r.provider || r.model) ? esc(r.provider||'') + ' / ' + esc(r.model||'') : '<span class="muted">-</span>';
const stream = r.stream ? '<span class="pill stream">stream</span>' : '';
const rid = r.id;
// 请求文本区:可折叠,含下载按钮
let reqBlock = '';
if (r.text_full) {
const len = r.text_full.length;
reqBlock = '<details class="req">' +
'<summary><span>请求文本 <span class="muted">(' + len + ' 字)</span></span>' +
'<span class="icon-btn" data-dl="req-txt" data-rid="' + rid + '" title="下载请求文本"><span class="ic">↓</span>文本</span>' +
'</summary>' +
'<div class="dev-rec-body">' + esc(r.text_full) + '</div>' +
'</details>';
}
// 响应文本区:可折叠,含下载按钮
let respBlock = '';
if (r.response_full) {
const len = r.response_full.length;
respBlock = '<details class="resp">' +
'<summary><span>响应文本 <span class="muted">(' + len + ' 字)</span></span>' +
'<span class="icon-btn" data-dl="resp-txt" data-rid="' + rid + '" title="下载响应文本"><span class="ic">↓</span>文本</span>' +
'</summary>' +
'<div class="dev-rec-body">' + esc(r.response_full) + '</div>' +
'</details>';
}
// 错误区(默认展开)
let errBlock = '';
if (!r.ok) {
errBlock = '<details class="err" open>' +
'<summary><span>错误</span></summary>' +
'<div class="dev-rec-body">' + esc(r.error_code||'') + ' ' + esc(r.error_message||'') + '</div>' +
'</details>';
}
// 原始 body 下载按钮(紧凑行)
const rawRow = '<div class="dev-rec-meta" style="margin-top:6px">' +
(r.request_raw ? '<span class="icon-btn" data-dl="req-raw" data-rid="' + rid + '" title="下载原始请求 JSON"><span class="ic">↓</span>请求 JSON (' + r.request_raw.length + ' B)</span>' : '') +
(r.response_raw ? '<span class="icon-btn" data-dl="resp-raw" data-rid="' + rid + '" title="下载原始响应 JSON"><span class="ic">↓</span>响应 JSON (' + r.response_raw.length + ' B)</span>' : '') +
'</div>';
return '<div class="dev-rec ' + (r.ok ? '' : 'fail') + '">' +
'<div class="dev-rec-head">' +
'<span><span class="pill ' + scCls + '">' + sc + '</span> ' + esc(r.method) + ' ' + stream + '</span>' +
'<span class="muted">' + fmtTime(r.ts) + ' · ' + _fmtRelative(r.ts) + '</span>' +
'</div>' +
'<div class="dev-rec-path">' + esc(r.path) + '</div>' +
'<div class="dev-rec-meta">' +
'<span>厂商/模型:<span class="mono">' + pm + '</span></span>' +
'<span>耗时 ' + elapsed + '</span>' +
'</div>' +
reqBlock + respBlock + errBlock + rawRow +
'</div>';
}).join('');
// 绑定下载按钮(事件委托)
const listEl = $('devRecList');
if (!listEl.__dlBound) {
listEl.__dlBound = true;
listEl.addEventListener('click', function(e) {
const btn = e.target.closest ? e.target.closest('[data-dl]') : null;
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const kind = btn.getAttribute('data-dl');
const rid = Number(btn.getAttribute('data-rid'));
downloadDevRec(rid, kind);
});
}
}
const totalPages = Math.max(1, Math.ceil(_devRecTotal / _devRecPageSize));
$('devRecPageInfo').textContent = '共 ' + _devRecTotal + ' 条,第 ' + _devRecPage + '/' + totalPages + ' 页';
} catch (e) { $('devRecList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
}
function downloadDevRec(rid, kind) {
const c = _devRecCache[rid];
if (!c) { toast('记录缓存已失效,请刷新', true); return; }
let filename = '', content = '', mime = 'text/plain;charset=utf-8';
const ts = c.ts ? new Date(c.ts * 1000).toISOString().replace(/[:.]/g, '-') : String(rid);
const prefix = 'dev-' + (_curDeviceId || 'unknown').slice(0, 20) + '-' + ts + '-' + rid;
if (kind === 'req-txt') { filename = prefix + '-请求.txt'; content = c.text_full; }
else if (kind === 'resp-txt') { filename = prefix + '-响应.txt'; content = c.response_full; }
else if (kind === 'req-raw') { filename = prefix + '-请求.json'; content = c.request_raw; mime = 'application/json;charset=utf-8'; }
else if (kind === 'resp-raw') { filename = prefix + '-响应.json'; content = c.response_raw; mime = 'application/json;charset=utf-8'; }
else { return; }
if (!content) { toast('该条无内容', true); return; }
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function devRecPrev() { if (_devRecPage > 1 && _curDeviceId) loadDeviceRecords(_curDeviceId, _devRecPage - 1); }
function devRecNext() { if (_devRecPage * _devRecPageSize < _devRecTotal && _curDeviceId) loadDeviceRecords(_curDeviceId, _devRecPage + 1); }
function clearDevRecFilter() {
if (!$('devDetailKeyword')) return;
$('devDetailKeyword').value = '';
$('devDetailScope').value = 'both';
loadDeviceRecords(_curDeviceId, 1);
}
async function clearDeviceRecords() {
if (!_curDeviceId) return;
if (!confirm('确认清空设备 ' + _curDeviceId + ' 的全部请求记录?此操作不可恢复。')) return;
try {
const r = await api('/admin/api/request-log/devices/' + encodeURIComponent(_curDeviceId), { method: 'DELETE' });
toast('已清空 ' + (r.cleared || 0) + ' 条记录');
loadDeviceRecords(_curDeviceId, 1);
// 刷新设备列表(清空后该设备的统计会变)
if (typeof loadDevices === 'function') loadDevices();
} catch (e) { toast(e.message, true); }
}
// 自动登录
if (ADMIN_KEY) {
api('/admin/api/login', { method: 'POST', body: { admin_key: ADMIN_KEY } })
.then(() => {
$('loginCard').classList.add('hidden');
$('appBody').classList.remove('hidden');
checkSvcStatus();
loadOverview();
})
.catch(() => { localStorage.removeItem('xtc_admin_key'); ADMIN_KEY=''; });
}
</script>
</body>
</html>
"""