Spaces:
Running
Running
| """管理后台 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"]) | |
| 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> | |
| """ | |