feat(admin): separate usage stats and user management
This commit is contained in:
+11
-10
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]?.();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user