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

1186 lines
89 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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
/* Keep these tokens aligned with CLIProxyAPI Manager's dark theme. */
--bg-primary: #1d1b18; --bg-secondary: #151412; --bg-tertiary: #262320; --bg-hover: #2e2a26; --bg-quinary: #191714; --floating-surface: #2a2723;
--border-color: #3a3530; --border-primary: #4a453f; --border-hover: #5a544d;
--text-primary: #f6f4f1; --text-secondary: #c9c3bb; --text-tertiary: #9c958d; --text-quaternary: #6f6962;
--primary-color: #8b8680; --primary-hover: #9a948e; --primary-active: #a6a099;
--success-color: #10b981; --error-color: #c65746; --warning-color: #ffd862; --amber-color: #f59e0b;
--bg: var(--bg-primary); --panel: var(--bg-secondary); --panel-raised: var(--floating-surface); --panel-soft: var(--bg-quinary);
--border: var(--border-color); --border-soft: var(--border-color); --text: var(--text-primary); --muted: var(--text-tertiary);
--accent: var(--primary-active); --accent-strong: var(--primary-color); --accent-soft: var(--bg-tertiary);
--success: var(--success-color); --danger: var(--error-color); --warning: var(--warning-color);
}
* { 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: color-mix(in srgb, var(--bg-primary) 92%, transparent); 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 var(--border-primary); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); 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: var(--bg-secondary); }
.mode-button { border: 0; padding: 5px 9px; background: transparent; color: var(--muted); }
.mode-button:hover { border-color: transparent; background: var(--bg-hover); }
.mode-button.active { background: var(--bg-tertiary); color: var(--text); }
.demo-mode .mode-button[data-mode="demo"] { background: color-mix(in srgb, var(--amber-color) 38%, transparent); color: #fcd34d; }
.demo-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 22px; border-bottom: 1px solid color-mix(in srgb, var(--amber-color) 45%, transparent); background: color-mix(in srgb, var(--amber-color) 14%, var(--bg-secondary)); color: #fcd34d; font-size: 11px; }
.demo-banner button { border-color: color-mix(in srgb, var(--amber-color) 45%, transparent); background: color-mix(in srgb, var(--amber-color) 24%, transparent); color: #fcd34d; }
input, select { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; outline: none; background: var(--bg-tertiary); color: inherit; transition: border-color .15s, box-shadow .15s; }
input:focus, select:focus { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
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: var(--bg-secondary); 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: var(--bg-tertiary); color: var(--text); font-size: 12px; }
button:hover, summary:hover { border-color: var(--border-hover); background: var(--bg-hover); }
button.primary { border-color: var(--primary-color); background: var(--primary-color); color: white; }
button.primary:hover { border-color: var(--primary-hover); background: var(--primary-hover); }
button.danger { color: #f1b0a6; }
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: var(--panel); }
.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: var(--text-quaternary); 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: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: var(--bg-tertiary); }
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--primary-active); }
.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: var(--bg-primary); }
.price-row:hover { border-color: var(--border-hover); background: var(--bg-hover); }
.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: var(--bg-quinary); }
.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 var(--border-primary); border-radius: 8px; background: var(--accent-soft); color: var(--text-secondary); 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: var(--text-quaternary); font-size: 10px; text-overflow: ellipsis; }
.credential { display: flex; align-items: center; }
.credential-copy { cursor: copy; border-radius: 4px; padding: 3px 4px; }
.credential-copy:hover { background: var(--bg-hover); color: var(--text-primary); }
.credential-copy:focus-visible { outline: 2px solid var(--primary-active); outline-offset: 2px; }
code { color: var(--text-secondary); 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: #059669; background: #064e3b4d; color: #6ee7b7; }
.badge.disabled, .badge.archived { color: var(--text-tertiary); }
.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: var(--bg-quinary); 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: color-mix(in srgb, var(--text-primary) 3%, transparent); }
.ok { color: var(--success-color); }
.failed { color: var(--error-color); }
.drawer-overlay { position: fixed; z-index: 40; inset: 0; background: #000000a6; backdrop-filter: blur(2px); }
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(540px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: var(--bg-primary); 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; }
.drawer-section { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--bg-secondary); }
.drawer-section h3 { margin: 0; font-size: 11px; }
.field-hint { margin: -2px 0 0; color: var(--text-quaternary); font-size: 9px; }
.billing-preview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.billing-preview div { padding: 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--bg-primary); }
.billing-preview span { display: block; color: var(--muted); font-size: 9px; }
.billing-preview strong { display: block; margin-top: 3px; font-size: 14px; }
.billing-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ledger-list { display: grid; max-height: 210px; overflow: auto; gap: 5px; }
.ledger-item { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; padding: 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--bg-primary); }
.ledger-item strong { font-size: 10px; }
.ledger-item .positive { color: var(--success); }
.ledger-item .negative { color: var(--danger); }
.ledger-item small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.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: var(--primary-color); background: var(--primary-color); color: white; }
.page-buttons button:disabled { cursor: default; opacity: .4; }
.page-ellipsis { width: 22px; color: var(--muted); text-align: center; font-size: 10px; }
.usage-filters { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: 9px; margin-bottom: 10px; padding: 11px; }
.usage-filter { display: grid; gap: 5px; min-width: 0; color: var(--muted); font-size: 9px; }
.usage-filter input, .usage-filter select { height: 32px; padding: 6px 8px; font-size: 11px; }
.usage-filter.request-filter { grid-column: span 2; }
.usage-filter-actions { display: flex; align-items: end; justify-content: flex-end; gap: 7px; grid-column: span 2; }
@media (max-width: 1100px) { .usage-filters { grid-template-columns: repeat(3, minmax(140px, 1fr)); } }
@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; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
</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></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><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 usage-filters">
<label class="usage-filter">开始时间<input id="usage-from" type="datetime-local" step="1"></label>
<label class="usage-filter">结束时间<input id="usage-to" type="datetime-local" step="1"></label>
<label class="usage-filter">用户<select id="usage-key"><option value="">全部用户</option></select></label>
<label class="usage-filter">模型<input id="usage-model" list="usage-model-options" placeholder="全部模型"><datalist id="usage-model-options"></datalist></label>
<label class="usage-filter">结果<select id="usage-result"><option value="">全部结果</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="rejected">已拒绝</option><option value="canceled">已取消</option></select></label>
<label class="usage-filter">上游<select id="usage-auth"><option value="">全部上游</option></select></label>
<label class="usage-filter">端点<select id="usage-endpoint"><option value="">全部端点</option><option value="responses">responses</option><option value="compact">compact</option><option value="chat">chat</option></select></label>
<label class="usage-filter request-filter">请求 ID<input id="usage-request-id" placeholder="粘贴完整 Request ID"></label>
<div class="usage-filter-actions"><button id="clear-usage-filters" type="button">清空</button><button id="apply-usage-filters" class="primary" 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">
<div class="drawer-section"><h3>用户</h3><label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
<label class="field">完整 Key<input id="editor-secret" class="secret" minlength="6" maxlength="256" placeholder="留空自动生成"></label></div>
<div id="managed-fields" class="drawer-form">
<div class="drawer-section"><h3>访问与路由</h3>
<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="deepseek-*gpt-5.6-sol"></label><p class="field-hint">多个规则用逗号分隔;* 匹配任意字符,未匹配的模型会被拒绝。</p></div>
<div class="drawer-section"><h3>额度与周期</h3>
<div id="billing-preview" class="billing-preview"><div><span>额度</span><strong id="billing-quota-view">$0.0000</strong></div><div><span>已用</span><strong id="billing-spent-view">$0.0000</strong></div><div><span>余额</span><strong id="billing-balance-view">$0.0000</strong></div></div>
<div class="field-row"><label class="field">本周期额度(USD<input id="editor-quota" inputmode="decimal" value="0"></label><label class="field">并发上限<input id="editor-concurrency" type="number" min="1" max="64" step="1" value="4"></label></div>
<div class="field-row"><label class="field">自动重置<select id="editor-reset-period"><option value="none">不重置</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option></select></label><label class="field">下次重置时间<input id="editor-next-reset" type="datetime-local" step="1"></label></div>
<p class="field-hint">请求结束后按实际费用扣款;最后一个并发请求可能产生负余额。</p>
<div class="billing-tools"><span id="billing-status" class="status"></span><button id="reset-billing" class="small" type="button">立即重置额度</button></div>
</div>
<div id="ledger-section" class="drawer-section"><h3>最近账目</h3><div id="billing-ledger" class="ledger-list"></div></div>
</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 SUMMARY_API = "/v0/management/plugins/cpa-ext/usage-summary";
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 BILLING_RESET_API = "/v0/management/plugins/cpa-ext/billing-reset";
const BILLING_LEDGER_API = "/v0/management/plugins/cpa-ext/billing-ledger";
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:v3";
let currentRecords = [];
let currentPage = 1;
let currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
let usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
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: false, models: ["deepseek-*"] },
{ 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: false, models: ["deepseek-source-b", "deepseek-v4-flash"] },
{ 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(), managed_key_id: key.id, 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"
};
});
const ledger = [];
keys.forEach((key, keyIndex) => {
const records = usage.filter(item => item.key_alias === key.name);
const spent = records.reduce((sum, item) => sum + (item.cost_usd || 0), 0);
const quota = 250 + keyIndex * 50;
key.billing = { quota_usd: String(quota), spent_usd: spent.toFixed(6), balance_usd: (quota - spent).toFixed(6), reset_period: keyIndex % 2 ? "monthly" : "none", next_reset_at: keyIndex % 2 ? new Date(now + 86400000 * 20).toISOString() : null, max_concurrency: 4, active_requests: keyIndex % 3, cycle_started_at: new Date(now - 86400000 * 10).toISOString() };
records.slice(0, 12).forEach((record, index) => ledger.push({ id: keyIndex * 100 + index + 1, key_id: key.id, kind: "charge", amount_usd: (-record.cost_usd).toFixed(6), balance_after_usd: (quota - spent + index * record.cost_usd).toFixed(6), request_id: record.request_id, model: record.model, occurred_at: record.requested_at }));
});
return { keys, upstreams, usage, prices: [demoPrice()], ledger };
}
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) && Array.isArray(value.ledger)) 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 money(value) {
const amount = Number(value || 0);
return "$" + (Number.isFinite(amount) ? amount : 0).toFixed(4);
}
function localDateTimeValue(value) {
if (!value) return "";
const date = new Date(value);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 19);
}
function billingPayload() {
const period = document.querySelector("#editor-reset-period").value;
const nextValue = document.querySelector("#editor-next-reset").value;
return {
quota_usd: document.querySelector("#editor-quota").value.trim() || "0",
reset_period: period,
next_reset_at: period === "none" || !nextValue ? null : new Date(nextValue).toISOString(),
max_concurrency: Number(document.querySelector("#editor-concurrency").value || 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 activeColumns = columns.filter(column => visibleColumns.has(column.id));
rowsNode.replaceChildren(...currentRecords.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();
}
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() {
const pageCount = currentPagination.total_pages;
currentPage = currentPagination.page || 1;
const controls = [];
const button = (label, page, disabled = false, active = false, cursor = "") => {
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", () => load(true, page, cursor));
return node;
};
controls.push(button("上一页", currentPage - 1, currentPage <= 1, false, currentPagination.previous_cursor || ""));
pageItems(currentPage, Math.max(1, 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, pageCount === 0 || currentPage >= pageCount, false, currentPagination.next_cursor || ""));
pageButtonsNode.replaceChildren(...controls);
}
function usageFilterQuery() {
const params = new URLSearchParams();
const from = document.querySelector("#usage-from").value;
const to = document.querySelector("#usage-to").value;
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to).toISOString());
[["key_id", "#usage-key"], ["model", "#usage-model"], ["result", "#usage-result"], ["auth_id", "#usage-auth"], ["endpoint", "#usage-endpoint"], ["request_id", "#usage-request-id"]].forEach(([name, selector]) => {
const value = document.querySelector(selector).value.trim();
if (value) params.set(name, value);
});
return params;
}
function demoUsagePage(page) {
const params = usageFilterQuery();
const from = params.get("from") ? new Date(params.get("from")) : null;
const to = params.get("to") ? new Date(params.get("to")) : null;
const resultValue = record => record.outcome === "canceled" ? "canceled" : record.outcome === "rejected" ? "rejected" : (record.failed || record.outcome === "failed") ? "failed" : "succeeded";
const endpointValue = record => endpoint(record.endpoint);
const records = demoState.usage.filter(record => {
const requestedAt = new Date(record.requested_at);
return (!from || requestedAt >= from) && (!to || requestedAt < to) &&
(!params.get("key_id") || record.managed_key_id === params.get("key_id")) &&
(!params.get("model") || String(record.model || "").toLowerCase() === params.get("model").toLowerCase()) &&
(!params.get("result") || resultValue(record) === params.get("result")) &&
(!params.get("auth_id") || record.auth_id === params.get("auth_id")) &&
(!params.get("endpoint") || endpointValue(record) === params.get("endpoint")) &&
(!params.get("request_id") || record.request_id === params.get("request_id"));
});
const totalPages = Math.ceil(records.length / PAGE_SIZE);
const safePage = Math.max(1, Math.min(page || 1, Math.max(1, totalPages)));
const start = (safePage - 1) * PAGE_SIZE;
return { records: records.slice(start, start + PAGE_SIZE), pagination: { page: safePage, page_size: PAGE_SIZE, total: records.length, total_pages: totalPages, previous_cursor: safePage > 1 ? "demo-previous" : "", next_cursor: safePage < totalPages ? "demo-next" : "" } };
}
function demoUsageDashboard() {
const today = startOfDay(new Date());
const summarize = records => ({ requests: records.length, input_tokens: records.reduce((sum, item) => sum + (item.input_tokens || 0), 0), output_tokens: records.reduce((sum, item) => sum + (item.output_tokens || 0), 0), total_tokens: records.reduce((sum, item) => sum + (item.total_tokens || 0), 0), cost_usd: records.reduce((sum, item) => sum + (item.cost_available ? item.cost_usd || 0 : 0), 0) });
const todayRecords = demoState.usage.filter(record => new Date(record.requested_at) >= today);
const users = demoState.keys.map(key => {
const records = demoState.usage.filter(record => record.managed_key_id === key.id);
return { key_id: key.id, key_alias: key.name, today: summarize(records.filter(record => new Date(record.requested_at) >= today)), last_used_at: records[0]?.requested_at || null };
});
const days = Array.from({ length: 7 }, (_, index) => {
const date = startOfDay(new Date()); date.setDate(date.getDate() - (6 - index));
const end = new Date(date); end.setDate(end.getDate() + 1);
return { date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, total_tokens: demoState.usage.filter(record => { const value = new Date(record.requested_at); return value >= date && value < end; }).reduce((sum, record) => sum + (record.total_tokens || 0), 0) };
});
return { today: summarize(todayRecords), users, days };
}
function populateUsageFilterOptions(keys, upstreams, models) {
const keySelect = document.querySelector("#usage-key");
const authSelect = document.querySelector("#usage-auth");
const selectedKey = keySelect.value, selectedAuth = authSelect.value;
keySelect.replaceChildren(option("", "全部用户"), ...keys.map(key => option(key.id, key.name, key.id === selectedKey)));
authSelect.replaceChildren(option("", "全部上游"), ...upstreams.map(account => option(account.cpa_auth_id, account.display_name || account.cpa_auth_id, account.cpa_auth_id === selectedAuth)));
document.querySelector("#usage-model-options").replaceChildren(...models.map(model => option(model, model)));
}
async function loadUsageFilterOptions() {
if (testMode) {
populateUsageFilterOptions(demoState.keys, demoState.upstreams, [...new Set(demoState.usage.map(record => record.model).filter(Boolean))]);
return;
}
if (!keyInput.value.trim()) return;
try {
const [keys, upstreams, models] = await Promise.all([managedFetch(KEYS_API + "?include_archived=1"), managedFetch(UPSTREAMS_API), managedFetch(MODELS_API)]);
populateUsageFilterOptions(keys.keys || [], upstreams.accounts || [], models.models || []);
} catch (error) { statusNode.textContent = "读取筛选项失败: " + error.message; }
}
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 "自由选择";
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
return account ? account.display_name || account.cpa_auth_id || account.id : "未指定";
}
function maskedSecret(value) {
const secret = String(value || "");
return secret.slice(0, 2) + "******" + secret.slice(-4);
}
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;
const period = document.querySelector("#editor-reset-period").value;
const nextReset = document.querySelector("#editor-next-reset");
nextReset.disabled = archived || period === "none";
if (period !== "none" && !nextReset.value) {
const next = new Date();
if (period === "daily") next.setDate(next.getDate() + 1);
else if (period === "weekly") next.setDate(next.getDate() + 7);
else next.setMonth(next.getMonth() + 1);
nextReset.value = localDateTimeValue(next);
}
}
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.remove("hidden");
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";
const billing = key?.billing || { quota_usd: "0", spent_usd: "0", balance_usd: "0", reset_period: "none", max_concurrency: 4, active_requests: 0 };
document.querySelector("#editor-quota").value = billing.quota_usd || "0";
document.querySelector("#editor-concurrency").value = billing.max_concurrency || 4;
document.querySelector("#editor-reset-period").value = billing.reset_period || "none";
document.querySelector("#editor-next-reset").value = localDateTimeValue(billing.next_reset_at);
document.querySelector("#billing-quota-view").textContent = money(billing.quota_usd);
document.querySelector("#billing-spent-view").textContent = money(billing.spent_usd);
document.querySelector("#billing-balance-view").textContent = money(billing.balance_usd);
document.querySelector("#billing-status").textContent = creating ? "新用户默认额度为 $0" : `${billing.active_requests || 0} 个请求正在执行`;
document.querySelector("#billing-preview").classList.toggle("hidden", creating);
document.querySelector("#ledger-section").classList.toggle("hidden", creating);
document.querySelector("#reset-billing").classList.toggle("hidden", creating || key?.status === "archived");
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", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
syncEditorRoute(); openDrawer();
if (!creating) loadBillingLedger(key);
}
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.className = "credential-copy"; secret.textContent = maskedSecret(key.secret); secret.tabIndex = 0; secret.setAttribute("role", "button"); secret.setAttribute("aria-label", "复制完整 Key"); secret.title = "点击复制完整 Key";
secret.addEventListener("click", () => copyText(key.secret, secret)); secret.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); copyText(key.secret, secret); } }); credential.append(secret);
const status = document.createElement("span"); status.className = `badge ${key.status}`; status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
const billing = key.billing || {}; const balance = document.createElement("div"); const balanceMain = document.createElement("div"); balanceMain.className = "route-main"; balanceMain.textContent = money(billing.balance_usd); const balanceSub = document.createElement("div"); balanceSub.className = "route-sub"; balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`; balance.append(balanceMain, balanceSub);
const concurrency = document.createElement("span"); concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
const routeNode = document.createElement("span"); routeNode.className = "route-main"; routeNode.textContent = routeLabel(key);
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, balance, concurrency, routeNode, models, actions].forEach(node => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
return row;
}));
const todaySummary = usageDashboard.today || {};
document.querySelector("#metric-requests").textContent = number(todaySummary.requests || 0);
document.querySelector("#metric-tokens").textContent = compactNumber(todaySummary.total_tokens || 0);
document.querySelector("#metric-cost").textContent = "$" + Number(todaySummary.cost_usd || 0).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 byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
(usageDashboard.users || []).forEach(user => {
const name = user.key_alias || "未识别";
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
const value = byUser.get(name); const today = user.today || {};
value.requests = today.requests || 0; value.tokens = today.total_tokens || 0; value.cost = today.cost_usd || 0;
value.last = user.last_used_at ? new Date(user.last_used_at) : null;
});
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 = (usageDashboard.days || []).map(item => ({ date: new Date(item.date + "T00:00:00"), tokens: item.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"];
usageDashboard = demoUsageDashboard();
renderManagedKeys();
keyStatusNode.textContent = `测试数据 · ${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号`;
return;
}
if (!keyInput.value.trim()) return;
keyStatusNode.textContent = "正在同步";
try {
const [upstreams, models, summary] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(SUMMARY_API)]);
upstreamAccounts = upstreams.accounts || [];
modelSuggestions = models.models || [];
usageDashboard = summary;
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();
const route = document.querySelector("#editor-route").value;
const allModels = document.querySelector("#editor-all-models").checked;
const payload = { name, secret, 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), billing: billingPayload() };
try {
if (testMode) {
if (!name) throw new Error("请输入名称");
if (secret && secret.length < 6) throw new Error("Key 至少需要 6 个字符");
if (demoState.keys.some(key => key.name.toLowerCase() === name.toLowerCase())) throw new Error("名称已经存在");
const created = { id: "demo-key-" + Date.now(), ...payload, secret: secret || `demo-${Date.now()}-000000`, status: "active", billing: { ...payload.billing, spent_usd: "0", balance_usd: payload.billing.quota_usd, active_requests: 0, cycle_started_at: new Date().toISOString() } };
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(payload) });
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 不存在");
const previous = demoState.keys[index]; const spent = Number(previous.billing?.spent_usd || 0); const previousQuota = Number(previous.billing?.quota_usd || 0); const nextQuota = Number(payload.billing?.quota_usd || 0);
const billing = { ...previous.billing, ...payload.billing, spent_usd: String(spent), balance_usd: String(nextQuota - spent), active_requests: previous.billing?.active_requests || 0 };
demoState.keys[index] = { ...previous, ...payload, billing };
if (previousQuota !== nextQuota) demoState.ledger.push({ id: Date.now(), key_id: previous.id, kind: "quota_change", amount_usd: String(nextQuota - previousQuota), balance_after_usd: String(nextQuota - spent), occurred_at: new Date().toISOString() });
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),
billing: billingPayload()
});
}
async function loadBillingLedger(key) {
const node = document.querySelector("#billing-ledger");
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "正在读取" }));
try {
const entries = testMode ? demoState.ledger.filter(item => item.key_id === key.id).sort((a, b) => b.id - a.id).slice(0, 50) : (await managedFetch(BILLING_LEDGER_API + "?id=" + encodeURIComponent(key.id) + "&limit=50")).entries || [];
node.replaceChildren(...(entries.length ? entries.map(entry => {
const row = document.createElement("div"); row.className = "ledger-item";
const label = document.createElement("strong"); label.textContent = ({ charge: "请求扣费", quota_change: "调整额度", cycle_reset: "额度重置" }[entry.kind] || entry.kind);
const amount = document.createElement("strong"); const numeric = Number(entry.amount_usd || 0); amount.className = numeric < 0 ? "negative" : "positive"; amount.textContent = (numeric > 0 ? "+" : "") + money(numeric);
const detail = document.createElement("small"); detail.textContent = `${new Date(entry.occurred_at).toLocaleString()} · 余额 ${money(entry.balance_after_usd)}${entry.model ? " · " + entry.model : ""}${entry.request_id ? " · " + entry.request_id : ""}`;
row.append(label, amount, detail); return row;
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无账目" })]));
} catch (error) { node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "读取失败:" + error.message })); }
}
async function resetManagedBilling() {
if (!editingKey || !confirm(`立即重置 ${editingKey.name} 的额度?当前剩余余额不会结转。`)) return;
try {
if (testMode) {
const key = demoState.keys.find(item => item.id === editingKey.id); const quota = Number(key.billing.quota_usd || 0); const now = new Date();
key.billing.spent_usd = "0"; key.billing.balance_usd = String(quota); key.billing.cycle_started_at = now.toISOString();
if (key.billing.reset_period !== "none") { const next = new Date(now); if (key.billing.reset_period === "daily") next.setDate(next.getDate() + 1); else if (key.billing.reset_period === "weekly") next.setDate(next.getDate() + 7); else next.setMonth(next.getMonth() + 1); key.billing.next_reset_at = next.toISOString(); }
demoState.ledger.push({ id: Date.now(), key_id: key.id, kind: "cycle_reset", amount_usd: String(quota), balance_after_usd: String(quota), occurred_at: now.toISOString() }); saveDemoState(); editingKey = key;
} else {
await managedFetch(BILLING_RESET_API, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
}
await loadKeyManagement(); const refreshed = managedKeys.find(item => item.id === editingKey.id); if (refreshed) openKeyEditor(refreshed);
} catch (error) { document.querySelector("#billing-status").textContent = "重置失败:" + error.message; }
}
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") { loadUsageFilterOptions(); load(true, 1); }
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; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = [];
usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
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") { loadUsageFilterOptions(); load(true, 1); }
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, page = currentPage, cursor = "") {
if (loading) return;
if (testMode) {
const payload = demoUsagePage(page);
currentPagination = payload.pagination;
render(payload.records);
statusNode.textContent = "";
return;
}
const key = keyInput.value.trim();
if (!key) {
statusNode.textContent = "请输入管理密钥";
return;
}
loading = true;
if (manual) statusNode.textContent = "正在读取";
try {
const params = usageFilterQuery();
params.set("page", String(page || 1));
params.set("page_size", String(PAGE_SIZE));
if (cursor && !cursor.startsWith("demo-")) params.set("cursor", cursor);
const response = await fetch(API + "?" + params.toString(), { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
currentPagination = payload.pagination || { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0 };
const records = payload.records || [];
lastSignature = JSON.stringify(payload);
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, currentPage));
document.querySelector("#apply-usage-filters").addEventListener("click", () => { currentPage = 1; load(true, 1); });
document.querySelector("#clear-usage-filters").addEventListener("click", () => { ["usage-from", "usage-to", "usage-model", "usage-request-id"].forEach(id => document.querySelector("#" + id).value = ""); ["usage-key", "usage-result", "usage-auth", "usage-endpoint"].forEach(id => document.querySelector("#" + id).value = ""); currentPage = 1; load(true, 1); });
document.querySelector("#usage-request-id").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); currentPage = 1; load(true, 1); } });
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.querySelector("#editor-reset-period").addEventListener("change", syncEditorRoute);
document.querySelector("#reset-billing").addEventListener("click", resetManagedBilling);
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 && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
renderColumnControls();
setMode(sessionStorage.getItem("cpa-ext:data-mode") || "live");
setView(sessionStorage.getItem("cpa-ext:active-view") || "keys");
setInterval(() => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
</script>
</body>
</html>