From a35854e0184b3f9db8563b16b0d509a6ad88a020 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 19 Aug 2026 18:48:03 +0800 Subject: [PATCH] feat(admin): separate usage stats and user management --- internal/access/types.go | 21 ++-- internal/plugin/key_management.go | 20 ++-- internal/plugin/management_test.go | 6 +- internal/plugin/readonly_management.go | 3 +- internal/repository/sqlite_billing.go | 4 +- internal/repository/sqlite_billing_test.go | 38 ++++++- internal/repository/sqlite_usage.go | 26 +++++ internal/web/app/features/keys.js | 114 +++++++++++---------- internal/web/app/main.js | 13 ++- internal/web/styles/keys.css | 33 ++++-- internal/web/styles/responsive.css | 4 +- internal/web/ui.html | 20 +++- internal/webdemo/fake_data.go | 15 ++- internal/webdemo/fake_handler.go | 2 +- 14 files changed, 213 insertions(+), 106 deletions(-) diff --git a/internal/access/types.go b/internal/access/types.go index 2d2f8ec..77f253d 100644 --- a/internal/access/types.go +++ b/internal/access/types.go @@ -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 { diff --git a/internal/plugin/key_management.go b/internal/plugin/key_management.go index d7ee374..02a67b5 100644 --- a/internal/plugin/key_management.go +++ b/internal/plugin/key_management.go @@ -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, }) diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 629def2..5c84f34 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -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{``, `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{``, `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) } diff --git a/internal/plugin/readonly_management.go b/internal/plugin/readonly_management.go index b21a477..2d314b6 100644 --- a/internal/plugin/readonly_management.go +++ b/internal/plugin/readonly_management.go @@ -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, } diff --git a/internal/repository/sqlite_billing.go b/internal/repository/sqlite_billing.go index 0a1d048..2c3ea12 100644 --- a/internal/repository/sqlite_billing.go +++ b/internal/repository/sqlite_billing.go @@ -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 { diff --git a/internal/repository/sqlite_billing_test.go b/internal/repository/sqlite_billing_test.go index a36dce3..b679a81 100644 --- a/internal/repository/sqlite_billing_test.go +++ b/internal/repository/sqlite_billing_test.go @@ -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) diff --git a/internal/repository/sqlite_usage.go b/internal/repository/sqlite_usage.go index 5145e78..562f8ce 100644 --- a/internal/repository/sqlite_usage.go +++ b/internal/repository/sqlite_usage.go @@ -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 } diff --git a/internal/web/app/features/keys.js b/internal/web/app/features/keys.js index 44a749d..0358037 100644 --- a/internal/web/app/features/keys.js +++ b/internal/web/app/features/keys.js @@ -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(() => { diff --git a/internal/web/app/main.js b/internal/web/app/main.js index 8eb6e75..e2cbcea 100644 --- a/internal/web/app/main.js +++ b/internal/web/app/main.js @@ -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]?.(); } diff --git a/internal/web/styles/keys.css b/internal/web/styles/keys.css index cd37c94..a0ace3c 100644 --- a/internal/web/styles/keys.css +++ b/internal/web/styles/keys.css @@ -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; } diff --git a/internal/web/styles/responsive.css b/internal/web/styles/responsive.css index 23841a7..cb950d5 100644 --- a/internal/web/styles/responsive.css +++ b/internal/web/styles/responsive.css @@ -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; } } diff --git a/internal/web/ui.html b/internal/web/ui.html index 89fbf28..e5a70e4 100644 --- a/internal/web/ui.html +++ b/internal/web/ui.html @@ -15,10 +15,11 @@
-
+
-
-
-
用户Key状态余额 / 额度并发路由允许模型查看
+
+ +
+
+
+

+
+ + +
+
+
diff --git a/internal/webdemo/fake_data.go b/internal/webdemo/fake_data.go index 68abe67..4a9a2de 100644 --- a/internal/webdemo/fake_data.go +++ b/internal/webdemo/fake_data.go @@ -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 { diff --git a/internal/webdemo/fake_handler.go b/internal/webdemo/fake_handler.go index 5119d8c..283b712 100644 --- a/internal/webdemo/fake_handler.go +++ b/internal/webdemo/fake_handler.go @@ -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 }