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

1158 lines
84 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>Billing 管理台</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; padding-top: 18px; }
/* CPA Manager overlays its own controls on the iframe's top-right corner. */
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 58px; padding: 0 222px 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; }
body.read-only .admin-only { display: none !important; }
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: block; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); cursor: pointer; outline: none; }
.price-row:hover { border-color: var(--border-hover); background: var(--bg-hover); }
.price-row:focus-visible { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
.price-row.selected { border-color: var(--border-primary); background: var(--bg-tertiary); }
.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-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; }
.catalog-toolbar { display: flex; align-items: center; gap: 7px; }
.catalog-toolbar input { flex: 1 1 260px; }
.catalog-summary { min-height: 17px; margin-bottom: 8px; color: var(--muted); font-size: 10px; }
.catalog-results { display: grid; gap: 6px; max-height: 250px; margin-top: 8px; overflow-y: auto; }
.catalog-result { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); }
.catalog-result strong { display: block; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.catalog-result small { display: block; margin-top: 3px; color: var(--muted); font-size: 9px; }
.price-source { min-height: 18px; margin-top: 8px; color: var(--text-tertiary); font-size: 10px; }
.price-source.catalog-linked { color: var(--text-secondary); }
.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; }
.section-heading .tools { position: relative; }
.usage-filter-panel { position: static; margin: 0; }
.usage-filter-panel > summary { display: inline-flex; align-items: center; }
.usage-filter-panel .usage-filters { position: absolute; z-index: 15; top: calc(100% + 8px); right: 0; width: min(960px, calc(100vw - 44px)); margin-bottom: 0; box-shadow: 0 16px 40px #0009; }
.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) { .app-shell { padding-top: 14px; } .topbar { align-items: flex-start; min-height: 0; padding: 11px 12px; } .brand span { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .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 class="read-only">
<div class="app-shell">
<header class="topbar">
<div class="brand"><div class="brand-mark">B</div><div><strong>Billing</strong><span> · 管理控制台</span></div></div>
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
</header>
<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="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 id="key-management" class="surface admin-only">
<div class="surface-toolbar"><span id="key-status" class="status"></span><button id="create-key" class="primary" type="button">创建 Key</button></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 id="usage-filter-panel" class="usage-filter-panel"><summary>查询过滤</summary><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></details>
<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 admin-only" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
<section class="price-editor">
<div id="price-editor-content" class="hidden">
<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 id="price-source" class="price-source">手动配置</div></div>
<div class="price-section admin-only"><div class="price-section-header"><h3>models.dev 参考价格</h3><button id="refresh-catalog" class="small" type="button">更新目录</button></div><div id="catalog-summary" class="catalog-summary"></div><div class="catalog-toolbar"><input id="catalog-query" placeholder="搜索模型或供应商"><button id="search-catalog" class="small" type="button">搜索</button></div><div id="catalog-results" class="catalog-results"></div></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 id="long-price-section" class="price-section hidden"><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 admin-only"><button id="delete-price" class="danger hidden" type="button">删除</button><button id="clear-price" type="button">清空</button><button id="save-price" class="primary" type="button">保存价格</button></div>
</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 MANAGEMENT_API = "/v0/management/plugins/billing";
const API = MANAGEMENT_API + "/usage";
const SUMMARY_API = MANAGEMENT_API + "/usage-summary";
const PRICE_API = MANAGEMENT_API + "/prices";
const PRICE_IMPORT_API = MANAGEMENT_API + "/prices/import";
const CATALOG_API = MANAGEMENT_API + "/price-catalog";
const CATALOG_REFRESH_API = MANAGEMENT_API + "/price-catalog/refresh";
const CATALOG_APPLY_API = MANAGEMENT_API + "/price-catalog/apply";
const KEYS_API = MANAGEMENT_API + "/keys";
const KEY_STATS_API = MANAGEMENT_API + "/key-stats";
const UPSTREAMS_API = MANAGEMENT_API + "/upstreams";
const MODELS_API = MANAGEMENT_API + "/model-suggestions";
const BILLING_RESET_API = MANAGEMENT_API + "/billing-reset";
const BILLING_LEDGER_API = MANAGEMENT_API + "/billing-ledger";
const READONLY_API = location.pathname;
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 = "billing:usage-columns";
const priceStatusNode = document.querySelector("#price-status");
const priceListNode = document.querySelector("#price-list");
const priceEditorContentNode = document.querySelector("#price-editor-content");
const priceSourceNode = document.querySelector("#price-source");
const catalogSummaryNode = document.querySelector("#catalog-summary");
const catalogResultsNode = document.querySelector("#catalog-results");
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;
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 loading = false;
let currentPrices = [];
let selectedPriceModel = "";
let currentCatalog = null;
let managedKeys = [];
let upstreamAccounts = [];
let modelSuggestions = [];
let editingKey = null;
let managementAuthorized = false;
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 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() {
try {
const [keys, upstreams, models] = await Promise.all([dataFetch(KEYS_API + "?include_archived=1"), dataFetch(UPSTREAMS_API), dataFetch(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;
}
function setAccessState(authorized) {
managementAuthorized = authorized;
document.body.classList.toggle("read-only", !authorized);
document.querySelectorAll("#view-pricing .price-editor input, #view-pricing .price-editor select").forEach(node => {
if (node.id !== "catalog-query") node.disabled = !authorized;
});
syncLongSectionVisibility();
if (!authorized) closeDrawer();
}
function readOnlyURL(managementURL) {
const source = new URL(managementURL, location.origin);
const views = {
[API]: "usage", [SUMMARY_API]: "usage-summary", [PRICE_API]: "prices",
[CATALOG_API]: "price-catalog", [KEYS_API]: "keys", [KEY_STATS_API]: "key-stats",
[UPSTREAMS_API]: "upstreams", [MODELS_API]: "model-suggestions"
};
const view = views[source.pathname];
if (!view) throw new Error("该数据不支持匿名读取");
source.searchParams.set("view", view);
return READONLY_API + "?" + source.searchParams.toString();
}
async function responsePayload(response) {
try { return await response.json(); } catch (_) { return {}; }
}
async function dataFetch(url) {
const key = keyInput.value.trim();
if (key) {
const response = await fetch(url, { headers: authHeaders() });
const payload = await responsePayload(response);
if (response.ok) { setAccessState(true); return payload; }
if (response.status !== 401 && response.status !== 403) throw new Error(payload?.error?.message || "HTTP " + response.status);
setAccessState(false);
} else {
setAccessState(false);
}
const response = await fetch(readOnlyURL(url), { headers: { Accept: "application/json" } });
const payload = await responsePayload(response);
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
return payload;
}
async function managedFetch(url, options = {}) {
if (!keyInput.value.trim()) { setAccessState(false); throw new Error("请输入管理密钥"); }
const headers = authHeaders(Boolean(options.body));
const response = await fetch(url, { ...options, headers: { ...headers, ...(options.headers || {}) } });
const payload = await responsePayload(response);
if (!response.ok) {
if (response.status === 401 || response.status === 403) setAccessState(false);
throw new Error(payload?.error?.message || "HTTP " + response.status);
}
setAccessState(true);
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 = managementAuthorized ? maskedSecret(key.secret) : (key.masked_secret || "******");
if (managementAuthorized && 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));
actions.append(stats);
if (managementAuthorized) { const manage = document.createElement("button"); manage.type = "button"; manage.className = "small admin-only"; manage.textContent = archived ? "查看" : "管理"; manage.addEventListener("click", () => openKeyEditor(key)); actions.append(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 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() {
keyStatusNode.textContent = "正在读取";
try {
const [upstreams, models, summary] = await Promise.all([dataFetch(UPSTREAMS_API), dataFetch(MODELS_API), dataFetch(SUMMARY_API)]);
upstreamAccounts = upstreams.accounts || [];
modelSuggestions = models.models || [];
usageDashboard = summary;
const keys = await dataFetch(KEYS_API);
managedKeys = keys.keys || [];
renderManagedKeys();
keyStatusNode.textContent = managementAuthorized ? `共 ${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 {
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 {
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 = (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 {
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 {
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 {
const payload = await dataFetch(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 syncLongSectionVisibility(hasLong = document.querySelector("#long-enabled").checked) {
document.querySelector("#long-price-section").classList.toggle("hidden", !managementAuthorized && !hasLong);
}
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("billing:active-view", view); } catch (_) {}
if (view === "usage") { loadUsageFilterOptions(); load(true, 1); }
if (view === "pricing") loadPrices();
if (view === "keys") loadKeyManagement();
}
function reloadCurrentView() {
currentPage = 1; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
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 showPriceEditor(show) {
priceEditorContentNode.classList.toggle("hidden", !show);
document.querySelector("#delete-price").classList.toggle("hidden", !selectedPriceModel);
}
function clearPriceForm(showEditor = true) {
selectedPriceModel = "";
["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";
showPriceSource({ kind: "manual" });
setLongFields(false);
syncLongSectionVisibility(false);
showPriceEditor(showEditor);
renderPrices();
}
function priceSourceText(source) {
if (source?.kind === "models.dev") return `models.dev · ${source.provider || "-"} / ${source.model || source.catalog_id || "-"}`;
return "手动配置";
}
function showPriceSource(source) {
priceSourceNode.textContent = priceSourceText(source);
priceSourceNode.classList.toggle("catalog-linked", source?.kind === "models.dev");
}
function fillPriceForm(price) {
selectedPriceModel = price.model;
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";
showPriceSource(price.source || { kind: "manual" });
setLongFields(Boolean(long));
syncLongSectionVisibility(Boolean(long));
showPriceEditor(true);
document.querySelectorAll(".price-row").forEach(row => row.classList.toggle("selected", row.dataset.model === selectedPriceModel));
}
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" + (price.model === selectedPriceModel ? " selected" : "");
row.dataset.model = price.model;
row.tabIndex = 0;
row.setAttribute("role", "button");
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 : ""} · ${priceSourceText(price.source)}`;
text.append(model, detail);
row.addEventListener("click", () => fillPriceForm(price));
row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); fillPriceForm(price); } });
row.append(text); return row;
}));
}
async function loadPrices() {
try {
const payload = await dataFetch(PRICE_API);
currentPrices = payload.prices || [];
renderPrices();
const selected = currentPrices.find(price => price.model === selectedPriceModel);
if (selected) fillPriceForm(selected);
else if (selectedPriceModel) clearPriceForm(false);
else showPriceEditor(false);
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
if (managementAuthorized) await loadCatalogStatus();
else { currentCatalog = null; catalogSummaryNode.textContent = ""; renderCatalogResults([]); }
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
}
async function savePrice() {
try {
const payload = pricePayload();
selectedPriceModel = payload.model;
await managedFetch(PRICE_API, { method: "PUT", body: JSON.stringify(payload) });
priceStatusNode.textContent = "价格已保存";
await loadPrices();
showPriceSource({ kind: "manual" });
} catch (error) { priceStatusNode.textContent = "保存失败: " + error.message; }
}
function renderCatalogResults(models) {
if (!models.length) {
catalogResultsNode.replaceChildren();
return;
}
catalogResultsNode.replaceChildren(...models.map(entry => {
const row = document.createElement("div"); row.className = "catalog-result";
const text = document.createElement("div");
const title = document.createElement("strong"); title.textContent = `${entry.provider_name || entry.provider} · ${entry.model_name || entry.model}`;
const detail = document.createElement("small"); detail.textContent = `${entry.id} · 输入 ${entry.base.input_per_1m} / 输出 ${entry.base.output_per_1m}${entry.long_context ? " · 长上下文" : ""}`;
text.append(title, detail); row.append(text);
if (managementAuthorized) { const use = document.createElement("button"); use.type = "button"; use.className = "small admin-only"; use.textContent = "使用"; use.addEventListener("click", () => importCatalogPrice(entry)); row.append(use); }
return row;
}));
}
function catalogSummary(payload) {
currentCatalog = payload?.catalog || null;
if (!payload?.loaded) return payload?.last_error ? `尚无可用目录 · ${payload.last_error}` : "尚未下载目录";
const updated = currentCatalog?.fetched_at ? new Date(currentCatalog.fetched_at).toLocaleString() : "-";
return `${currentCatalog?.models || 0} 条参考价格 · ${updated}`;
}
async function searchCatalog() {
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
try {
const payload = await dataFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
catalogSummaryNode.textContent = catalogSummary(payload);
renderCatalogResults(payload.models || []);
} catch (error) { catalogSummaryNode.textContent = "读取参考价格失败:" + error.message; renderCatalogResults([]); }
}
async function loadCatalogStatus() {
await searchCatalog();
}
async function importCatalogPrice(entry) {
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
if (!localModel) { priceStatusNode.textContent = "请先输入本地模型名称"; return; }
try {
const imported = await managedFetch(PRICE_IMPORT_API, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
await loadPrices(); fillPriceForm(imported); priceStatusNode.textContent = `${localModel} 已采用 ${entry.provider_name || entry.provider} / ${entry.model} 参考价`;
} catch (error) { priceStatusNode.textContent = "导入参考价格失败:" + error.message; }
}
async function refreshCatalog() {
try {
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
const preview = await managedFetch(CATALOG_REFRESH_API, { method: "POST" });
currentCatalog = preview.catalog;
const changed = (preview.changes || []).filter(change => change.status === "changed");
const missing = (preview.changes || []).filter(change => change.status === "missing");
if (!changed.length) {
catalogSummaryNode.textContent = `目录已更新 · 没有价格变化${missing.length ? ` · ${missing.length} 个来源已下架` : ""}`;
await searchCatalog(); return;
}
const lines = changed.slice(0, 8).map(change => `${change.model}: 输入 ${change.before.base.input_per_1m}${change.after.base.input_per_1m},输出 ${change.before.base.output_per_1m}${change.after.base.output_per_1m}`);
const confirmed = confirm(`发现 ${changed.length} 个已关联价格发生变化:\n\n${lines.join("\n")}${changed.length > lines.length ? "\n…" : ""}\n\n确认更新这些本地价格?`);
if (!confirmed) { catalogSummaryNode.textContent = `目录已更新 · ${changed.length} 个变化尚未应用`; return; }
const applied = await managedFetch(CATALOG_APPLY_API, { method: "POST", body: JSON.stringify({ revision: preview.catalog.revision, models: changed.map(change => change.model) }) });
catalogSummaryNode.textContent = `已确认更新 ${applied.updated} 个本地价格`;
await loadPrices();
} catch (error) { catalogSummaryNode.textContent = "更新目录失败:" + error.message; }
}
async function deletePrice(model) {
try {
if (!confirm(`删除 ${model} 的真实价格配置?`)) return;
await managedFetch(PRICE_API, { method: "DELETE", body: JSON.stringify({ model }) });
if (selectedPriceModel === model) clearPriceForm(false);
await loadPrices();
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
}
async function load(manual = false, page = currentPage, cursor = "") {
if (loading) 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) params.set("cursor", cursor);
const payload = await dataFetch(API + "?" + params.toString());
currentPagination = payload.pagination || { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0 };
const records = payload.records || [];
render(records);
statusNode.textContent = "";
} catch (error) {
statusNode.textContent = "读取失败: " + error.message;
} finally {
loading = false;
}
}
keyInput.value = storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
keyInput.addEventListener("change", () => { sessionStorage.setItem("billing:management-key", keyInput.value.trim()); setAccessState(false); reloadCurrentView(); });
keyInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); keyInput.blur(); } });
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("#delete-price").addEventListener("click", () => selectedPriceModel && deletePrice(selectedPriceModel));
document.querySelector("#search-catalog").addEventListener("click", searchCatalog);
document.querySelector("#catalog-query").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchCatalog(); } });
document.querySelector("#refresh-catalog").addEventListener("click", refreshCatalog);
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
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.addEventListener("keydown", event => { if (event.key === "Escape") closeDrawer(); });
document.addEventListener("visibilitychange", () => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
renderColumnControls();
setAccessState(false);
setView(sessionStorage.getItem("billing:active-view") || "keys");
setInterval(() => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
</script>
</body>
</html>