feat(admin): separate usage stats and user management

This commit is contained in:
2026-08-19 18:48:03 +08:00
parent faabe563e1
commit a35854e018
14 changed files with 213 additions and 106 deletions
+11 -10
View File
@@ -65,16 +65,17 @@ type UsageSummary struct {
}
type BillingState struct {
KeyID string `json:"key_id"`
QuotaMicros int64 `json:"-"`
SpentMicros int64 `json:"-"`
BalanceMicros int64 `json:"-"`
ResetPeriod string `json:"reset_period"`
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
MaxConcurrency int `json:"max_concurrency"`
ActiveRequests int `json:"active_requests"`
CycleSequence int64 `json:"cycle_sequence"`
CycleStartedAt time.Time `json:"cycle_started_at"`
KeyID string `json:"key_id"`
QuotaMicros int64 `json:"-"`
SpentMicros int64 `json:"-"`
LifetimeSpentMicros int64 `json:"-"`
BalanceMicros int64 `json:"-"`
ResetPeriod string `json:"reset_period"`
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
MaxConcurrency int `json:"max_concurrency"`
ActiveRequests int `json:"active_requests"`
CycleSequence int64 `json:"cycle_sequence"`
CycleStartedAt time.Time `json:"cycle_started_at"`
}
type BillingSettings struct {
+11 -9
View File
@@ -47,14 +47,15 @@ type billingSettingsRequest struct {
}
type billingStateDTO struct {
QuotaUSD string `json:"quota_usd"`
SpentUSD string `json:"spent_usd"`
BalanceUSD string `json:"balance_usd"`
ResetPeriod string `json:"reset_period"`
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
MaxConcurrency int `json:"max_concurrency"`
ActiveRequests int `json:"active_requests"`
CycleStartedAt time.Time `json:"cycle_started_at"`
QuotaUSD string `json:"quota_usd"`
SpentUSD string `json:"spent_usd"`
LifetimeSpentUSD string `json:"lifetime_spent_usd"`
BalanceUSD string `json:"balance_usd"`
ResetPeriod string `json:"reset_period"`
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
MaxConcurrency int `json:"max_concurrency"`
ActiveRequests int `json:"active_requests"`
CycleStartedAt time.Time `json:"cycle_started_at"`
}
type managedKeyDTO struct {
@@ -300,7 +301,8 @@ func (a *App) resetManagedKeyBilling(body []byte) ManagementResponse {
}
return jsonManagementResponse(http.StatusOK, billingStateDTO{
QuotaUSD: formatMicros(state.QuotaMicros), SpentUSD: formatMicros(state.SpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
LifetimeSpentUSD: formatMicros(state.LifetimeSpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
NextResetAt: state.NextResetAt, MaxConcurrency: state.MaxConcurrency,
ActiveRequests: state.ActiveRequests, CycleStartedAt: state.CycleStartedAt,
})
+3 -3
View File
@@ -565,7 +565,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, `data-view="logs">日志`) {
t.Fatalf("unexpected UI response: status=%d", pageResponse.StatusCode)
}
for _, feature := range []string{`<body class="read-only">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `id="quota-chart"`, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="surface admin-only"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
for _, feature := range []string{`<body class="read-only">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `data-view="stats">统计`, `data-view="users">用户`, `id="view-users" class="view admin-only"`, `id="quota-chart"`, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="user-management"`, `id="reset-all-billing"`, `id="key-rows" class="user-card-grid"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain HTML feature %q", feature)
}
@@ -594,12 +594,12 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
}
javascript.Write(response.Body)
}
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "pageSize = 100", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", "maskedSecret(key.secret)", `return "自由选择"`, "剩余额度", `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "pageSize = 100", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", `identity.title = "点击复制 Key"`, `return "自由选择"`, "剩余额度", `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
if !strings.Contains(javascript.String(), feature) {
t.Fatalf("UI modules do not contain feature %q", feature)
}
}
for _, removed := range []string{"DEMO_STORE_KEY", "demoState", "mode-button", `data-mode="demo"`, "scrollIntoView"} {
for _, removed := range []string{"DEMO_STORE_KEY", "demoState", "mode-button", `data-mode="demo"`, "scrollIntoView", "maskedSecret("} {
if strings.Contains(javascript.String(), removed) {
t.Fatalf("UI modules still contain removed feature %q", removed)
}
+2 -1
View File
@@ -125,7 +125,8 @@ func maskManagedSecret(secret string) string {
func billingStateResponse(state managedaccess.BillingState) billingStateDTO {
return billingStateDTO{
QuotaUSD: formatMicros(state.QuotaMicros), SpentUSD: formatMicros(state.SpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
LifetimeSpentUSD: formatMicros(state.LifetimeSpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
NextResetAt: state.NextResetAt, MaxConcurrency: state.MaxConcurrency,
ActiveRequests: state.ActiveRequests, CycleStartedAt: state.CycleStartedAt,
}
+2 -2
View File
@@ -263,12 +263,12 @@ func scanBillingState(ctx context.Context, tx *sql.Tx, keyID string) (managedacc
var nextRaw sql.NullString
var startedRaw string
err := tx.QueryRowContext(ctx, `
SELECT a.managed_key_id, a.quota_micros, c.spent_micros, a.reset_period, a.next_reset_at,
SELECT a.managed_key_id, a.quota_micros, c.spent_micros, a.lifetime_spent_micros, a.reset_period, a.next_reset_at,
a.max_concurrency, a.current_cycle_sequence, c.started_at,
(SELECT COUNT(*) FROM billing_admissions d WHERE d.managed_key_id=a.managed_key_id AND d.status='open')
FROM billing_accounts a JOIN billing_cycles c
ON c.managed_key_id=a.managed_key_id AND c.sequence=a.current_cycle_sequence
WHERE a.managed_key_id=?`, keyID).Scan(&state.KeyID, &state.QuotaMicros, &state.SpentMicros,
WHERE a.managed_key_id=?`, keyID).Scan(&state.KeyID, &state.QuotaMicros, &state.SpentMicros, &state.LifetimeSpentMicros,
&state.ResetPeriod, &nextRaw, &state.MaxConcurrency, &state.CycleSequence,
&startedRaw, &state.ActiveRequests)
if err != nil {
+35 -3
View File
@@ -60,7 +60,7 @@ func TestBillingQuotaConcurrencySettlementAndReset(t *testing.T) {
t.Fatal(err)
}
state, _ = store.BillingState(ctx, "key_default", now)
if state.SpentMicros != 600_000 || state.BalanceMicros != 400_000 {
if state.SpentMicros != 600_000 || state.LifetimeSpentMicros != 600_000 || state.BalanceMicros != 400_000 {
t.Fatalf("duplicate settlement changed balance: %+v", state)
}
cost = 500_000
@@ -70,14 +70,14 @@ func TestBillingQuotaConcurrencySettlementAndReset(t *testing.T) {
t.Fatal(err)
}
state, _ = store.BillingState(ctx, "key_default", now)
if state.BalanceMicros != -100_000 {
if state.BalanceMicros != -100_000 || state.LifetimeSpentMicros != 1_100_000 {
t.Fatalf("soft quota did not preserve negative balance: %+v", state)
}
if _, err := store.AuthorizeBilling(ctx, "key_default", "blocked", now); !errors.Is(err, ErrBillingQuotaExhausted) {
t.Fatalf("negative balance authorization err=%v", err)
}
state, err = store.ResetBilling(ctx, "key_default", now.Add(time.Minute))
if err != nil || state.SpentMicros != 0 || state.BalanceMicros != 1_000_000 || state.CycleSequence != 2 {
if err != nil || state.SpentMicros != 0 || state.LifetimeSpentMicros != 1_100_000 || state.BalanceMicros != 1_000_000 || state.CycleSequence != 2 {
t.Fatalf("manual reset state=%+v err=%v", state, err)
}
entries, err := store.ListBillingLedger(ctx, "key_default", 0, 50)
@@ -86,6 +86,38 @@ func TestBillingQuotaConcurrencySettlementAndReset(t *testing.T) {
}
}
func TestBillingLifetimeSpendBackfillsOnceFromCycles(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "billing.db")
store, err := OpenSQLiteUsage(path)
if err != nil {
t.Fatal(err)
}
if _, err := store.BootstrapManagedKey(ctx, "default", "000000"); err != nil {
t.Fatal(err)
}
cost := int64(375_000)
if err := store.Insert(ctx, collection.Record{ManagedKeyID: "key_default", ExecutionID: "historical", RequestedAt: time.Now().UTC(), CostMicros: &cost}); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`UPDATE billing_accounts SET lifetime_spent_micros=0; DELETE FROM cpa_ext_migrations WHERE name='billing_lifetime_spent_v1'`); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = OpenSQLiteUsage(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
state, err := store.BillingState(ctx, "key_default", time.Now().UTC())
if err != nil || state.LifetimeSpentMicros != cost {
t.Fatalf("backfilled state=%+v err=%v", state, err)
}
}
func TestBillingAutomaticMonthlyResetPreservesAnchorDay(t *testing.T) {
location := shanghaiLocation()
anchor := time.Date(2026, time.January, 31, 10, 0, 0, 0, location)
+26
View File
@@ -180,6 +180,7 @@ CREATE TABLE IF NOT EXISTS model_prices (
CREATE TABLE IF NOT EXISTS billing_accounts (
managed_key_id TEXT PRIMARY KEY,
quota_micros INTEGER NOT NULL DEFAULT 0 CHECK(quota_micros >= 0),
lifetime_spent_micros INTEGER NOT NULL DEFAULT 0 CHECK(lifetime_spent_micros >= 0),
reset_period TEXT NOT NULL DEFAULT 'none' CHECK(reset_period IN ('none', 'daily', 'weekly', 'monthly')),
next_reset_at TEXT,
reset_anchor_day INTEGER NOT NULL DEFAULT 0,
@@ -279,6 +280,7 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
`ALTER TABLE model_prices ADD COLUMN source_catalog_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE model_prices ADD COLUMN source_revision TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE model_prices ADD COLUMN source_fetched_at TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE billing_accounts ADD COLUMN lifetime_spent_micros INTEGER NOT NULL DEFAULT 0`,
} {
if _, err := db.Exec(migration); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
_ = db.Close()
@@ -302,6 +304,25 @@ SELECT k.id, 1, k.created_at, 0, 0 FROM managed_keys k`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("迁移 Key 额度周期: %w", err)
}
var lifetimeSpendMigrated int
if err := db.QueryRow(`SELECT COUNT(*) FROM cpa_ext_migrations WHERE name='billing_lifetime_spent_v1'`).Scan(&lifetimeSpendMigrated); err != nil {
_ = db.Close()
return nil, fmt.Errorf("检查累计消费迁移: %w", err)
}
if lifetimeSpendMigrated == 0 {
if _, err := db.Exec(`
UPDATE billing_accounts
SET lifetime_spent_micros=COALESCE((
SELECT SUM(spent_micros) FROM billing_cycles WHERE managed_key_id=billing_accounts.managed_key_id
), 0)`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("回填累计消费: %w", err)
}
if _, err := db.Exec(`INSERT INTO cpa_ext_migrations(name, completed_at) VALUES('billing_lifetime_spent_v1', ?)`, now); err != nil {
_ = db.Close()
return nil, fmt.Errorf("记录累计消费迁移: %w", err)
}
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_records_managed_key ON usage_records(managed_key_id, requested_at DESC)`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("创建 Key 用量索引: %w", err)
@@ -467,6 +488,11 @@ UPDATE billing_cycles SET spent_micros=spent_micros+? WHERE managed_key_id=? AND
cost, record.ManagedKeyID, sequence); err != nil {
return fmt.Errorf("更新额度消费: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE billing_accounts SET lifetime_spent_micros=lifetime_spent_micros+?, updated_at=? WHERE managed_key_id=?`,
cost, formatTime(time.Now().UTC()), record.ManagedKeyID); err != nil {
return fmt.Errorf("更新累计消费: %w", err)
}
}
return nil
}
+60 -54
View File
@@ -3,6 +3,7 @@ import { localDateTimeValue, money, number, option, result } from "../core/share
const keyRowsNode = document.querySelector("#key-rows");
const keyStatusNode = document.querySelector("#key-status");
const resetAllBillingNode = document.querySelector("#reset-all-billing");
const keyStatsNode = document.querySelector("#key-stats");
const drawerNode = document.querySelector("#key-drawer");
const drawerOverlayNode = document.querySelector("#drawer-overlay");
@@ -20,6 +21,7 @@ let editingKey = null;
export function initializeKeys() {
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
resetAllBillingNode.addEventListener("click", resetAllManagedBilling);
document.querySelector("#close-drawer").addEventListener("click", closeKeyDrawer);
document.querySelector("#cancel-editor").addEventListener("click", closeKeyDrawer);
drawerOverlayNode.addEventListener("click", closeKeyDrawer);
@@ -52,6 +54,7 @@ export async function loadKeys() {
const keys = await dataFetch(routes.keys);
managedKeys = keys.keys || [];
renderManagedKeys();
resetAllBillingNode.disabled = !managedKeys.length;
keyStatusNode.textContent = isManagementAuthorized() ? `${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}` : "";
} catch (error) {
keyStatusNode.textContent = "读取失败: " + error.message;
@@ -115,58 +118,40 @@ function openKeyEditor(key = null) {
function renderManagedKeys() {
keyRowsNode.replaceChildren(...managedKeys.map(key => {
const row = document.createElement("tr");
const card = document.createElement("article");
card.className = "user-card";
const archived = key.status === "archived";
const identity = document.createElement("div");
identity.className = "key-identity";
const identity = document.createElement("button");
identity.type = "button";
identity.className = "key-identity key-copy";
identity.disabled = !key.secret;
identity.title = "点击复制 Key";
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);
identityText.append(name);
identity.append(avatar, identityText);
const credential = document.createElement("div");
credential.className = "credential";
const secret = document.createElement("code");
secret.className = "credential-copy";
secret.textContent = isManagementAuthorized() ? maskedSecret(key.secret) : (key.masked_secret || "******");
if (isManagementAuthorized() && 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" ? "禁用" : "已归档";
identity.addEventListener("click", () => copyText(key.secret, name));
const header = document.createElement("header");
header.className = "user-card-header";
const billing = key.billing || {};
const balance = document.createElement("div");
const balanceMain = document.createElement("div");
balanceMain.className = "route-main";
balanceMain.textContent = money(billing.balance_usd);
const balanceSub = document.createElement("div");
balanceSub.className = "route-sub";
balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`;
balance.append(balanceMain, balanceSub);
const concurrency = document.createElement("span");
concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
const routeNode = document.createElement("span");
routeNode.className = "route-main";
routeNode.textContent = routeLabel(key);
const models = document.createElement("span");
const resetPeriod = billing.reset_period && billing.reset_period !== "none" ? ({ daily: "每天重置", weekly: "每周重置", monthly: "每月重置" }[billing.reset_period] || billing.reset_period) : "不重置";
const details = document.createElement("div");
details.className = "user-card-details";
details.append(
userCardDetail("剩余额度", money(billing.balance_usd), resetPeriod),
userCardDetail("总额度", money(billing.quota_usd), `累计消耗 ${money(billing.lifetime_spent_usd)}`),
userCardDetail("并发", `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`, "执行中 / 上限"),
userCardDetail("路由", routeLabel(key), "")
);
const modelBlock = document.createElement("div");
modelBlock.className = "user-card-line";
const models = document.createElement("strong");
models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
modelBlock.append(Object.assign(document.createElement("span"), { textContent: "允许模型" }), models);
const actions = document.createElement("div");
actions.className = "key-actions";
const stats = document.createElement("button");
@@ -183,17 +168,26 @@ function renderManagedKeys() {
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;
header.append(identity, actions);
card.append(header, details, modelBlock);
return card;
}));
renderUserCharts();
}
function userCardDetail(label, value, hint) {
const item = document.createElement("div");
item.className = "user-card-detail";
const content = document.createElement("div");
content.append(Object.assign(document.createElement("strong"), { textContent: value }));
if (hint) content.append(Object.assign(document.createElement("small"), { textContent: hint }));
item.append(
Object.assign(document.createElement("span"), { textContent: label }),
content
);
return item;
}
function renderUserCharts() {
const byUser = new Map(managedKeys.map(key => [key.name, {
name: key.name, requests: 0, tokens: 0, cost: 0,
@@ -353,6 +347,23 @@ async function resetManagedBilling() {
}
}
async function resetAllManagedBilling() {
const targets = managedKeys.filter(key => key.status !== "archived");
if (!targets.length || !confirm(`重置全部 ${targets.length} 个用户的额度?当前剩余余额不会结转。`)) return;
resetAllBillingNode.disabled = true;
keyStatusNode.textContent = `正在重置 ${targets.length} 个用户`;
const results = await Promise.allSettled(targets.map(key => managedFetch(routes.billingReset, {
method: "POST",
body: JSON.stringify({ id: key.id })
})));
const failed = results.filter(result => result.status === "rejected");
await loadKeys();
resetAllBillingNode.disabled = false;
keyStatusNode.textContent = failed.length
? `已重置 ${targets.length - failed.length} 个用户,${failed.length} 个失败`
: `已重置全部 ${targets.length} 个用户的额度`;
}
async function archiveManagedKey(key) {
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
try {
@@ -446,11 +457,6 @@ function routeLabel(key) {
return account ? account.display_name || account.cpa_auth_id || account.id : "未指定";
}
function maskedSecret(value) {
const secret = String(value || "");
return secret.slice(0, 2) + "******" + secret.slice(-4);
}
function copyText(value, button) {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(value).then(() => {
+9 -4
View File
@@ -1,14 +1,17 @@
import { initializeRuntime } from "./core/runtime.js";
import { initializeRuntime, onAccessChange } from "./core/runtime.js";
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, logs: () => {} });
const loaders = Object.freeze({ stats: loadKeys, usage: activateUsage, pricing: loadPricing, logs: () => {}, users: loadKeys });
initializeKeys();
initializeUsage();
initializePricing();
initializeRuntime(reloadCurrentView);
onAccessChange(authorized => {
if (!authorized && document.querySelector(".nav-button.active")?.dataset.view === "users") setView("stats");
});
document.querySelectorAll(".nav-button").forEach(button => {
button.addEventListener("click", () => setView(button.dataset.view));
@@ -16,9 +19,11 @@ document.querySelectorAll(".nav-button").forEach(button => {
document.addEventListener("visibilitychange", refreshVisibleUsage);
setInterval(refreshVisibleUsage, 3000);
setView(sessionStorage.getItem("billing:active-view") || "keys");
setView(sessionStorage.getItem("billing:active-view") || "stats");
function setView(view) {
if (view === "keys" || !loaders[view]) view = "stats";
if (view === "users" && document.body.classList.contains("read-only")) view = "stats";
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
try {
@@ -29,7 +34,7 @@ function setView(view) {
function reloadCurrentView() {
closeKeyDrawer();
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
const active = document.querySelector(".nav-button.active")?.dataset.view || "stats";
if (active === "usage") resetUsage();
else loaders[active]?.();
}
+22 -11
View File
@@ -1,6 +1,4 @@
.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, 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); }
.quota-matrix-wrap { overflow-x: auto; }
@@ -15,19 +13,32 @@
.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; }
.user-management { display: grid; gap: 14px; }
.user-management-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.user-management-header .status { min-height: 16px; margin: 0; }
.user-management-actions { display: flex; gap: 8px; }
.user-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
.user-card { display: grid; min-width: 0; overflow: hidden; border: 1px solid var(--border-soft); border-radius: 10px; background: var(--panel); }
.user-card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px; border-bottom: 1px solid var(--border-soft); }
.user-card-line { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 10px; min-width: 0; padding: 10px 14px; border-bottom: 1px solid var(--border-soft); }
.user-card-line > span { color: var(--muted); font-size: 9px; }
.user-card-line > strong, .user-card-line .credential { min-width: 0; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.user-card-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--border-soft); }
.user-card-detail { display: grid; grid-template-columns: 62px minmax(0, 1fr); align-items: center; min-width: 0; gap: 10px; padding: 11px 14px; border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); }
.user-card-detail:nth-child(2n) { border-right: 0; }
.user-card-detail:nth-last-child(-n+2) { border-bottom: 0; }
.user-card-detail > span, .user-card-detail small { overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.user-card-detail > div { display: flex; min-width: 0; align-items: baseline; justify-content: flex-end; gap: 6px; }
.user-card-detail strong { overflow: hidden; font-size: 12px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.key-identity { display: flex; align-items: center; gap: 9px; }
.key-copy { min-width: 0; border: 0; padding: 0; background: transparent; color: inherit; text-align: left; }
.key-copy:hover { border: 0; background: transparent; }
.key-copy:hover strong { color: var(--primary-active); }
.key-copy:focus-visible { outline: 2px solid var(--primary-active); outline-offset: 4px; }
.key-copy:disabled { cursor: default; opacity: 1; }
.avatar { display: grid; flex: 0 0 auto; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--accent-soft); color: var(--text-secondary); font-size: 11px; font-weight: 700; }
.key-identity strong { display: block; font-size: 12px; }
.key-identity small { display: block; max-width: 160px; overflow: hidden; color: var(--text-quaternary); font-size: 10px; text-overflow: ellipsis; }
.credential { display: flex; align-items: center; }
.credential-copy { cursor: copy; border-radius: 4px; padding: 3px 4px; }
.credential-copy:hover { background: var(--bg-hover); color: var(--text-primary); }
.credential-copy:focus-visible { outline: 2px solid var(--primary-active); outline-offset: 2px; }
code { color: var(--text-secondary); font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 10px; }
.badge::before { width: 5px; height: 5px; border-radius: 50%; background: currentColor; content: ""; }
.badge.active { border-color: #059669; background: #064e3b4d; color: #6ee7b7; }
.badge.disabled, .badge.archived { color: var(--text-tertiary); }
.route-main { font-size: 12px; }
.route-sub { margin-top: 2px; max-width: 220px; overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; }
.key-actions { display: flex; gap: 6px; }
+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)); } .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; } }
@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, .user-card-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; } .user-management-header { align-items: 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; } }
+15 -5
View File
@@ -15,10 +15,11 @@
<div class="app-shell">
<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 active" type="button" data-view="stats">统计</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>
<button class="nav-button admin-only" type="button" data-view="users">用户</button>
</nav>
<div class="topbar-actions">
<div id="demo-perspective" class="demo-perspective hidden" aria-label="演示视角">
@@ -29,14 +30,23 @@
</div>
</header>
<main>
<section id="view-keys" class="view active">
<section id="view-stats" class="view active">
<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 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>
</section>
<section id="view-users" class="view admin-only">
<div id="key-management" class="user-management">
<div class="user-management-header">
<p id="key-status" class="status"></p>
<div class="user-management-actions">
<button id="reset-all-billing" type="button">重置所有</button>
<button id="create-key" class="primary" type="button">创建 Key</button>
</div>
</div>
<div id="key-rows" class="user-card-grid"></div>
</div>
</section>
+14 -1
View File
@@ -98,6 +98,7 @@ func NewFakeInput() Input {
input.models = []string{"deepseek-v4-flash", "deepseek-reasoner", "gpt-5.6-sol"}
input.keys = fakeKeys(now)
input.usage = fakeUsageRecords(now, input.keys, input.upstreams, input.models)
populateFakeLifetimeSpend(input.keys, input.usage)
input.prices = fakePrices()
input.catalog = fakeCatalog()
input.ledger = fakeLedgerRecords(now, input.keys, input.models)
@@ -120,7 +121,19 @@ func fakeBilling(quota, spent, balance, period string, next time.Time, concurren
if !next.IsZero() {
nextValue = next
}
return map[string]any{"quota_usd": quota, "spent_usd": spent, "balance_usd": balance, "reset_period": period, "next_reset_at": nextValue, "max_concurrency": concurrency, "active_requests": active, "cycle_started_at": started}
return map[string]any{"quota_usd": quota, "spent_usd": spent, "lifetime_spent_usd": spent, "balance_usd": balance, "reset_period": period, "next_reset_at": nextValue, "max_concurrency": concurrency, "active_requests": active, "cycle_started_at": started}
}
func populateFakeLifetimeSpend(keys []fakeKey, records []fakeUsage) {
totals := make(map[string]float64, len(keys))
for _, record := range records {
if record.CostAvailable {
totals[record.ManagedKeyID] += record.CostUSD
}
}
for index := range keys {
keys[index].Billing["lifetime_spent_usd"] = fmt.Sprintf("%.6f", totals[keys[index].ID])
}
}
func fakeUsageRecords(now time.Time, keys []fakeKey, upstreams []fakeUpstream, models []string) []fakeUsage {
+1 -1
View File
@@ -178,7 +178,7 @@ func maskFakeSecret(value string) string {
}
func normalizeFakeBilling(value, previous map[string]any) map[string]any {
result := map[string]any{"quota_usd": "0", "spent_usd": "0", "balance_usd": "0", "reset_period": "none", "next_reset_at": nil, "max_concurrency": 4, "active_requests": 0, "cycle_started_at": time.Now()}
result := map[string]any{"quota_usd": "0", "spent_usd": "0", "lifetime_spent_usd": "0", "balance_usd": "0", "reset_period": "none", "next_reset_at": nil, "max_concurrency": 4, "active_requests": 0, "cycle_started_at": time.Now()}
for key, item := range previous {
result[key] = item
}