diff --git a/backend/internal/app/account/service.go b/backend/internal/app/account/service.go index b495b54..7dda593 100644 --- a/backend/internal/app/account/service.go +++ b/backend/internal/app/account/service.go @@ -780,8 +780,12 @@ func (s *Service) GetSingleAccountUsage(ctx context.Context, id int) (map[string key := strconv.Itoa(item.ID) accountUsage := map[string]any{} + var cachedInfo AccountUsageInfo + var hasCachedInfo bool if cached, _, ok := s.getUsageCacheForRead(ctx, item.Platform); ok { - if cachedInfo, exists := cached[key]; exists { + if info, exists := cached[key]; exists { + cachedInfo = info + hasCachedInfo = true accountUsage = accountUsageInfoToMap(cachedInfo) } } @@ -792,6 +796,9 @@ func (s *Service) GetSingleAccountUsage(ctx context.Context, id int) (map[string s.handleSingleAccountUsageErrors(ctx, item, usageErrors) if ok { normalized := normalizeAccountUsageInfo(info) + if hasCachedInfo { + normalized = mergeAccountUsageInfo(cachedInfo, normalized, s.now()) + } accountUsage = accountUsageInfoToMap(normalized) s.updateSingleAccountUsageCache(ctx, item.Platform, key, normalized) if item.State != "disabled" { @@ -1089,6 +1096,7 @@ func (s *Service) updateSingleAccountUsageCache(ctx context.Context, platform, a snapshot map[string]AccountUsageInfo } var pending []pendingWrite + now := s.now() s.usageMu.Lock() for _, raw := range usageCacheKeysForInvalidation(platform) { @@ -1098,8 +1106,16 @@ func (s *Service) updateSingleAccountUsageCache(ctx context.Context, platform, a continue } next := cloneAccountUsageInfoMap(entry.data) - next[accountKey] = info - s.usageCache[cacheKey] = &usageCacheEntry{data: next, expiresAt: entry.expiresAt} + if existing, ok := next[accountKey]; ok { + next[accountKey] = mergeAccountUsageInfo(existing, info, now) + } else { + next[accountKey] = info + } + expiresAt := usageCacheExpiresAt(next, now) + if expiresAt.Sub(now) < usageCacheMinimumTTL { + expiresAt = now.Add(usageCacheMinimumTTL) + } + s.usageCache[cacheKey] = &usageCacheEntry{data: next, expiresAt: expiresAt} pending = append(pending, pendingWrite{cacheKey: cacheKey, snapshot: next}) } s.usageMu.Unlock() @@ -1211,6 +1227,7 @@ func (s *Service) getUsageCacheFromRedis(ctx context.Context, cacheKey string) ( func (s *Service) setUsageCache(ctx context.Context, cacheKey string, accounts map[string]AccountUsageInfo) { cacheKey = usageCachePlatformKey(cacheKey) now := s.now() + accounts = s.mergeUsageCacheAccounts(cacheKey, accounts, now) expiresAt := usageCacheExpiresAt(accounts, now) ttl := expiresAt.Sub(now) if ttl < usageCacheMinimumTTL { @@ -1231,6 +1248,25 @@ func (s *Service) setUsageCache(ctx context.Context, cacheKey string, accounts m } } +func (s *Service) mergeUsageCacheAccounts(cacheKey string, accounts map[string]AccountUsageInfo, now time.Time) map[string]AccountUsageInfo { + s.usageMu.RLock() + entry, ok := s.usageCache[usageCachePlatformKey(cacheKey)] + if !ok { + s.usageMu.RUnlock() + return accounts + } + existing := entry.data + s.usageMu.RUnlock() + + merged := cloneAccountUsageInfoMap(accounts) + for key, info := range accounts { + if existingInfo, ok := existing[key]; ok { + merged[key] = mergeAccountUsageInfo(existingInfo, info, now) + } + } + return merged +} + func (s *Service) setUsageMemoryCache(cacheKey string, accounts map[string]AccountUsageInfo, expiresAt time.Time) { s.usageMu.Lock() s.usageCache[usageCachePlatformKey(cacheKey)] = &usageCacheEntry{data: accounts, expiresAt: expiresAt} diff --git a/backend/internal/app/account/usage_contract.go b/backend/internal/app/account/usage_contract.go index a3b59fa..fb28e28 100644 --- a/backend/internal/app/account/usage_contract.go +++ b/backend/internal/app/account/usage_contract.go @@ -5,7 +5,7 @@ import ( "time" ) -const accountUsageCacheVersion = 1 +const accountUsageCacheVersion = 2 type AccountUsageWindow struct { Key string `json:"key,omitempty"` @@ -156,6 +156,124 @@ func normalizeAccountUsageWindow(window AccountUsageWindow) (AccountUsageWindow, return window, true } +func mergeAccountUsageInfo(existing, incoming AccountUsageInfo, now time.Time) AccountUsageInfo { + merged := incoming + if merged.UpdatedAt == "" { + merged.UpdatedAt = existing.UpdatedAt + } + if merged.Credits == nil { + merged.Credits = existing.Credits + } + if len(existing.Windows) == 0 { + return merged + } + if len(merged.Windows) == 0 { + merged.Windows = liveAccountUsageWindows(existing.Windows, now) + return merged + } + + existingByID := make(map[string]AccountUsageWindow, len(existing.Windows)) + for _, window := range existing.Windows { + id := accountUsageWindowIdentity(window) + if id == "" { + continue + } + existingByID[id] = window + } + + windows := make([]AccountUsageWindow, 0, len(merged.Windows)+len(existingByID)) + seen := make(map[string]struct{}, len(merged.Windows)) + for _, window := range merged.Windows { + id := accountUsageWindowIdentity(window) + if id != "" { + if cached, ok := existingByID[id]; ok { + window = mergeAccountUsageWindow(cached, window, now) + } + seen[id] = struct{}{} + } + windows = append(windows, window) + } + for _, window := range existing.Windows { + id := accountUsageWindowIdentity(window) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + resetAt, ok := accountUsageWindowResetAt(window, now) + if !ok || !resetAt.After(now) { + continue + } + windows = append(windows, windowWithResetAt(window, resetAt, now)) + } + merged.Windows = windows + return merged +} + +func liveAccountUsageWindows(windows []AccountUsageWindow, now time.Time) []AccountUsageWindow { + result := make([]AccountUsageWindow, 0, len(windows)) + for _, window := range windows { + resetAt, ok := accountUsageWindowResetAt(window, now) + if !ok || !resetAt.After(now) { + continue + } + result = append(result, windowWithResetAt(window, resetAt, now)) + } + return result +} + +func mergeAccountUsageWindow(existing, incoming AccountUsageWindow, now time.Time) AccountUsageWindow { + merged := incoming + if merged.Label == "" { + merged.Label = existing.Label + } + if merged.DisplayLabel == "" { + merged.DisplayLabel = existing.DisplayLabel + } + if merged.Slot == "" { + merged.Slot = existing.Slot + } + if merged.Group == "" { + merged.Group = existing.Group + } + if merged.UpdatedAt == "" { + merged.UpdatedAt = existing.UpdatedAt + } + if merged.ResetAt == "" && merged.ResetSeconds <= 0 && merged.ResetAfterSeconds <= 0 { + if resetAt, ok := accountUsageWindowResetAt(existing, now); ok && resetAt.After(now) { + merged = windowWithResetAt(merged, resetAt, now) + } + } + return merged +} + +func windowWithResetAt(window AccountUsageWindow, resetAt, now time.Time) AccountUsageWindow { + window.ResetAt = resetAt.UTC().Format(time.RFC3339) + remaining := resetAt.Sub(now) + if remaining > 0 { + window.ResetSeconds = int64(remaining.Seconds()) + window.ResetAfterSeconds = window.ResetSeconds + } + return window +} + +func accountUsageWindowIdentity(window AccountUsageWindow) string { + if key := strings.TrimSpace(window.Key); key != "" { + return key + } + group := strings.TrimSpace(window.Group) + slot := normalizeUsageWindowToken(window.Slot) + if group != "" || slot != "" { + label := strings.TrimSpace(window.DisplayLabel) + if label == "" { + label = strings.TrimSpace(window.Label) + } + return group + ":" + slot + ":" + label + } + return strings.TrimSpace(window.Label) +} + func inferUsageWindowDisplayLabel(key, label, slot string) string { if slot == "monthly" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(label)), "cr ") { return "Cr" diff --git a/backend/internal/app/account/usage_contract_test.go b/backend/internal/app/account/usage_contract_test.go index 0d94495..acf2a53 100644 --- a/backend/internal/app/account/usage_contract_test.go +++ b/backend/internal/app/account/usage_contract_test.go @@ -76,3 +76,74 @@ func TestUsageCacheExpiresAtUsesEarlierResetOrFiveHours(t *testing.T) { t.Fatalf("expiresAt = %s, want %s", got, want) } } + +func TestMergeAccountUsageInfoPreservesLiveMissingWindows(t *testing.T) { + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + existing := AccountUsageInfo{ + UpdatedAt: "2026-05-20T11:55:00Z", + Windows: []AccountUsageWindow{ + { + Key: "5h", + Label: "5h", + DisplayLabel: "5h", + Slot: "5h", + Group: "base", + UsedPercent: 31, + ResetAt: now.Add(2 * time.Hour).Format(time.RFC3339), + }, + { + Key: "7d", + Label: "7d", + DisplayLabel: "7d", + Slot: "7d", + Group: "base", + UsedPercent: 44, + ResetAt: now.Add(48 * time.Hour).Format(time.RFC3339), + }, + }, + } + incoming := AccountUsageInfo{ + UpdatedAt: "2026-05-20T12:00:00Z", + Windows: []AccountUsageWindow{ + { + Key: "7d", + Label: "7d", + DisplayLabel: "7d", + Slot: "7d", + Group: "base", + UsedPercent: 55, + }, + }, + } + + merged := mergeAccountUsageInfo(existing, incoming, now) + if len(merged.Windows) != 2 { + t.Fatalf("len(windows) = %d, want 2: %+v", len(merged.Windows), merged.Windows) + } + if got := merged.Windows[0]; got.Key != "7d" || got.UsedPercent != 55 || got.ResetSeconds <= 0 { + t.Fatalf("merged 7d window = %+v, want incoming usage with preserved reset", got) + } + if got := merged.Windows[1]; got.Key != "5h" || got.UsedPercent != 31 || got.ResetSeconds <= 0 { + t.Fatalf("preserved 5h window = %+v, want live cached 5h", got) + } +} + +func TestMergeAccountUsageInfoDropsExpiredMissingWindows(t *testing.T) { + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + existing := AccountUsageInfo{ + Windows: []AccountUsageWindow{ + { + Key: "5h", + Label: "5h", + UsedPercent: 31, + ResetAt: now.Add(-time.Minute).Format(time.RFC3339), + }, + }, + } + incoming := AccountUsageInfo{Windows: []AccountUsageWindow{{Key: "7d", Label: "7d", UsedPercent: 55}}} + + merged := mergeAccountUsageInfo(existing, incoming, now) + if len(merged.Windows) != 1 || merged.Windows[0].Key != "7d" { + t.Fatalf("windows = %+v, want only incoming 7d", merged.Windows) + } +} diff --git a/backend/internal/app/usage/types.go b/backend/internal/app/usage/types.go index c56dbdd..8b5bfb4 100644 --- a/backend/internal/app/usage/types.go +++ b/backend/internal/app/usage/types.go @@ -56,6 +56,7 @@ type LogRecord struct { APIKeyDeleted bool AccountID int64 AccountName string + AccountEmail string GroupID int64 Platform string Model string diff --git a/backend/internal/infra/store/usage_store.go b/backend/internal/infra/store/usage_store.go index c18fe90..bc959b8 100644 --- a/backend/internal/infra/store/usage_store.go +++ b/backend/internal/infra/store/usage_store.go @@ -571,10 +571,9 @@ func mapUsageLog(item *ent.UsageLog) appusage.LogRecord { if item.Edges.Account != nil { record.AccountID = int64(item.Edges.Account.ID) if email, ok := item.Edges.Account.Credentials["email"]; ok && email != "" { - record.AccountName = email - } else { - record.AccountName = item.Edges.Account.Name + record.AccountEmail = email } + record.AccountName = item.Edges.Account.Name } else { record.AccountName = "-" } diff --git a/backend/internal/server/dto/usage.go b/backend/internal/server/dto/usage.go index 39a886f..a6c5b38 100644 --- a/backend/internal/server/dto/usage.go +++ b/backend/internal/server/dto/usage.go @@ -14,6 +14,7 @@ type UsageLogResp struct { APIKeyDeleted bool `json:"api_key_deleted"` AccountID int64 `json:"account_id"` AccountName string `json:"account_name,omitempty"` + AccountEmail string `json:"account_email,omitempty"` GroupID int64 `json:"group_id"` Platform string `json:"platform"` Model string `json:"model"` diff --git a/backend/internal/server/handler/usage_handler_mapper.go b/backend/internal/server/handler/usage_handler_mapper.go index f692a10..4021944 100644 --- a/backend/internal/server/handler/usage_handler_mapper.go +++ b/backend/internal/server/handler/usage_handler_mapper.go @@ -18,6 +18,7 @@ func toUsageLogResp(record appusage.LogRecord) dto.UsageLogResp { APIKeyDeleted: record.APIKeyDeleted, AccountID: record.AccountID, AccountName: record.AccountName, + AccountEmail: record.AccountEmail, GroupID: record.GroupID, Platform: record.Platform, Model: record.Model, diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 66dc4d3..8070467 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -550,6 +550,8 @@ "group": "Group", "select_group": "Select group", "quota_used": "Quota / Used", + "quota_used_short": "Used", + "quota_total_short": "Total", "expire_time": "Expires", "name_placeholder": "Name your key", "quota_label": "Quota (USD)", @@ -951,6 +953,8 @@ "quota_label": "Quota (USD)", "quota_hint": "Set to 0 or leave empty for unlimited", "quota_unlimited_hint": "Leave empty for unlimited", + "quota_used_short": "Used", + "quota_total_short": "Total", "expires_at": "Expires At", "expire_hint": "Leave empty for no expiry", "never_expire": "Never expires", @@ -974,7 +978,10 @@ "group_rate_short": "Group Rate", "group_rate_default": "Group default", "user_override_tag": "override", + "markup_title": "Sales/Cost", "sell_rate_short": "Sell Rate", + "cost_actual": "Cost", + "profit": "Profit", "copy": "Copy", "copied": "Copied", "reveal_failed": "Failed to retrieve key", diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index 95a6921..9a12dec 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -553,6 +553,8 @@ "group": "分组", "select_group": "请选择分组", "quota_used": "配额/已用", + "quota_used_short": "已使用", + "quota_total_short": "总配额", "expire_time": "过期时间", "name_placeholder": "给密钥起个名字", "quota_label": "配额 (USD)", @@ -954,6 +956,8 @@ "quota_label": "配额 (USD)", "quota_hint": "设为 0 或留空表示不限配额", "quota_unlimited_hint": "留空为无限制", + "quota_used_short": "已使用", + "quota_total_short": "总配额", "expires_at": "过期时间", "expire_hint": "留空表示永不过期", "never_expire": "永不过期", @@ -977,7 +981,10 @@ "group_rate_short": "分组倍率", "group_rate_default": "分组默认", "user_override_tag": "专属", + "markup_title": "销售/成本", "sell_rate_short": "销售倍率", + "cost_actual": "成本", + "profit": "利润", "copy": "复制", "copied": "已复制", "reveal_failed": "无法获取密钥原文", diff --git a/web/src/pages/admin/APIKeysPage.tsx b/web/src/pages/admin/APIKeysPage.tsx index b6c52b9..ae320e8 100644 --- a/web/src/pages/admin/APIKeysPage.tsx +++ b/web/src/pages/admin/APIKeysPage.tsx @@ -18,6 +18,7 @@ import { getTotalPages } from '../../shared/utils/pagination'; import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter'; import { TableLoadingRow } from '../../shared/components/TableLoadingRow'; import { CommonTable } from '../../shared/components/CommonTable'; +import { MetricChips } from '../../shared/components/MetricChips'; import { useClipboard } from '../../shared/hooks/useClipboard'; import { useCopyFeedback } from '../../shared/hooks/useCopyFeedback'; import { CreateKeyModal } from './apikeys/CreateKeyModal'; @@ -142,7 +143,7 @@ export default function APIKeysPage() { totalPages={totalPages} /> )} - minWidth={960} + minWidth={1120} > @@ -152,8 +153,8 @@ export default function APIKeysPage() { {t('api_keys.key_prefix')} {t('api_keys.group')} {t('common.status')} - {t('api_keys.quota_used')} - {t('api_keys.usage')} + {t('api_keys.quota_used')} + {t('api_keys.usage')} {t('api_keys.expire_time')} {t('common.actions')} @@ -208,23 +209,44 @@ export default function APIKeysPage() { - - ${row.used_quota.toFixed(2)} - / - {row.quota_usd > 0 ? `$${row.quota_usd.toFixed(2)}` : t('common.unlimited')} - + 0 ? row.quota_usd : undefined, + color: 'success', + label: t('api_keys.quota_total_short', '总配额'), + value: '∞', + }, + ]} + /> -
-
- {t('api_keys.today')}: - ${row.today_cost.toFixed(4)} -
-
- {t('api_keys.thirty_days')}: - ${row.thirty_day_cost.toFixed(4)} -
-
+
{formatExpiry(row.expires_at)} diff --git a/web/src/pages/admin/GroupsPage.tsx b/web/src/pages/admin/GroupsPage.tsx index e969091..2cbdb7a 100644 --- a/web/src/pages/admin/GroupsPage.tsx +++ b/web/src/pages/admin/GroupsPage.tsx @@ -4,11 +4,12 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { Plus, Pencil, - Layers, ArrowUpDown, Trash2, RefreshCw, Percent, + Image, + Text, } from 'lucide-react'; import { AlertDialog, Button, Chip, EmptyState, Label, ListBox, Select, Spinner } from '@heroui/react'; import { DialogTriggerShim } from '../../shared/components/DialogTriggerShim'; @@ -23,6 +24,7 @@ import { getTotalPages } from '../../shared/utils/pagination'; import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter'; import { TableLoadingRow } from '../../shared/components/TableLoadingRow'; import { CommonTable } from '../../shared/components/CommonTable'; +import { MetricChips } from '../../shared/components/MetricChips'; import { GroupFormModal } from './groups/EditGroupModal'; import { GroupRateOverridesModal } from './groups/GroupRateOverridesModal'; import type { GroupResp, CreateGroupReq, UpdateGroupReq } from '../../shared/types'; @@ -86,12 +88,11 @@ export default function GroupsPage() { }, }); - // 格式化费用 - const formatCost = (v: number) => `$${v.toFixed(2)}`; const rows = data?.list ?? []; const total = data?.total ?? 0; const totalPages = getTotalPages(total, pageSize); const selectedPlatformLabel = PLATFORM_OPTIONS.find((option) => option.value === platformFilter)?.label ?? t('groups.all_platforms'); + const isImageGroup = (group: GroupResp) => group.plugin_settings?.openai?.image_enabled === 'true'; return (
@@ -166,10 +167,10 @@ export default function GroupsPage() { {t('groups.group_type')} - + {t('groups.account_stats')} - + {t('groups.usage')} @@ -198,7 +199,11 @@ export default function GroupsPage() { - + {isImageGroup(row) ? ( + + ) : ( + + )} {row.name} @@ -229,36 +234,48 @@ export default function GroupsPage() { {t('groups.type_public')} )} - -
-
- {t('groups.account_available')}: - {row.account_active} -
- {row.account_error > 0 && ( -
- {t('groups.account_error')}: - {row.account_error} -
- )} -
- {t('groups.account_total')}: - {row.account_total} - {t('groups.account_unit')} -
-
+ + 0 ? [{ + color: 'default' as const, + label: t('groups.account_error'), + value: String(row.account_error), + }] : []), + { + color: 'default' as const, + label: t('groups.account_total'), + value: String(row.account_total), + }, + ]} + /> - -
-
- {t('groups.today_cost')} - {formatCost(row.today_cost)} -
-
- {t('groups.total_cost')} - {formatCost(row.total_cost)} -
-
+ +
diff --git a/web/src/pages/admin/UsagePage.tsx b/web/src/pages/admin/UsagePage.tsx index e1efce4..c75fcf3 100644 --- a/web/src/pages/admin/UsagePage.tsx +++ b/web/src/pages/admin/UsagePage.tsx @@ -664,9 +664,16 @@ export default function UsagePage() { width: '172px', hideOnMobile: true, render: (row) => { - const label = row.account_name || '-'; + const name = row.account_name || '-'; + const email = row.account_email?.trim(); + const title = email && name !== '-' ? `${name}\n${email}` : name; return ( - {label} +
+ {name} + {email && name !== '-' ? ( + {email} + ) : null} +
); }, }; diff --git a/web/src/pages/admin/accounts/useAccountTableColumns.tsx b/web/src/pages/admin/accounts/useAccountTableColumns.tsx index 4bad5e7..ee169e2 100644 --- a/web/src/pages/admin/accounts/useAccountTableColumns.tsx +++ b/web/src/pages/admin/accounts/useAccountTableColumns.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, type MouseEvent } from 'react'; +import { useMemo, useRef, type CSSProperties, type MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { RefreshCw } from 'lucide-react'; @@ -22,6 +22,13 @@ import { type QuotaRefreshResult = Awaited>; +const ACCOUNT_GROUP_CHIP_COLOR = 'oklch(62.04% 0.1950 253.83)'; +const ACCOUNT_GROUP_CHIP_STYLE: CSSProperties = { + background: `color-mix(in srgb, ${ACCOUNT_GROUP_CHIP_COLOR} 18%, transparent)`, + boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${ACCOUNT_GROUP_CHIP_COLOR} 34%, transparent)`, + color: ACCOUNT_GROUP_CHIP_COLOR, +}; + type UseAccountTableColumnsArgs = { applyQuotaRefreshResult: (id: number, result: QuotaRefreshResult) => void; groupMap: Map; @@ -144,7 +151,7 @@ export function useAccountTableColumns({ {name} @@ -152,7 +159,7 @@ export function useAccountTableColumns({ {hiddenCount > 0 ? ( +{hiddenCount} diff --git a/web/src/pages/user/UserKeysPage.tsx b/web/src/pages/user/UserKeysPage.tsx index 9d3e815..b166154 100644 --- a/web/src/pages/user/UserKeysPage.tsx +++ b/web/src/pages/user/UserKeysPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apikeysApi } from '../../shared/api/apikeys'; @@ -17,6 +17,7 @@ import { getTotalPages } from '../../shared/utils/pagination'; import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter'; import { TableLoadingRow } from '../../shared/components/TableLoadingRow'; import { CommonTable } from '../../shared/components/CommonTable'; +import { MetricChips } from '../../shared/components/MetricChips'; import { useClipboard } from '../../shared/hooks/useClipboard'; import { useCopyFeedback } from '../../shared/hooks/useCopyFeedback'; import { @@ -43,6 +44,12 @@ import { UseKeyModal, useUseKeyModal } from './userkeys/UseKeyModal'; import { CcsImportModal, useCcsImportModal } from './userkeys/CcsImportModal'; import { type KeyForm, emptyForm } from './userkeys/types'; +const GROUP_CHIP_COLOR = 'oklch(62.04% 0.1950 253.83)'; +const GROUP_CHIP_STYLE: CSSProperties = { + background: `color-mix(in srgb, ${GROUP_CHIP_COLOR} 18%, transparent)`, + boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${GROUP_CHIP_COLOR} 34%, transparent)`, + color: GROUP_CHIP_COLOR, +}; export default function UserKeysPage() { const { t } = useTranslation(); const { toast } = useToast(); @@ -181,7 +188,8 @@ export default function UserKeysPage() { const payload: UpdateAPIKeyReq = { name: form.name, group_id: form.group_id ? Number(form.group_id) : undefined, - quota_usd: form.quota_usd ? Number(form.quota_usd) : undefined, + // 空字符串显式改为 0 = 无限配额;省略字段只表示不修改旧配额 + quota_usd: form.quota_usd.trim() ? Number(form.quota_usd) : 0, sell_rate: form.sell_rate ? Number(form.sell_rate) : 0, // 空字符串显式改为 0 = 关闭并发限制;后端看到 0 会清除旧值 max_concurrency: form.max_concurrency ? Number(form.max_concurrency) : 0, @@ -313,9 +321,9 @@ export default function UserKeysPage() { {t('user_keys.title')} {t('user_keys.group')} {t('common.status')} - {t('user_keys.quota_label')} - {t('user_keys.markup_title', '销售/成本')} - {t('api_keys.usage')} + {t('user_keys.quota_label')} + {t('user_keys.markup_title', '销售/成本')} + {t('api_keys.usage')} {t('user_keys.expires_at')} {t('common.actions')} @@ -335,7 +343,8 @@ export default function UserKeysPage() { ) : ( rows.map((row) => { const group = row.group_id == null ? null : groupMap.get(row.group_id); - const groupName = row.group_id == null + const isGroupUnbound = row.group_id == null; + const groupName = isGroupUnbound ? t('user_keys.group_unbound') : group?.name || `#${row.group_id}`; const hasSellRate = row.sell_rate != null && row.sell_rate > 0; @@ -363,28 +372,34 @@ export default function UserKeysPage() {
-
{groupName}
- {group && ( -
- {t('user_keys.group_rate_short', '分组倍率')}:{' '} - {hasOverride && userOverride != null ? ( - - {userOverride.toFixed(2)} - - {t('user_keys.user_override_tag', '专属')} - - - ) : ( - group.rate_multiplier.toFixed(2) - )} -
- )} - {hasSellRate && ( -
- {t('user_keys.sell_rate_short', '销售倍率')}: {row.sell_rate!.toFixed(2)} -
+
+ + {isGroupUnbound ? : null} + {groupName} + +
+ {(group || hasSellRate) && ( + )}
@@ -392,49 +407,68 @@ export default function UserKeysPage() { - - {row.quota_usd > 0 ? ( - <> - ${row.used_quota.toFixed(4)} / ${row.quota_usd.toFixed(4)} - - ) : ( - {t('user_keys.quota_unlimited_hint')} - )} - + 0 ? row.quota_usd : undefined, + color: 'success', + label: t('user_keys.quota_total_short', '总配额'), + value: '∞', + }, + ]} + /> - {!row.sell_rate || row.sell_rate <= 0 ? ( - - ) : ( -
-
- {t('user_keys.sell_rate_short', '倍率')}: - {row.sell_rate.toFixed(2)} -
-
- {t('user_keys.cost_actual', '成本')}: - ${(row.used_quota_actual || 0).toFixed(4)} -
-
- {t('user_keys.profit', '利润')}: - 0 ? 'var(--ag-success)' : undefined }}> - ${profit.toFixed(4)} - -
-
- )} +
-
-
- {t('api_keys.today')}: - ${row.today_cost.toFixed(4)} -
-
- {t('api_keys.thirty_days')}: - ${row.thirty_day_cost.toFixed(4)} -
-
+
{row.expires_at diff --git a/web/src/shared/components/MetricChips.tsx b/web/src/shared/components/MetricChips.tsx new file mode 100644 index 0000000..cf663b2 --- /dev/null +++ b/web/src/shared/components/MetricChips.tsx @@ -0,0 +1,73 @@ +import { Chip } from '@heroui/react'; + +type MetricChipColor = 'default' | 'warning' | 'success' | 'accent'; + +export type MetricChipItem = { + amount?: number; + color: MetricChipColor; + decimals?: number; + dollarTone?: MetricChipColor; + highlightDollar?: boolean; + label: string; + mutedWhenZero?: boolean; + value?: string; +}; + +function formatMoneyAmount(value: number, decimals = 4) { + return (Number.isFinite(value) ? value : 0).toFixed(decimals); +} + +function formatMetricTitleValue(item: MetricChipItem) { + if (item.amount != null) return `$${formatMoneyAmount(item.amount, item.decimals)}`; + return item.value ?? ''; +} + +function MetricChip({ amount, color, decimals, dollarTone, highlightDollar, label, mutedWhenZero, value }: MetricChipItem) { + const amountText = amount == null ? null : formatMoneyAmount(amount, decimals); + const isMutedZero = mutedWhenZero && amount === 0; + const chipClassName = [ + 'ag-metric-chip', + isMutedZero ? 'ag-metric-chip--zero' : '', + ].filter(Boolean).join(' '); + const effectiveDollarTone = dollarTone ?? (highlightDollar ? 'warning' : undefined); + const dollarClassName = [ + 'ag-metric-dollar', + effectiveDollarTone ? `ag-metric-dollar--${effectiveDollarTone}` : '', + ].filter(Boolean).join(' '); + + return ( + + {label} + + {amountText == null ? ( + value === '∞' ? {value} : value + ) : ( + <> + $ + {amountText} + + )} + + + ); +} + +export function MetricChips({ + className, + items, +}: { + className?: string; + items: MetricChipItem[]; +}) { + const title = items + .map((item) => `${item.label} ${formatMetricTitleValue(item)}`) + .join(' / '); + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/web/src/shared/types/index.ts b/web/src/shared/types/index.ts index 6e8d1cb..171402c 100644 --- a/web/src/shared/types/index.ts +++ b/web/src/shared/types/index.ts @@ -473,6 +473,7 @@ export interface UsageLogResp { api_key_deleted: boolean; account_id: number; account_name?: string; + account_email?: string; group_id: number; platform: string; model: string; diff --git a/web/src/styles/pages.css b/web/src/styles/pages.css index 05c08e7..5564589 100644 --- a/web/src/styles/pages.css +++ b/web/src/styles/pages.css @@ -14,6 +14,109 @@ margin-inline: auto; } +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips { + display: inline-grid; + align-items: center; + gap: 0.375rem; + font-family: var(--ag-font-mono); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--quota { + width: min(100%, 17.5rem); + min-width: 17.5rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--stack { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--usage { + width: min(100%, 10.25rem); + min-width: 10.25rem; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--markup { + width: min(100%, 10.25rem); + min-width: 10.25rem; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--compact-y { + gap: 0.25rem; +} + +.ag-groups-table .ag-groups-metric-cell { + padding-block: 0.625rem !important; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip { + display: inline-grid !important; + width: 100%; + min-width: 0; + grid-template-columns: 2.75rem minmax(5.25rem, 1fr); + align-items: center; + color: var(--ag-text) !important; + gap: 0.375rem; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--markup .ag-metric-chip { + grid-template-columns: 3.25rem minmax(5.25rem, 1fr); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--usage .ag-metric-chip { + grid-template-columns: 3.25rem minmax(5.25rem, 1fr); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-label, +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-value { + min-width: 0; + color: var(--ag-text) !important; + overflow: hidden; + opacity: 1; + white-space: nowrap; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-label { + color: var(--ag-text-secondary) !important; + font-weight: 600; + letter-spacing: 0; + text-overflow: ellipsis; + text-align: left; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-value { + display: inline-flex; + min-width: 5.25rem; + align-items: center; + justify-self: end; + font-variant-numeric: tabular-nums; + font-weight: 600; + justify-content: flex-end; + text-align: right; + text-overflow: ellipsis; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip--zero { + background: var(--ag-default-bg) !important; +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-dollar--warning { + color: var(--ag-warning); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-dollar--success { + color: var(--ag-success); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-dollar--accent { + color: var(--ag-primary); +} + +:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-infinity { + font-weight: 800; +} + .ag-elevation-modal.ag-create-key-modal, .ag-elevation-modal.modal__dialog--lg.ag-create-key-modal { width: min(45rem, calc(100vw - 2rem)) !important;