Files
cpa-plugin/internal/web/ui.html
T

959 lines
67 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CPA Ext 管理台</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #0b0f17; --panel: #111722; --panel-raised: #151d2a; --panel-soft: #0f1520;
--border: #263142; --border-soft: #1d2736; --text: #e7edf6; --muted: #8b98aa;
--accent: #7dd3fc; --accent-strong: #38bdf8; --accent-soft: #0c2b3d;
--success: #34d399; --danger: #fb7185; --warning: #fbbf24;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 58px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: #0b0f17eb; backdrop-filter: blur(16px); }
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand-mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid #155e75; border-radius: 8px; background: linear-gradient(145deg, #164e63, #0c2534); color: #bae6fd; font-size: 11px; font-weight: 800; letter-spacing: .04em; }
.brand strong { font-size: 14px; letter-spacing: .02em; }
.brand span { color: var(--muted); font-size: 12px; }
.auth-box { display: flex; align-items: center; gap: 8px; }
.auth-box label { color: var(--muted); font-size: 12px; }
.mode-switch { display: flex; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: #080d14; }
.mode-button { border: 0; padding: 5px 9px; background: transparent; color: var(--muted); }
.mode-button:hover { border-color: transparent; background: #ffffff08; }
.mode-button.active { background: #1b283a; color: var(--text); }
.demo-mode .mode-button[data-mode="demo"] { background: #78350f; color: #fef3c7; }
.demo-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 22px; border-bottom: 1px solid #92400e; background: #451a03; color: #fde68a; font-size: 11px; }
.demo-banner button { border-color: #92400e; background: #78350f; color: #fef3c7; }
input, select { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; outline: none; background: #0d1420; color: inherit; transition: border-color .15s, box-shadow .15s; }
input:focus, select:focus { border-color: #0e7490; box-shadow: 0 0 0 3px #0891b21f; }
input:disabled, select:disabled { cursor: not-allowed; opacity: .55; }
input[type="checkbox"] { width: 15px; height: 15px; padding: 0; accent-color: var(--accent-strong); }
#key { width: 188px; }
.workspace-nav { display: flex; align-items: stretch; gap: 3px; height: 50px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: #0d121b; overflow-x: auto; overflow-y: hidden; }
.nav-button { position: relative; display: flex; flex: 0 0 auto; align-items: center; height: 49px; border: 0; padding: 0 13px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; }
.nav-button:hover { color: var(--text); }
.nav-button.active { color: var(--text); }
.nav-button.active::after { position: absolute; right: 10px; bottom: -1px; left: 10px; height: 2px; border-radius: 2px 2px 0 0; background: var(--accent-strong); content: ""; }
main { padding: 18px 22px 28px; }
.view { display: none; }
.view.active { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading.actions-only { justify-content: flex-end; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 4px; font-size: 19px; letter-spacing: -.01em; }
h2 { margin-bottom: 10px; font-size: 15px; }
.section-heading p, .subtle { margin-bottom: 0; color: var(--muted); font-size: 12px; }
.tools { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
button, summary { border: 1px solid var(--border); border-radius: 7px; padding: 8px 11px; background: #151d2a; color: var(--text); font-size: 12px; }
button:hover, summary:hover { border-color: #3b4b61; background: #192334; }
button.primary { border-color: #0e7490; background: #0e7490; color: white; }
button.primary:hover { background: #0891b2; }
button.danger { color: #fecdd3; }
button.small { padding: 5px 8px; }
button.icon-button { width: 32px; height: 32px; padding: 0; font-size: 17px; }
details { position: relative; }
summary { list-style: none; cursor: pointer; }
summary::-webkit-details-marker { display: none; }
.column-options { position: absolute; z-index: 10; right: 0; top: calc(100% + 6px); display: grid; grid-template-columns: repeat(2, max-content); gap: 8px 18px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel-raised); box-shadow: 0 16px 40px #0009; }
.column-options label { display: flex; align-items: center; gap: 6px; }
.status { min-height: 20px; color: var(--muted); font-size: 12px; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; margin-bottom: 12px; }
.summary-card { min-height: 78px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: linear-gradient(145deg, #121a27, #101620); }
.summary-card span { color: var(--muted); font-size: 11px; }
.summary-card strong { display: block; margin-top: 5px; font-size: 22px; font-weight: 650; letter-spacing: -.03em; }
.summary-card small { display: block; margin-top: 2px; color: #64748b; font-size: 11px; }
.surface { border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
.surface-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 45px; padding: 8px 10px 8px 13px; border-bottom: 1px solid var(--border-soft); }
.surface-toolbar .status { min-height: 0; }
.chart-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 9px; margin-bottom: 12px; }
.chart-card { min-height: 180px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
.chart-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 13px; }
.chart-card h2 { margin-bottom: 2px; font-size: 12px; }
.chart-card p { margin-bottom: 0; color: var(--muted); font-size: 10px; }
.bar-chart { display: grid; gap: 9px; }
.bar-row { display: grid; grid-template-columns: minmax(70px, 110px) minmax(100px, 1fr) 42px; align-items: center; gap: 9px; font-size: 10px; }
.bar-label { overflow: hidden; color: #cbd5e1; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: #202b3b; }
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: linear-gradient(90deg, #0e7490, #38bdf8); }
.bar-count { color: var(--muted); text-align: right; }
.user-usage { display: grid; overflow-x: auto; }
.user-usage-row { display: grid; grid-template-columns: minmax(90px, 1.2fr) 68px minmax(90px, .8fr) 78px minmax(120px, 1fr); align-items: center; gap: 10px; min-width: 570px; min-height: 31px; border-bottom: 1px solid var(--border-soft); font-size: 10px; }
.user-usage-row:last-child { border-bottom: 0; }
.user-usage-row.header { min-height: 26px; color: var(--muted); font-size: 9px; }
.user-usage-row span:not(:first-child) { text-align: right; }
.price-panel { padding: 0; }
.price-layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); min-height: 520px; }
.price-sidebar { padding: 13px; border-right: 1px solid var(--border-soft); background: var(--panel-soft); }
.price-sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.price-sidebar-header h2 { margin: 0; font-size: 12px; }
.price-list { display: grid; gap: 6px; }
.price-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 9px 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: #111925; }
.price-row:hover { border-color: #3b4b61; background: #151f2d; }
.price-row .price-name { overflow: hidden; font-size: 11px; font-weight: 600; text-overflow: ellipsis; }
.price-row small { display: block; margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.price-row .key-actions { flex-wrap: nowrap; }
.price-editor { padding: 16px; }
.price-editor-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.price-editor-header h2 { margin: 0; }
.price-section { margin-bottom: 10px; padding: 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: #0e1520; }
.price-section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 11px; }
.price-section-header h3 { margin: 0; font-size: 11px; }
.price-section-header .inline-check { color: var(--text); }
.price-grid { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; }
.price-grid.long-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.field, .price-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 10px; }
.price-model-field { max-width: 480px; }
.fast-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(130px, 220px); align-items: end; gap: 16px; }
.fast-row .inline-check { align-self: center; color: var(--text); }
.price-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 3px; }
.inline-check { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
.key-identity { display: flex; align-items: center; gap: 9px; }
.avatar { display: grid; flex: 0 0 auto; width: 28px; height: 28px; place-items: center; border: 1px solid #285266; border-radius: 8px; background: var(--accent-soft); color: #bae6fd; font-size: 11px; font-weight: 700; }
.key-identity strong { display: block; font-size: 12px; }
.key-identity small { display: block; max-width: 160px; overflow: hidden; color: #64748b; font-size: 10px; text-overflow: ellipsis; }
.credential { display: flex; align-items: center; gap: 6px; }
code { color: #cbd5e1; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 10px; }
.badge::before { width: 5px; height: 5px; border-radius: 50%; background: currentColor; content: ""; }
.badge.active { border-color: #14532d; background: #052e1b; color: var(--success); }
.badge.disabled, .badge.archived { color: #94a3b8; }
.route-main { font-size: 12px; }
.route-sub { margin-top: 2px; max-width: 220px; overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; }
.key-actions { display: flex; gap: 6px; }
.hidden { display: none !important; }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; }
th, td { padding: 8px 10px; border-bottom: 1px solid var(--border-soft); text-align: right; }
th { position: sticky; top: 0; z-index: 1; background: #141c29; color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .03em; text-transform: uppercase; }
th.left, td.left { text-align: left; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #ffffff05; }
.ok { color: #34d399; }
.failed { color: #f87171; }
.drawer-overlay { position: fixed; z-index: 40; inset: 0; background: #020617a6; backdrop-filter: blur(2px); }
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(430px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: #0f1621; box-shadow: -20px 0 60px #0008; }
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 17px 18px 13px; border-bottom: 1px solid var(--border-soft); }
.drawer-header h2 { margin-bottom: 3px; }
.drawer-header p { margin-bottom: 0; color: var(--muted); font-size: 11px; }
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px; }
.drawer-form { display: grid; gap: 13px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.drawer-footer { display: flex; justify-content: space-between; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border-soft); }
.drawer-footer div { display: flex; gap: 7px; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; }
.stats-tile { padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--panel-soft); }
.stats-tile span { color: var(--muted); font-size: 10px; }
.stats-tile strong { display: block; margin-top: 5px; font-size: 17px; }
.recent-list { display: grid; gap: 6px; }
.recent-item { padding: 9px 10px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--panel-soft); }
.recent-item strong { display: block; margin-bottom: 3px; font-size: 11px; }
.recent-item span { color: var(--muted); font-size: 10px; }
.empty { padding: 28px; color: var(--muted); text-align: center; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 0; }
.page-buttons { display: flex; align-items: center; gap: 4px; }
.page-buttons button { min-width: 29px; height: 27px; padding: 3px 7px; }
.page-buttons button.active { border-color: #0e7490; background: #0e7490; color: white; }
.page-buttons button:disabled { cursor: default; opacity: .4; }
.page-ellipsis { width: 22px; color: var(--muted); text-align: center; font-size: 10px; }
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .summary-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); overflow-x: auto; } .chart-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .topbar { align-items: flex-start; height: auto; padding: 11px 12px; } .brand span, .auth-box label { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .demo-banner { padding-right: 12px; padding-left: 12px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } }
</style>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand"><div class="brand-mark">CX</div><div><strong>CPA Ext</strong><span> · 管理控制台</span></div></div>
<div class="auth-box"><div class="mode-switch" aria-label="数据模式"><button class="mode-button active" type="button" data-mode="live">实时</button><button class="mode-button" type="button" data-mode="demo">测试</button></div><label for="key">管理密钥</label><input id="key" type="password" autocomplete="off" placeholder="输入管理密钥"></div>
</header>
<div id="demo-banner" class="demo-banner hidden"><span><strong>测试模式</strong> · 页面使用浏览器本地样例数据,任何创建、编辑、归档和价格操作都不会访问 CPA。</span><button id="reset-demo" type="button">重置测试数据</button></div>
<nav class="workspace-nav" aria-label="工作区">
<button class="nav-button active" type="button" data-view="keys">用户 Key</button>
<button class="nav-button" type="button" data-view="usage">请求明细</button>
<button class="nav-button" type="button" data-view="pricing">价格配置</button>
</nav>
<main>
<section id="view-keys" class="view active">
<div class="section-heading actions-only"><div class="tools"><label class="inline-check"><input id="include-archived" type="checkbox">显示已归档</label><button id="reload-keys" type="button">刷新</button><button id="create-key" class="primary" type="button">创建 Key</button></div></div>
<div class="summary-grid">
<article class="summary-card"><span>今日请求</span><strong id="metric-requests"></strong><small>自然日内实际请求</small></article>
<article class="summary-card"><span>今日 Token</span><strong id="metric-tokens"></strong><small>输入与输出合计</small></article>
<article class="summary-card"><span>今日成本</span><strong id="metric-cost"></strong><small>按已配置价格计算</small></article>
</div>
<div class="chart-grid"><article class="chart-card"><header><div><h2>今日用户用量</h2></div></header><div id="user-usage" class="user-usage"></div></article><article class="chart-card"><header><div><h2>近 7 日 Token</h2></div></header><div id="daily-chart" class="bar-chart"></div></article></div>
<div class="surface">
<div class="surface-toolbar"><span id="key-status" class="status"></span><span class="subtle">点击“管理”修改路由与权限</span></div>
<div class="table-wrap"><table class="key-table"><thead><tr><th class="left">用户</th><th class="left">完整 Key</th><th class="left">状态</th><th class="left">路由</th><th class="left">允许模型</th><th class="left">操作</th></tr></thead><tbody id="key-rows"></tbody></table></div>
</div>
</section>
<section id="view-usage" class="view">
<div class="section-heading"><div><div id="page-buttons" class="page-buttons"></div><span id="status" class="status"></span></div><div class="tools"><details><summary>选择列</summary><div id="column-options" class="column-options"></div></details><button id="refresh" type="button">刷新</button></div></div>
<div class="surface"><div class="table-wrap"><table><thead><tr id="headers"></tr></thead><tbody id="rows"></tbody></table></div></div>
</section>
<section id="view-pricing" class="view">
<div class="section-heading"><div><h1>价格配置</h1></div><div id="price-status" class="status"></div></div>
<div class="surface price-panel price-layout">
<aside class="price-sidebar"><div class="price-sidebar-header"><h2>已配置模型</h2><button id="new-price" class="small" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
<section class="price-editor">
<div class="price-editor-header"><h2>模型价格</h2></div>
<div class="price-section"><label class="field price-model-field">模型名称<input id="price-model" placeholder="gpt-5.6-sol"></label></div>
<div class="price-section"><div class="price-section-header"><h3>基础价格 · $ / 1M Token</h3></div><div class="price-grid">
<label>输入<input id="price-input" inputmode="decimal"></label><label>缓存读取<input id="price-cache-read" inputmode="decimal"></label><label>缓存写入<input id="price-cache-write" inputmode="decimal"></label><label>输出<input id="price-output" inputmode="decimal"></label>
</div></div>
<div class="price-section"><div class="price-section-header"><h3>长上下文价格</h3><label class="inline-check"><input id="long-enabled" type="checkbox">启用</label></div><div class="price-grid long-grid">
<label class="long-field hidden">输入门槛<input id="long-threshold" type="number" min="0" step="1"></label><label class="long-field hidden">门槛比较<select id="long-comparison"><option value="gt">大于</option><option value="gte">大于等于</option></select></label><label class="long-field hidden">输入<input id="long-input" inputmode="decimal"></label><label class="long-field hidden">缓存读取<input id="long-cache-read" inputmode="decimal"></label><label class="long-field hidden">缓存写入<input id="long-cache-write" inputmode="decimal"></label><label class="long-field hidden">输出<input id="long-output" inputmode="decimal"></label>
</div></div>
<div class="price-section"><div class="price-section-header"><h3>Fast 价格</h3></div><div class="fast-row"><label class="inline-check"><input id="fast-pricing-enabled" type="checkbox">使用倍率计价</label><label class="field">倍率<input id="fast-multiplier" inputmode="decimal" value="2.5"></label></div></div>
<div class="price-actions"><button id="clear-price" type="button">清空</button><button id="save-price" class="primary" type="button">保存价格</button></div>
</section>
</div>
</section>
</main>
</div>
<div id="drawer-overlay" class="drawer-overlay hidden"></div>
<aside id="key-drawer" class="drawer hidden" aria-hidden="true">
<div class="drawer-header"><div><h2 id="drawer-title">管理 Key</h2><p id="drawer-subtitle">修改用户状态、路由和模型权限</p></div><button id="close-drawer" class="icon-button" type="button" aria-label="关闭">×</button></div>
<div class="drawer-body">
<div id="key-editor" class="drawer-form">
<label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
<label class="field">完整 Key<input id="editor-secret" class="secret" placeholder="留空自动生成"></label>
<div id="managed-fields" class="drawer-form">
<div class="field-row"><label class="field">状态<select id="editor-status"><option value="active">启用</option><option value="disabled">禁用</option><option value="archived">已归档</option></select></label><label class="field">路由<select id="editor-route"><option value="auto">自动选择</option><option value="strict">指定账号</option></select></label></div>
<label class="field">上游账号<select id="editor-upstream"></select></label>
<label class="inline-check"><input id="editor-all-models" type="checkbox">允许全部模型</label>
<label class="field">允许模型<input id="editor-models" placeholder="多个模型用逗号分隔"></label>
</div>
</div>
<div id="key-stats" class="hidden"><div id="stats-grid" class="stats-grid"></div><h3>最近请求</h3><div id="stats-recent" class="recent-list"></div></div>
</div>
<div id="editor-footer" class="drawer-footer"><button id="archive-key" class="danger hidden" type="button">永久归档</button><div><button id="cancel-editor" type="button">取消</button><button id="save-key" class="primary" type="button">保存</button></div></div>
</aside>
<script>
const API = "/v0/management/plugins/cpa-ext/usage";
const PRICE_API = "/v0/management/plugins/cpa-ext/prices";
const KEYS_API = "/v0/management/plugins/cpa-ext/keys";
const KEY_STATS_API = "/v0/management/plugins/cpa-ext/key-stats";
const UPSTREAMS_API = "/v0/management/plugins/cpa-ext/upstreams";
const MODELS_API = "/v0/management/plugins/cpa-ext/model-suggestions";
const keyInput = document.querySelector("#key");
const statusNode = document.querySelector("#status");
const rowsNode = document.querySelector("#rows");
const headersNode = document.querySelector("#headers");
const columnOptionsNode = document.querySelector("#column-options");
const columnStoreKey = "cpa-ext:usage-columns";
const priceStatusNode = document.querySelector("#price-status");
const priceListNode = document.querySelector("#price-list");
const keyRowsNode = document.querySelector("#key-rows");
const keyStatusNode = document.querySelector("#key-status");
const keyStatsNode = document.querySelector("#key-stats");
const drawerNode = document.querySelector("#key-drawer");
const drawerOverlayNode = document.querySelector("#drawer-overlay");
const editorNode = document.querySelector("#key-editor");
const managedFieldsNode = document.querySelector("#managed-fields");
const editorFooterNode = document.querySelector("#editor-footer");
const statsGridNode = document.querySelector("#stats-grid");
const statsRecentNode = document.querySelector("#stats-recent");
const pageButtonsNode = document.querySelector("#page-buttons");
const PAGE_SIZE = 100;
const DEMO_STORE_KEY = "cpa-ext:demo-state:v2";
let currentRecords = [];
let currentPage = 1;
let lastSignature = "";
let loading = false;
let currentPrices = [];
let managedKeys = [];
let upstreamAccounts = [];
let modelSuggestions = [];
let editingKey = null;
let testMode = false;
let demoState = loadDemoState();
function demoPrice() {
return {
model: "deepseek-v4-flash",
base: { input_per_1m: "2.5", cache_read_per_1m: "0.25", cache_write_per_1m: "3.125", output_per_1m: "15" },
long_context: { threshold_input_tokens: 272000, comparison: "gt", input_per_1m: "5", cache_read_per_1m: "0.5", cache_write_per_1m: "6.25", output_per_1m: "22.5" },
fast_pricing_enabled: true, fast_multiplier: "2.5"
};
}
function freshDemoState() {
const upstreams = ["A", "B", "C"].map((name, index) => ({
id: "demo-upstream-" + name.toLowerCase(), cpa_auth_id: "codex:demo:" + name.toLowerCase(),
provider: "codex", display_name: "DeepSeek 测试源 " + name, priority: 10,
disabled: false, unavailable: false
}));
const keys = [
{ id: "demo-key-alice", name: "alice", secret: "alice-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: true, models: [] },
{ id: "demo-key-bob", name: "bob", secret: "bob-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: true, models: [] },
{ id: "demo-key-carol", name: "carol", secret: "carol-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[1].id, all_models: true, models: [] },
{ id: "demo-key-eve", name: "eve", secret: "eve-000000", status: "disabled", route_mode: "auto", upstream_account_id: "", all_models: false, models: ["deepseek-source-c"] }
];
const now = Date.now();
const usage = Array.from({ length: 2370 }, (_, index) => {
const key = keys[index % keys.length];
const failed = index % 13 === 0;
const input = 900 + (index * 137) % 18000;
const output = 24 + (index * 17) % 420;
return {
request_id: "demo-request-" + String(index + 1).padStart(4, "0"), execution_id: "demo-exec-" + index,
requested_at: new Date(now - index * 187000).toISOString(), key_alias: key.name, api_key: key.secret,
auth_id: upstreams[index % upstreams.length].cpa_auth_id, auth_index: "demo-" + (index % upstreams.length + 1),
model: index % 7 === 0 ? "deepseek-source-c" : "deepseek-v4-flash", reasoning_effort: "-", service_tier: "auto", speed: "",
failed, outcome: failed ? "failed" : "succeeded", status_code: failed ? 503 : 200,
request_type: "SSE", endpoint: "/v1/responses", ttft_ms: 120 + index % 360, speed_tps: 32 + index % 28,
input_tokens: input, output_tokens: output, reasoning_tokens: 0, cache_read_tokens: Math.floor(input * .72), cache_write_tokens: 0,
cache_rate: 72, total_tokens: input + output, cost_usd: (input * 2.5 + output * 15) / 1000000,
cost_available: true, client_ip: "127.0.0.1"
};
});
return { keys, upstreams, usage, prices: [demoPrice()] };
}
function loadDemoState() {
try {
const value = JSON.parse(localStorage.getItem(DEMO_STORE_KEY));
if (value && Array.isArray(value.keys) && Array.isArray(value.usage) && Array.isArray(value.prices)) return value;
} catch (_) {}
return freshDemoState();
}
function saveDemoState() {
localStorage.setItem(DEMO_STORE_KEY, JSON.stringify(demoState));
}
function storedPanelKey() {
const prefix = "enc::v1::";
const salt = "cli-proxy-api-webui::secure-storage";
let raw = localStorage.getItem("cli-proxy-auth");
if (!raw) return "";
if (raw.startsWith(prefix)) {
const secret = new TextEncoder().encode(salt + "|" + location.host + "|" + navigator.userAgent);
const encoded = atob(raw.slice(prefix.length));
const bytes = new Uint8Array(encoded.length);
for (let i = 0; i < encoded.length; i++) bytes[i] = encoded.charCodeAt(i) ^ secret[i % secret.length];
raw = new TextDecoder().decode(bytes);
}
try {
const value = JSON.parse(raw);
return value?.state?.managementKey || "";
} catch (_) { return ""; }
}
function duration(value) {
return value > 0 ? value.toLocaleString() + " ms" : "-";
}
function number(value) {
return Number.isFinite(value) ? value.toLocaleString() : "-";
}
function percent(value) {
return Number.isFinite(value) ? value.toFixed(2) + "%" : "-";
}
function speed(value) {
return Number.isFinite(value) ? value.toFixed(1) + " tok/s" : "-";
}
function cost(record) {
return record.cost_available && Number.isFinite(record.cost_usd) ? "$" + record.cost_usd.toFixed(4) : "-";
}
function isCompactEndpoint(value) {
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "").split("?", 1)[0].replace(/\/+$/, "");
return path.endsWith("/responses/compact");
}
function endpoint(value) {
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "");
if (isCompactEndpoint(path)) return "compact";
if (path.endsWith("/chat/completions")) return "chat";
if (path.endsWith("/responses")) return "responses";
return path.replace(/^\/v1\//, "") || "-";
}
function result(record) {
if (record.outcome === "canceled") return "已取消";
if (record.outcome === "rejected") return "已拒绝";
if (record.failed || record.outcome === "failed") return record.status_code ? `失败 (${record.status_code})` : "失败";
return "成功";
}
const columns = [
{ id: "time", label: "时间", align: "left", value: record => new Date(record.requested_at).toLocaleString() },
{ id: "key", label: "Key / 别名", align: "left", value: record => record.key_alias || record.api_key || "-" },
{ id: "upstream", label: "上游账号", align: "left", value: record => record.auth_id || record.auth_index || record.auth_type || "-" },
{ id: "model", label: "模型", align: "left", value: record => record.model || "-" },
{ id: "reasoning_effort", label: "推理强度", align: "left", value: record => record.reasoning_effort || "-" },
{ id: "service_tier", label: "模式", align: "left", value: record => String(record.speed || "").toLowerCase() === "fast" ? "fast" : (record.service_tier || "-") },
{ id: "result", label: "结果", align: "left", value: record => result(record) },
{ id: "request_type", label: "类型", align: "left", value: record => record.request_type || "-" },
{ id: "endpoint", label: "端点", align: "left", value: record => endpoint(record.endpoint) },
{ id: "ttft", label: "首字延迟", value: record => isCompactEndpoint(record.endpoint) ? "-" : duration(record.ttft_ms) },
{ id: "speed", label: "生成速度", value: record => isCompactEndpoint(record.endpoint) ? "-" : speed(record.speed_tps) },
{ id: "input", label: "输入", value: record => number(record.input_tokens) },
{ id: "output", label: "输出", value: record => number(record.output_tokens) },
{ id: "reasoning", label: "推理", value: record => number(record.reasoning_tokens) },
{ id: "cache_read", label: "缓存读取", value: record => number(record.cache_read_tokens) },
{ id: "cache_write", label: "缓存写入", value: record => number(record.cache_write_tokens) },
{ id: "cache_rate", label: "缓存率", value: record => percent(record.cache_rate) },
{ id: "total", label: "Token 总数", value: record => number(record.total_tokens) },
{ id: "cost", label: "总成本", value: record => cost(record) },
{ id: "client_ip", label: "客户端 IP", align: "left", value: record => record.client_ip || "-" }
];
function loadVisibleColumns() {
try {
const saved = JSON.parse(localStorage.getItem(columnStoreKey));
if (Array.isArray(saved)) {
const valid = saved.filter(id => columns.some(column => column.id === id));
if (valid.length) return new Set(valid);
}
} catch (_) {}
return new Set(columns.map(column => column.id));
}
let visibleColumns = loadVisibleColumns();
function renderColumnControls() {
headersNode.replaceChildren(...columns.filter(column => visibleColumns.has(column.id)).map(column => {
const header = document.createElement("th");
header.textContent = column.label;
if (column.align) header.className = column.align;
return header;
}));
columnOptionsNode.replaceChildren(...columns.map(column => {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.checked = visibleColumns.has(column.id);
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) {
input.checked = true;
return;
}
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
try { localStorage.setItem(columnStoreKey, JSON.stringify(columns.filter(item => visibleColumns.has(item.id)).map(item => item.id))); } catch (_) {}
renderColumnControls();
render(currentRecords);
});
label.append(input, column.label);
return label;
}));
}
function render(records) {
currentRecords = records;
const pageCount = Math.max(1, Math.ceil(currentRecords.length / PAGE_SIZE));
currentPage = Math.min(Math.max(1, currentPage), pageCount);
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
const start = (currentPage - 1) * PAGE_SIZE;
const pageRecords = currentRecords.slice(start, start + PAGE_SIZE);
rowsNode.replaceChildren(...pageRecords.map(record => {
const row = document.createElement("tr");
activeColumns.forEach(column => {
const cell = document.createElement("td");
cell.textContent = column.value(record);
if (column.align) cell.classList.add(column.align);
if (column.id === "result") cell.classList.add(record.failed ? "failed" : "ok");
row.appendChild(cell);
});
return row;
}));
renderPagination(pageCount, start, pageRecords.length);
}
function pageItems(page, count) {
if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1);
const values = new Set([1, count, page - 1, page, page + 1]);
const sorted = [...values].filter(value => value >= 1 && value <= count).sort((a, b) => a - b);
const items = [];
sorted.forEach((value, index) => {
if (index && value - sorted[index - 1] > 1) items.push("ellipsis-" + value);
items.push(value);
});
return items;
}
function renderPagination(pageCount, start, visibleCount) {
const controls = [];
const button = (label, page, disabled = false, active = false) => {
const node = document.createElement("button"); node.type = "button"; node.textContent = label; node.disabled = disabled; node.classList.toggle("active", active);
if (!disabled && !active) node.addEventListener("click", () => { currentPage = page; render(currentRecords); });
return node;
};
controls.push(button("上一页", currentPage - 1, currentPage === 1));
pageItems(currentPage, pageCount).forEach(item => {
if (typeof item === "string") { const ellipsis = document.createElement("span"); ellipsis.className = "page-ellipsis"; ellipsis.textContent = "…"; controls.push(ellipsis); }
else controls.push(button(String(item), item, false, item === currentPage));
});
controls.push(button("下一页", currentPage + 1, currentPage === pageCount));
pageButtonsNode.replaceChildren(...controls);
}
function authHeaders(json = false) {
const headers = { Authorization: "Bearer " + keyInput.value.trim() };
if (json) headers["Content-Type"] = "application/json";
return headers;
}
async function managedFetch(url, options = {}) {
if (testMode) throw new Error("测试模式禁止访问真实管理接口");
const headers = authHeaders(Boolean(options.body));
const response = await fetch(url, { ...options, headers: { ...headers, ...(options.headers || {}) } });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
return payload;
}
function option(value, label, selected = false) {
const node = document.createElement("option");
node.value = value; node.textContent = label; node.selected = selected;
return node;
}
function upstreamLabel(account) {
if (!account) return "未指定";
return `${account.provider || "unknown"} · ${account.display_name || account.cpa_auth_id || account.id}`;
}
function routeLabel(key) {
if (key.route_mode !== "strict") return { main: "自动选择", sub: "由 CPA 调度" };
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
return { main: "指定账号", sub: upstreamLabel(account) };
}
function copyText(value, button) {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(value).then(() => {
const previous = button.textContent; button.textContent = "已复制";
setTimeout(() => { button.textContent = previous; }, 1200);
});
}
function openDrawer() {
drawerNode.classList.remove("hidden");
drawerOverlayNode.classList.remove("hidden");
drawerNode.setAttribute("aria-hidden", "false");
}
function closeDrawer() {
drawerNode.classList.add("hidden");
drawerOverlayNode.classList.add("hidden");
drawerNode.setAttribute("aria-hidden", "true");
editingKey = null;
}
function syncEditorRoute() {
const archived = editingKey?.status === "archived";
const strict = document.querySelector("#editor-route").value === "strict";
document.querySelector("#editor-upstream").disabled = archived || !strict;
document.querySelector("#editor-models").disabled = archived || document.querySelector("#editor-all-models").checked;
}
function openKeyEditor(key = null) {
editingKey = key;
const creating = !key;
document.querySelector("#drawer-title").textContent = creating ? "创建 Key" : `管理 ${key.name}`;
document.querySelector("#drawer-subtitle").textContent = creating ? "创建后可继续配置路由和模型权限" : "修改用户状态、路由和模型权限";
editorNode.classList.remove("hidden"); keyStatsNode.classList.add("hidden"); editorFooterNode.classList.remove("hidden");
managedFieldsNode.classList.toggle("hidden", creating);
document.querySelector("#editor-name").value = key?.name || "";
const secret = document.querySelector("#editor-secret"); secret.value = key?.secret || ""; secret.readOnly = !creating;
document.querySelector("#editor-status").value = key?.status || "active";
document.querySelector("#editor-route").value = key?.route_mode || "auto";
const upstream = document.querySelector("#editor-upstream");
upstream.replaceChildren(option("", "未指定", !key?.upstream_account_id), ...upstreamAccounts.map(account => option(account.id, `${upstreamLabel(account)}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key?.upstream_account_id)));
document.querySelector("#editor-all-models").checked = key?.all_models ?? true;
const models = document.querySelector("#editor-models"); models.value = (key?.models || []).join(", "); models.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol";
document.querySelector("#archive-key").classList.toggle("hidden", creating || key.status === "archived");
document.querySelector("#save-key").classList.toggle("hidden", key?.status === "archived");
document.querySelector("#save-key").textContent = creating ? "创建 Key" : "保存修改";
["editor-name", "editor-status", "editor-route", "editor-all-models"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
syncEditorRoute(); openDrawer();
}
function renderManagedKeys() {
keyRowsNode.replaceChildren(...managedKeys.map(key => {
const row = document.createElement("tr");
const archived = key.status === "archived";
const identity = document.createElement("div"); identity.className = "key-identity";
const avatar = document.createElement("span"); avatar.className = "avatar"; avatar.textContent = (key.name || "K").slice(0, 2).toUpperCase();
const identityText = document.createElement("div"); const name = document.createElement("strong"); name.textContent = key.name; const id = document.createElement("small"); id.textContent = key.id; identityText.append(name, id); identity.append(avatar, identityText);
const credential = document.createElement("div"); credential.className = "credential"; const secret = document.createElement("code"); secret.textContent = key.secret;
const copy = document.createElement("button"); copy.type = "button"; copy.className = "small"; copy.textContent = "复制"; copy.addEventListener("click", () => copyText(key.secret, copy)); credential.append(secret, copy);
const status = document.createElement("span"); status.className = `badge ${key.status}`; status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
const route = routeLabel(key); const routeNode = document.createElement("div"); const routeMain = document.createElement("div"); routeMain.className = "route-main"; routeMain.textContent = route.main; const routeSub = document.createElement("div"); routeSub.className = "route-sub"; routeSub.textContent = route.sub; routeNode.append(routeMain, routeSub);
const models = document.createElement("span"); models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
const actions = document.createElement("div"); actions.className = "key-actions";
const stats = document.createElement("button"); stats.type = "button"; stats.className = "small"; stats.textContent = "统计"; stats.addEventListener("click", () => loadKeyStats(key));
const manage = document.createElement("button"); manage.type = "button"; manage.className = "small"; manage.textContent = archived ? "查看" : "管理"; manage.addEventListener("click", () => openKeyEditor(key)); actions.append(stats, manage);
[identity, credential, status, routeNode, models, actions].forEach(node => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
return row;
}));
const today = startOfDay(new Date());
const todayRecords = currentRecords.filter(record => new Date(record.requested_at) >= today);
document.querySelector("#metric-requests").textContent = number(todayRecords.length);
document.querySelector("#metric-tokens").textContent = compactNumber(todayRecords.reduce((sum, record) => sum + (record.total_tokens || 0), 0));
const todayCost = todayRecords.reduce((sum, record) => sum + (record.cost_available && Number.isFinite(record.cost_usd) ? record.cost_usd : 0), 0);
document.querySelector("#metric-cost").textContent = "$" + todayCost.toFixed(4);
renderUserCharts();
}
function startOfDay(value) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function compactNumber(value) {
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
}
function renderUserCharts() {
const today = startOfDay(new Date());
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
currentRecords.forEach(record => {
const name = record.key_alias || record.api_key || "未识别";
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
const value = byUser.get(name); const requestedAt = new Date(record.requested_at);
if (!value.last || requestedAt > value.last) value.last = requestedAt;
if (requestedAt >= today) {
value.requests++; value.tokens += record.total_tokens || 0;
if (record.cost_available && Number.isFinite(record.cost_usd)) value.cost += record.cost_usd;
}
});
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
const usageNode = document.querySelector("#user-usage");
const header = document.createElement("div"); header.className = "user-usage-row header"; ["用户", "今日请求", "今日 Token", "今日成本", "最近使用"].forEach(label => { const cell = document.createElement("span"); cell.textContent = label; header.append(cell); });
usageNode.replaceChildren(header, ...userRows.map(item => { const row = document.createElement("div"); row.className = "user-usage-row"; [item.name, number(item.requests), number(item.tokens), "$" + item.cost.toFixed(4), item.last ? item.last.toLocaleString() : "从未"].forEach(value => { const cell = document.createElement("span"); cell.textContent = value; row.append(cell); }); return row; }));
const days = Array.from({ length: 7 }, (_, index) => { const date = startOfDay(new Date()); date.setDate(date.getDate() - (6 - index)); return { date, tokens: 0 }; });
currentRecords.forEach(record => { const requestedAt = new Date(record.requested_at); const day = days.find(item => requestedAt >= item.date && requestedAt < new Date(item.date.getTime() + 86400000)); if (day) day.tokens += record.total_tokens || 0; });
const maximum = Math.max(1, ...days.map(day => day.tokens));
document.querySelector("#daily-chart").replaceChildren(...days.map(day => { const row = document.createElement("div"); row.className = "bar-row"; const label = document.createElement("span"); label.className = "bar-label"; label.textContent = `${day.date.getMonth() + 1}/${day.date.getDate()}`; const track = document.createElement("span"); track.className = "bar-track"; const value = document.createElement("span"); value.className = "bar-value"; value.style.width = `${day.tokens / maximum * 100}%`; track.append(value); const count = document.createElement("span"); count.className = "bar-count"; count.textContent = compactNumber(day.tokens); row.append(label, track, count); return row; }));
}
async function loadKeyManagement() {
if (testMode) {
managedKeys = demoState.keys.filter(key => document.querySelector("#include-archived").checked || key.status !== "archived");
upstreamAccounts = demoState.upstreams;
modelSuggestions = ["deepseek-v4-flash", "deepseek-source-a", "deepseek-source-b", "deepseek-source-c"];
currentRecords = demoState.usage;
renderManagedKeys();
keyStatusNode.textContent = `测试数据 · ${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号`;
return;
}
if (!keyInput.value.trim()) return;
keyStatusNode.textContent = "正在同步";
try {
const [upstreams, models, usage] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(API)]);
upstreamAccounts = upstreams.accounts || [];
modelSuggestions = models.models || [];
currentRecords = usage.records || [];
const archived = document.querySelector("#include-archived").checked ? "?include_archived=1" : "";
const keys = await managedFetch(KEYS_API + archived);
managedKeys = keys.keys || [];
renderManagedKeys();
keyStatusNode.textContent = `共 ${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}`;
} catch (error) { keyStatusNode.textContent = "读取失败: " + error.message; }
}
async function createManagedKey() {
const name = document.querySelector("#editor-name").value.trim();
const secret = document.querySelector("#editor-secret").value.trim();
try {
if (testMode) {
if (!name) throw new Error("请输入名称");
if (demoState.keys.some(key => key.name.toLowerCase() === name.toLowerCase())) throw new Error("名称已经存在");
const created = { id: "demo-key-" + Date.now(), name, secret: secret || `demo-${Date.now()}-000000`, status: "active", route_mode: "auto", upstream_account_id: "", all_models: true, models: [] };
demoState.keys.push(created); saveDemoState(); closeDrawer(); await loadKeyManagement();
keyStatusNode.textContent = `测试数据已创建 ${created.name}${created.secret}`;
return;
}
const created = await managedFetch(KEYS_API, { method: "POST", body: JSON.stringify({ name, secret }) });
keyStatusNode.textContent = `已创建 ${created.name}${created.secret}`;
closeDrawer(); await loadKeyManagement();
} catch (error) { keyStatusNode.textContent = "创建失败: " + error.message; }
}
async function updateManagedKey(payload) {
try {
if (testMode) {
const index = demoState.keys.findIndex(key => key.id === payload.id);
if (index < 0) throw new Error("测试 Key 不存在");
demoState.keys[index] = { ...demoState.keys[index], ...payload }; saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
}
await managedFetch(KEYS_API, { method: "PATCH", body: JSON.stringify(payload) }); closeDrawer(); await loadKeyManagement();
}
catch (error) { keyStatusNode.textContent = "保存失败: " + error.message; }
}
async function saveKeyEditor() {
if (!editingKey) return createManagedKey();
const route = document.querySelector("#editor-route").value;
const allModels = document.querySelector("#editor-all-models").checked;
return updateManagedKey({
id: editingKey.id,
name: document.querySelector("#editor-name").value.trim(),
status: document.querySelector("#editor-status").value,
route_mode: route,
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
all_models: allModels,
models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean)
});
}
async function archiveManagedKey(key) {
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
try {
if (testMode) {
const found = demoState.keys.find(item => item.id === key.id); if (!found) throw new Error("测试 Key 不存在"); found.status = "archived"; saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
}
await managedFetch(KEYS_API, { method: "DELETE", body: JSON.stringify({ id: key.id }) }); closeDrawer(); await loadKeyManagement();
}
catch (error) { keyStatusNode.textContent = "归档失败: " + error.message; }
}
async function loadKeyStats(key) {
try {
let payload;
if (testMode) {
const recent = demoState.usage.filter(item => item.key_alias === key.name);
const since = new Date(); since.setHours(0, 0, 0, 0);
const summarize = records => ({ requests: records.length, total_tokens: records.reduce((sum, item) => sum + item.total_tokens, 0), cost_micros: Math.round(records.reduce((sum, item) => sum + (item.cost_usd || 0), 0) * 1000000) });
payload = { stats: { total: summarize(recent), today: summarize(recent.filter(item => new Date(item.requested_at) >= since)) }, recent };
} else {
payload = await managedFetch(KEY_STATS_API + "?id=" + encodeURIComponent(key.id));
}
const total = payload.stats.total, today = payload.stats.today;
document.querySelector("#drawer-title").textContent = `${key.name} · 统计`;
document.querySelector("#drawer-subtitle").textContent = "历史统计永久保留";
editorNode.classList.add("hidden"); editorFooterNode.classList.add("hidden"); keyStatsNode.classList.remove("hidden");
statsGridNode.replaceChildren(...[
["今日请求", number(today.requests)], ["今日 Token", number(today.total_tokens)],
["累计请求", number(total.requests)], ["累计成本", "$" + (total.cost_micros / 1000000).toFixed(4)]
].map(([label, value]) => { const tile = document.createElement("div"); tile.className = "stats-tile"; const name = document.createElement("span"); name.textContent = label; const strong = document.createElement("strong"); strong.textContent = value; tile.append(name, strong); return tile; }));
const recent = (payload.recent || []).slice(0, 10);
statsRecentNode.replaceChildren(...(recent.length ? recent.map(item => { const row = document.createElement("div"); row.className = "recent-item"; const model = document.createElement("strong"); model.textContent = `${item.model || "-"} · ${result(item)}`; const detail = document.createElement("span"); detail.textContent = `${new Date(item.requested_at).toLocaleString()} · ${number(item.total_tokens)} Token · ${item.auth_id || "自动选择"}`; row.append(model, detail); return row; }) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无请求" })]));
openDrawer();
} catch (error) { keyStatusNode.textContent = "读取统计失败: " + error.message; }
}
function setLongFields(enabled) {
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
}
function setView(view) {
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
try { sessionStorage.setItem("cpa-ext:active-view", view); } catch (_) {}
if (view === "usage") load(true);
if (view === "pricing") loadPrices();
if (view === "keys") loadKeyManagement();
}
function setMode(mode) {
testMode = mode === "demo";
document.body.classList.toggle("demo-mode", testMode);
document.querySelectorAll(".mode-button").forEach(button => button.classList.toggle("active", button.dataset.mode === mode));
document.querySelector("#demo-banner").classList.toggle("hidden", !testMode);
keyInput.disabled = testMode;
keyInput.placeholder = testMode ? "测试模式无需密钥" : "输入管理密钥";
lastSignature = ""; currentPage = 1; closeDrawer();
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = [];
render([]); renderManagedKeys(); renderPrices();
try { sessionStorage.setItem("cpa-ext:data-mode", mode); } catch (_) {}
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
if (active === "keys") loadKeyManagement();
if (active === "usage") load(true);
if (active === "pricing") loadPrices();
}
function clearPriceForm() {
["price-model", "price-input", "price-cache-read", "price-cache-write", "price-output", "long-threshold", "long-input", "long-cache-read", "long-cache-write", "long-output"].forEach(id => document.querySelector("#" + id).value = "");
document.querySelector("#long-enabled").checked = false;
document.querySelector("#long-comparison").value = "gt";
document.querySelector("#fast-pricing-enabled").checked = false;
document.querySelector("#fast-multiplier").value = "2.5";
setLongFields(false);
}
function fillPriceForm(price) {
document.querySelector("#price-model").value = price.model;
document.querySelector("#price-input").value = price.base.input_per_1m;
document.querySelector("#price-cache-read").value = price.base.cache_read_per_1m;
document.querySelector("#price-cache-write").value = price.base.cache_write_per_1m;
document.querySelector("#price-output").value = price.base.output_per_1m;
const long = price.long_context;
document.querySelector("#long-enabled").checked = Boolean(long);
document.querySelector("#long-threshold").value = long?.threshold_input_tokens ?? "";
document.querySelector("#long-comparison").value = long?.comparison || "gt";
document.querySelector("#long-input").value = long?.input_per_1m ?? "";
document.querySelector("#long-cache-read").value = long?.cache_read_per_1m ?? "";
document.querySelector("#long-cache-write").value = long?.cache_write_per_1m ?? "";
document.querySelector("#long-output").value = long?.output_per_1m ?? "";
document.querySelector("#fast-pricing-enabled").checked = price.fast_pricing_enabled;
document.querySelector("#fast-multiplier").value = price.fast_multiplier || "2.5";
setLongFields(Boolean(long));
}
function pricePayload() {
const value = id => document.querySelector("#" + id).value.trim();
const payload = {
model: value("price-model"),
base: { input_per_1m: value("price-input"), cache_read_per_1m: value("price-cache-read"), cache_write_per_1m: value("price-cache-write"), output_per_1m: value("price-output") },
fast_pricing_enabled: document.querySelector("#fast-pricing-enabled").checked,
fast_multiplier: value("fast-multiplier")
};
if (document.querySelector("#long-enabled").checked) {
payload.long_context = {
threshold_input_tokens: Number(value("long-threshold")), comparison: value("long-comparison"),
input_per_1m: value("long-input"), cache_read_per_1m: value("long-cache-read"), cache_write_per_1m: value("long-cache-write"), output_per_1m: value("long-output")
};
}
return payload;
}
function renderPrices() {
if (!currentPrices.length) {
priceListNode.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "尚未配置模型" }));
return;
}
priceListNode.replaceChildren(...currentPrices.map(price => {
const row = document.createElement("div");
row.className = "price-row";
const text = document.createElement("div");
const model = document.createElement("div");
model.className = "price-name";
model.textContent = price.model;
const detail = document.createElement("small");
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""}`;
text.append(model, detail);
const actions = document.createElement("div"); actions.className = "key-actions";
const edit = document.createElement("button");
edit.type = "button"; edit.className = "small"; edit.textContent = "编辑"; edit.addEventListener("click", () => fillPriceForm(price));
const remove = document.createElement("button");
remove.type = "button"; remove.className = "small danger"; remove.textContent = "删除"; remove.addEventListener("click", () => deletePrice(price.model));
actions.append(edit, remove); row.append(text, actions); return row;
}));
}
async function loadPrices() {
if (testMode) {
currentPrices = demoState.prices.map(price => structuredClone(price)); renderPrices();
priceStatusNode.textContent = `测试数据 · 已配置 ${currentPrices.length} 个模型`; return;
}
if (!keyInput.value.trim()) return;
try {
const response = await fetch(PRICE_API, { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
currentPrices = payload.prices || [];
renderPrices();
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
}
async function savePrice() {
try {
if (testMode) {
const payload = pricePayload(); const index = demoState.prices.findIndex(price => price.model === payload.model);
if (!payload.model) throw new Error("请输入模型");
if (index >= 0) demoState.prices[index] = payload; else demoState.prices.push(payload);
saveDemoState(); priceStatusNode.textContent = "测试价格已保存"; await loadPrices(); return;
}
const response = await fetch(PRICE_API, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(pricePayload()) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
priceStatusNode.textContent = "价格已保存";
await loadPrices();
} catch (error) { priceStatusNode.textContent = "保存失败: " + error.message; }
}
async function deletePrice(model) {
try {
if (testMode) {
demoState.prices = demoState.prices.filter(price => price.model !== model); saveDemoState();
if (document.querySelector("#price-model").value.trim() === model) clearPriceForm(); await loadPrices(); return;
}
if (!confirm(`删除 ${model} 的真实价格配置?`)) return;
const response = await fetch(PRICE_API, { method: "DELETE", headers: authHeaders(true), body: JSON.stringify({ model }) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
if (document.querySelector("#price-model").value.trim() === model) clearPriceForm();
await loadPrices();
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
}
async function load(manual = false) {
if (loading) return;
if (testMode) {
currentRecords = demoState.usage; render(currentRecords);
statusNode.textContent = "";
return;
}
const key = keyInput.value.trim();
if (!key) {
statusNode.textContent = "请输入管理密钥";
return;
}
loading = true;
if (manual) statusNode.textContent = "正在读取";
try {
const response = await fetch(API, { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
const records = payload.records || [];
const signature = JSON.stringify(records);
if (signature !== lastSignature) {
lastSignature = signature;
render(records);
}
statusNode.textContent = "";
} catch (error) {
statusNode.textContent = "读取失败: " + error.message;
} finally {
loading = false;
}
}
keyInput.value = storedPanelKey() || sessionStorage.getItem("cpa-ext:management-key") || "";
keyInput.addEventListener("change", () => sessionStorage.setItem("cpa-ext:management-key", keyInput.value.trim()));
document.querySelector("#refresh").addEventListener("click", () => load(true));
document.querySelector("#long-enabled").addEventListener("change", event => setLongFields(event.target.checked));
document.querySelector("#save-price").addEventListener("click", savePrice);
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
document.querySelector("#new-price").addEventListener("click", clearPriceForm);
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
document.querySelector("#reload-keys").addEventListener("click", loadKeyManagement);
document.querySelector("#include-archived").addEventListener("change", loadKeyManagement);
document.querySelector("#close-drawer").addEventListener("click", closeDrawer);
document.querySelector("#cancel-editor").addEventListener("click", closeDrawer);
drawerOverlayNode.addEventListener("click", closeDrawer);
document.querySelector("#save-key").addEventListener("click", saveKeyEditor);
document.querySelector("#archive-key").addEventListener("click", () => editingKey && archiveManagedKey(editingKey));
document.querySelector("#editor-route").addEventListener("change", syncEditorRoute);
document.querySelector("#editor-all-models").addEventListener("change", syncEditorRoute);
document.querySelectorAll(".nav-button").forEach(button => button.addEventListener("click", () => setView(button.dataset.view)));
document.querySelectorAll(".mode-button").forEach(button => button.addEventListener("click", () => setMode(button.dataset.mode)));
document.querySelector("#reset-demo").addEventListener("click", () => { demoState = freshDemoState(); saveDemoState(); currentPage = 1; setMode("demo"); });
document.addEventListener("keydown", event => { if (event.key === "Escape") closeDrawer(); });
document.addEventListener("visibilitychange", () => { if (!testMode && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(); });
renderColumnControls();
setMode(sessionStorage.getItem("cpa-ext:data-mode") || "live");
setView(sessionStorage.getItem("cpa-ext:active-view") || "keys");
setInterval(() => { if (!testMode && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(); }, 3000);
</script>
</body>
</html>