feat(webdemo): add database-backed usage dashboard

This commit is contained in:
2026-08-19 18:24:49 +08:00
parent f0f8abbbb6
commit faabe563e1
20 changed files with 382 additions and 91 deletions
+34
View File
@@ -23,8 +23,11 @@ export const keyInput = document.querySelector("#key");
const readOnlyBase = config.readOnlyBase || location.pathname;
const accessListeners = new Set();
let managementAuthorized = false;
let managementAuthenticated = false;
let demoPerspective = "admin";
export function initializeRuntime(reload) {
document.body.classList.toggle("demo-mode", Boolean(config.demoPerspectives));
keyInput.value = config.managementKey || storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
keyInput.disabled = Boolean(config.lockManagementKey);
document.querySelector(".auth-box").classList.toggle("hidden", Boolean(config.hideManagementKey));
@@ -40,6 +43,7 @@ export function initializeRuntime(reload) {
keyInput.blur();
}
});
initializeDemoPerspective(reload);
setAccessState(false);
}
@@ -54,6 +58,36 @@ export function isManagementAuthorized() {
}
export function setAccessState(authorized) {
managementAuthenticated = authorized;
applyAccessState();
}
function initializeDemoPerspective(reload) {
const switcher = document.querySelector("#demo-perspective");
if (!config.demoPerspectives) return;
switcher.classList.remove("hidden");
switcher.querySelectorAll("[data-perspective]").forEach(button => {
button.addEventListener("click", () => {
if (button.dataset.perspective === demoPerspective) return;
demoPerspective = button.dataset.perspective;
syncDemoPerspective(switcher);
applyAccessState();
reload();
});
});
syncDemoPerspective(switcher);
}
function syncDemoPerspective(switcher) {
switcher.querySelectorAll("[data-perspective]").forEach(button => {
const selected = button.dataset.perspective === demoPerspective;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
}
function applyAccessState() {
const authorized = managementAuthenticated && (!config.demoPerspectives || demoPerspective === "admin");
if (managementAuthorized === authorized) return;
managementAuthorized = authorized;
document.body.classList.toggle("read-only", !authorized);
-4
View File
@@ -3,10 +3,6 @@ export function number(value) {
return Number.isFinite(value) ? value.toLocaleString() : "-";
}
export function compactNumber(value) {
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
}
export function money(value) {
const amount = Number(value || 0);
return "$" + (Number.isFinite(amount) ? amount : 0).toFixed(4);
+53 -29
View File
@@ -1,5 +1,5 @@
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
import { compactNumber, localDateTimeValue, money, number, option, result } from "../core/shared.js";
import { localDateTimeValue, money, number, option, result } from "../core/shared.js";
const keyRowsNode = document.querySelector("#key-rows");
const keyStatusNode = document.querySelector("#key-status");
@@ -191,29 +191,30 @@ function renderManagedKeys() {
});
return row;
}));
const today = usageDashboard.today || {};
document.querySelector("#metric-requests").textContent = number(today.requests || 0);
document.querySelector("#metric-tokens").textContent = compactNumber(today.total_tokens || 0);
document.querySelector("#metric-cost").textContent = "$" + Number(today.cost_usd || 0).toFixed(4);
renderUserCharts();
}
function renderUserCharts() {
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
const byUser = new Map(managedKeys.map(key => [key.name, {
name: key.name, requests: 0, tokens: 0, cost: 0,
balance: key.billing ? Number(key.billing.balance_usd || 0) : null,
days: [], 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 });
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, balance: null, days: [], 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.days = user.days || [];
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 header = document.createElement("div");
header.className = "user-usage-row header";
["用户", "今日请求", "今日 Token", "今日成本", "最近使用"].forEach(label => {
["用户", "今日请求", "今日 Token", "今日成本", "剩余额度", "最近使用"].forEach(label => {
const cell = document.createElement("span");
cell.textContent = label;
header.append(cell);
@@ -221,33 +222,56 @@ function renderUserCharts() {
document.querySelector("#user-usage").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 => {
[item.name, number(item.requests), number(item.tokens), money(item.cost), item.balance === null ? "" : money(item.balance), 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;
}));
const days = [...(usageDashboard.days || [])].reverse().map(item => ({ date: new Date(item.date + "T00:00:00"), cost: Number(item.cost_usd || 0) }));
renderQuotaMatrix(days, [...byUser.values()]);
}
function renderQuotaMatrix(days, users) {
const dates = days.map(day => dateKey(day.date));
const rows = users.map(user => {
const costs = new Map(user.days.map(day => [day.date, Number(day.cost_usd || 0)]));
const values = dates.map(date => costs.get(date) || 0);
return { name: user.name, values, total: values.reduce((sum, value) => sum + value, 0) };
}).sort((left, right) => right.total - left.total || left.name.localeCompare(right.name));
const maximum = Math.max(0.000001, ...rows.flatMap(row => row.values));
const matrix = document.createElement("div");
matrix.className = "quota-matrix";
matrix.style.gridTemplateColumns = `minmax(100px, 1.25fr) repeat(${days.length}, minmax(74px, 1fr)) minmax(82px, 1fr)`;
matrix.append(quotaCell("用户", "header user"), ...days.map(day => quotaCell(`${day.date.getMonth() + 1}/${day.date.getDate()}`, "header")), quotaCell("7 日合计", "header total"));
rows.forEach(row => {
matrix.append(quotaCell(row.name, "user"));
row.values.forEach((value, index) => {
const cell = quotaCell(value ? money(value) : "—", value ? "usage" : "");
cell.style.setProperty("--quota-intensity", `${12 + value / maximum * 58}%`);
cell.title = `${row.name} · ${dates[index]} · ${money(value)}`;
matrix.append(cell);
});
matrix.append(quotaCell(money(row.total), "total"));
});
const weekTotal = days.reduce((sum, day) => sum + day.cost, 0);
matrix.append(quotaCell("每日合计", "header user"), ...days.map(day => quotaCell(money(day.cost), "total")), quotaCell(money(weekTotal), "total"));
document.querySelector("#quota-chart").replaceChildren(matrix);
}
function quotaCell(text, classes = "") {
const cell = document.createElement("div");
cell.className = "quota-cell" + (classes ? " " + classes : "");
cell.textContent = text;
return cell;
}
function dateKey(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
async function createManagedKey() {
+1 -1
View File
@@ -3,7 +3,7 @@ import { closeKeyDrawer, initializeKeys, loadKeys } from "./features/keys.js";
import { initializePricing, loadPricing } from "./features/pricing.js";
import { activateUsage, initializeUsage, refreshVisibleUsage, resetUsage } from "./features/usage.js";
const loaders = Object.freeze({ keys: loadKeys, usage: activateUsage, pricing: loadPricing });
const loaders = Object.freeze({ keys: loadKeys, usage: activateUsage, pricing: loadPricing, logs: () => {} });
initializeKeys();
initializeUsage();
+9 -16
View File
@@ -1,24 +1,17 @@
.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-grid { display: grid; grid-template-columns: minmax(0, 1fr); 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; }
.quota-matrix-wrap { overflow-x: auto; }
.quota-matrix { display: grid; min-width: 720px; border-top: 1px solid var(--border-soft); border-left: 1px solid var(--border-soft); font-size: 9px; }
.quota-cell { display: grid; min-width: 0; min-height: 32px; padding: 7px 6px; place-items: center; overflow: hidden; border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); color: var(--text-secondary); text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.quota-cell.header { min-height: 27px; background: var(--bg-primary); color: var(--muted); }
.quota-cell.user { text-align: center; }
.quota-cell.total { background: var(--bg-tertiary); color: var(--text); font-weight: 600; }
.quota-cell.usage { background: color-mix(in srgb, var(--primary-active) var(--quota-intensity), var(--bg-primary)); color: var(--text); }
.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 { display: grid; grid-template-columns: minmax(120px, 1.25fr) repeat(4, minmax(100px, 1fr)) minmax(150px, 1.4fr); align-items: center; gap: 10px; min-width: 790px; 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; }
+8 -6
View File
@@ -1,13 +1,15 @@
.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; }
.workspace-bar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; min-height: 50px; padding-right: 222px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
body.demo-mode .workspace-bar { padding-right: 22px; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.demo-perspective { display: flex; align-items: center; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary); }
.demo-perspective button { border: 0; border-radius: 5px; padding: 6px 10px; background: transparent; color: var(--muted); font-size: 11px; }
.demo-perspective button:hover { border: 0; background: var(--bg-hover); color: var(--text); }
.demo-perspective button.active { background: var(--bg-tertiary); color: var(--text); box-shadow: inset 0 0 0 1px var(--border-primary); }
.auth-box { display: flex; align-items: center; gap: 8px; }
#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; }
.workspace-nav { display: flex; flex: 1 1 auto; align-items: stretch; gap: 3px; min-width: 0; height: 50px; padding: 0 22px; 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); }
+2 -2
View File
@@ -1,3 +1,3 @@
@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; } }
@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)); } .chart-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .workspace-bar, body.demo-mode .workspace-bar { align-items: stretch; flex-wrap: wrap; padding: 0 12px; } .workspace-nav { order: 2; width: 100%; padding: 0; } .topbar-actions { order: 1; width: 100%; justify-content: flex-end; padding-top: 8px; } .demo-perspective button { padding: 6px 8px; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } 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; } .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; } }
+22 -14
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Billing 管理台</title>
<title>CPA</title>
<link rel="stylesheet" href="./styles/base.css">
<link rel="stylesheet" href="./styles/layout.css">
<link rel="stylesheet" href="./styles/keys.css">
@@ -13,23 +13,27 @@
</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 class="workspace-bar">
<nav class="workspace-nav" aria-label="工作区">
<button class="nav-button active" type="button" data-view="keys">用户</button>
<button class="nav-button" type="button" data-view="usage">请求</button>
<button class="nav-button" type="button" data-view="pricing">模型</button>
<button class="nav-button" type="button" data-view="logs">日志</button>
</nav>
<div class="topbar-actions">
<div id="demo-perspective" class="demo-perspective hidden" aria-label="演示视角">
<button type="button" data-perspective="admin">管理员示教</button>
<button type="button" data-perspective="user">普通用户视角</button>
</div>
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
</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 class="chart-grid">
<article class="chart-card"><div id="user-usage" class="user-usage"></div></article>
<article class="chart-card"><div id="quota-chart" class="quota-matrix-wrap"></div></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>
@@ -75,6 +79,10 @@
</section>
</div>
</section>
<section id="view-logs" class="view">
<div class="surface"><div class="empty">暂无日志</div></div>
</section>
</main>
</div>