feat: 完善匿名只读管理台
This commit is contained in:
+154
-316
@@ -31,14 +31,7 @@
|
||||
.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; }
|
||||
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; }
|
||||
@@ -102,11 +95,12 @@
|
||||
.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 { 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-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; }
|
||||
@@ -197,22 +191,25 @@
|
||||
.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, .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; } }
|
||||
@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>
|
||||
<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"><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>
|
||||
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></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>
|
||||
@@ -220,51 +217,54 @@
|
||||
</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 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><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="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" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
|
||||
<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"><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 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 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">
|
||||
<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"><button id="clear-price" type="button">清空</button><button id="save-price" class="primary" type="button">保存价格</button></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>
|
||||
@@ -313,6 +313,7 @@
|
||||
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");
|
||||
@@ -321,6 +322,7 @@
|
||||
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");
|
||||
@@ -336,92 +338,19 @@
|
||||
const statsRecentNode = document.querySelector("#stats-recent");
|
||||
const pageButtonsNode = document.querySelector("#page-buttons");
|
||||
const PAGE_SIZE = 100;
|
||||
const DEMO_STORE_KEY = "billing:demo-state:v4";
|
||||
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 selectedPriceModel = "";
|
||||
let currentCatalog = null;
|
||||
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",
|
||||
source: { kind: "manual" }
|
||||
};
|
||||
}
|
||||
|
||||
function demoCatalog() {
|
||||
return [
|
||||
{ id: "openai/gpt-5.6-sol", provider: "openai", provider_name: "OpenAI", model: "gpt-5.6-sol", model_name: "GPT-5.6 Sol", base: { input_per_1m: "5", cache_read_per_1m: "0.5", cache_write_per_1m: "6.25", output_per_1m: "30" }, long_context: { threshold_input_tokens: 272000, comparison: "gt", input_per_1m: "10", cache_read_per_1m: "1", cache_write_per_1m: "12.5", output_per_1m: "45" } },
|
||||
{ id: "deepseek/deepseek-v4-flash", provider: "deepseek", provider_name: "DeepSeek", model: "deepseek-v4-flash", model_name: "DeepSeek V4 Flash", base: { input_per_1m: "0.14", cache_read_per_1m: "0.014", cache_write_per_1m: "0.14", output_per_1m: "0.28" } },
|
||||
{ id: "google/gemini-3.7-flash", provider: "google", provider_name: "Google", model: "gemini-3.7-flash", model_name: "Gemini 3.7 Flash", base: { input_per_1m: "0.38", cache_read_per_1m: "0.038", cache_write_per_1m: "0.38", output_per_1m: "1.88" } }
|
||||
];
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
let managementAuthorized = false;
|
||||
|
||||
function storedPanelKey() {
|
||||
const prefix = "enc::v1::";
|
||||
@@ -627,44 +556,6 @@
|
||||
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");
|
||||
@@ -675,13 +566,8 @@
|
||||
}
|
||||
|
||||
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)]);
|
||||
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; }
|
||||
}
|
||||
@@ -692,12 +578,60 @@
|
||||
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 (testMode) throw new Error("测试模式禁止访问真实管理接口");
|
||||
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 response.json();
|
||||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -803,8 +737,9 @@
|
||||
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 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}`;
|
||||
@@ -812,7 +747,8 @@
|
||||
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);
|
||||
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;
|
||||
}));
|
||||
@@ -823,10 +759,6 @@
|
||||
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);
|
||||
}
|
||||
@@ -851,27 +783,16 @@
|
||||
}
|
||||
|
||||
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 = "正在同步";
|
||||
keyStatusNode.textContent = "正在读取";
|
||||
try {
|
||||
const [upstreams, models, summary] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(SUMMARY_API)]);
|
||||
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 archived = document.querySelector("#include-archived").checked ? "?include_archived=1" : "";
|
||||
const keys = await managedFetch(KEYS_API + archived);
|
||||
const keys = await dataFetch(KEYS_API);
|
||||
managedKeys = keys.keys || [];
|
||||
renderManagedKeys();
|
||||
keyStatusNode.textContent = `共 ${managedKeys.length} 个 Key,${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}`;
|
||||
keyStatusNode.textContent = managementAuthorized ? `共 ${managedKeys.length} 个 Key,${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}` : "";
|
||||
} catch (error) { keyStatusNode.textContent = "读取失败: " + error.message; }
|
||||
}
|
||||
|
||||
@@ -882,15 +803,6 @@
|
||||
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();
|
||||
@@ -899,15 +811,6 @@
|
||||
|
||||
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; }
|
||||
@@ -933,7 +836,7 @@
|
||||
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 || [];
|
||||
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);
|
||||
@@ -947,14 +850,7 @@
|
||||
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 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; }
|
||||
}
|
||||
@@ -962,9 +858,6 @@
|
||||
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; }
|
||||
@@ -972,15 +865,7 @@
|
||||
|
||||
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 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 = "历史统计永久保留";
|
||||
@@ -999,6 +884,10 @@
|
||||
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));
|
||||
@@ -1008,25 +897,21 @@
|
||||
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 = []; currentCatalog = null;
|
||||
usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
|
||||
render([]); renderManagedKeys(); renderPrices(); renderCatalogResults([]); catalogSummaryNode.textContent = "";
|
||||
try { sessionStorage.setItem("billing:data-mode", mode); } catch (_) {}
|
||||
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 clearPriceForm() {
|
||||
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";
|
||||
@@ -1034,6 +919,9 @@
|
||||
document.querySelector("#fast-multiplier").value = "2.5";
|
||||
showPriceSource({ kind: "manual" });
|
||||
setLongFields(false);
|
||||
syncLongSectionVisibility(false);
|
||||
showPriceEditor(showEditor);
|
||||
renderPrices();
|
||||
}
|
||||
|
||||
function priceSourceText(source) {
|
||||
@@ -1047,6 +935,7 @@
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1064,6 +953,9 @@
|
||||
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() {
|
||||
@@ -1090,7 +982,10 @@
|
||||
}
|
||||
priceListNode.replaceChildren(...currentPrices.map(price => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "price-row";
|
||||
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";
|
||||
@@ -1098,44 +993,32 @@
|
||||
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);
|
||||
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;
|
||||
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() {
|
||||
if (testMode) {
|
||||
currentPrices = demoState.prices.map(price => structuredClone(price)); renderPrices();
|
||||
priceStatusNode.textContent = `测试数据 · 已配置 ${currentPrices.length} 个模型`; await loadCatalogStatus(); 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);
|
||||
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} 个模型` : "尚未配置模型价格";
|
||||
await loadCatalogStatus();
|
||||
if (managementAuthorized) await loadCatalogStatus();
|
||||
else { currentCatalog = null; catalogSummaryNode.textContent = ""; renderCatalogResults([]); }
|
||||
} 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("请输入模型");
|
||||
payload.source = { kind: "manual" };
|
||||
if (index >= 0) demoState.prices[index] = payload; else demoState.prices.push(payload);
|
||||
saveDemoState(); priceStatusNode.textContent = "测试价格已保存"; await loadPrices(); showPriceSource({ kind: "manual" }); 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);
|
||||
const payload = pricePayload();
|
||||
selectedPriceModel = payload.model;
|
||||
await managedFetch(PRICE_API, { method: "PUT", body: JSON.stringify(payload) });
|
||||
priceStatusNode.textContent = "价格已保存";
|
||||
await loadPrices();
|
||||
showPriceSource({ kind: "manual" });
|
||||
@@ -1152,14 +1035,14 @@
|
||||
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 ? " · 长上下文" : ""}`;
|
||||
const use = document.createElement("button"); use.type = "button"; use.className = "small"; use.textContent = "使用"; use.addEventListener("click", () => importCatalogPrice(entry));
|
||||
text.append(title, detail); row.append(text, use); return row;
|
||||
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 (testMode) return "测试目录 · 不访问 models.dev";
|
||||
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}`;
|
||||
@@ -1168,14 +1051,7 @@
|
||||
async function searchCatalog() {
|
||||
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
|
||||
try {
|
||||
if (testMode) {
|
||||
const models = query ? demoCatalog().filter(entry => [entry.id, entry.provider, entry.provider_name, entry.model, entry.model_name].some(value => value.toLowerCase().includes(query))) : [];
|
||||
currentCatalog = { revision: "demo-v1", models: demoCatalog().length, fetched_at: new Date().toISOString() };
|
||||
catalogSummaryNode.textContent = "测试目录 · 不访问 models.dev";
|
||||
renderCatalogResults(models); return;
|
||||
}
|
||||
if (!keyInput.value.trim()) { catalogSummaryNode.textContent = "输入管理密钥后读取目录"; return; }
|
||||
const payload = await managedFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
|
||||
const payload = await dataFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
|
||||
catalogSummaryNode.textContent = catalogSummary(payload);
|
||||
renderCatalogResults(payload.models || []);
|
||||
} catch (error) { catalogSummaryNode.textContent = "读取参考价格失败:" + error.message; renderCatalogResults([]); }
|
||||
@@ -1189,28 +1065,13 @@
|
||||
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
|
||||
if (!localModel) { priceStatusNode.textContent = "请先输入本地模型名称"; return; }
|
||||
try {
|
||||
let imported;
|
||||
if (testMode) {
|
||||
const existing = demoState.prices.find(price => price.model.toLowerCase() === localModel.toLowerCase());
|
||||
imported = {
|
||||
model: localModel, base: structuredClone(entry.base), long_context: entry.long_context ? structuredClone(entry.long_context) : undefined,
|
||||
fast_pricing_enabled: existing?.fast_pricing_enabled || false, fast_multiplier: existing?.fast_multiplier || "2.5",
|
||||
source: { kind: "models.dev", catalog_id: entry.id, provider: entry.provider, model: entry.model, revision: "demo-v1", fetched_at: new Date().toISOString() }
|
||||
};
|
||||
const index = demoState.prices.findIndex(price => price.model.toLowerCase() === localModel.toLowerCase());
|
||||
if (index >= 0) demoState.prices[index] = imported; else demoState.prices.push(imported);
|
||||
saveDemoState();
|
||||
} else {
|
||||
imported = await managedFetch(PRICE_IMPORT_API, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
|
||||
}
|
||||
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 {
|
||||
if (testMode) { catalogSummaryNode.textContent = "测试目录已刷新 · 未访问 models.dev"; return; }
|
||||
if (!keyInput.value.trim()) throw new Error("请输入管理密钥");
|
||||
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
|
||||
const preview = await managedFetch(CATALOG_REFRESH_API, { method: "POST" });
|
||||
currentCatalog = preview.catalog;
|
||||
@@ -1231,46 +1092,25 @@
|
||||
|
||||
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 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;
|
||||
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);
|
||||
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 || [];
|
||||
lastSignature = JSON.stringify(payload);
|
||||
render(records);
|
||||
statusNode.textContent = "";
|
||||
} catch (error) {
|
||||
@@ -1281,7 +1121,8 @@
|
||||
}
|
||||
|
||||
keyInput.value = storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
|
||||
keyInput.addEventListener("change", () => sessionStorage.setItem("billing:management-key", keyInput.value.trim()));
|
||||
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); });
|
||||
@@ -1290,12 +1131,11 @@
|
||||
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("#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);
|
||||
@@ -1306,14 +1146,12 @@
|
||||
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); });
|
||||
document.addEventListener("visibilitychange", () => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
|
||||
renderColumnControls();
|
||||
setMode(sessionStorage.getItem("billing:data-mode") || "live");
|
||||
setAccessState(false);
|
||||
setView(sessionStorage.getItem("billing:active-view") || "keys");
|
||||
setInterval(() => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
|
||||
setInterval(() => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user