feat(webdemo): add database-backed usage dashboard
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user