From afd72abc6ed765cda52e2c6e5876df5d28e5e5ee Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:22:31 +0800 Subject: [PATCH 1/5] fix: allow empty extra payload to clear account quota limits UpdateAccount previously required len(input.Extra) > 0, causing explicit empty payloads (extra:{}) to be silently skipped. Change condition to input.Extra != nil so clearing quota keys actually persists. --- backend/internal/service/admin_service.go | 4 ++- .../service/admin_service_overages_test.go | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ea76e171..5eeac183 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1530,7 +1530,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if len(input.Credentials) > 0 { account.Credentials = input.Credentials } - if len(input.Extra) > 0 { + // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 + // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 + if input.Extra != nil { // 保留配额用量字段,防止编辑账号时意外重置 for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { if v, ok := account.Extra[key]; ok { diff --git a/backend/internal/service/admin_service_overages_test.go b/backend/internal/service/admin_service_overages_test.go index 779b08b9..d6380f4d 100644 --- a/backend/internal/service/admin_service_overages_test.go +++ b/backend/internal/service/admin_service_overages_test.go @@ -121,3 +121,35 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi _, exists := repo.account.Extra[modelRateLimitsKey] require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流") } + +func TestUpdateAccount_EmptyExtraPayloadCanClearQuotaLimits(t *testing.T) { + accountID := int64(103) + repo := &updateAccountOveragesRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Extra: map[string]any{ + "quota_limit": 100.0, + "quota_daily_limit": 10.0, + "quota_weekly_limit": 40.0, + }, + }, + } + + svc := &adminServiceImpl{accountRepo: repo} + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + // 显式空对象:语义是“清空 extra 中的可配置键”(例如关闭配额限制) + Extra: map[string]any{}, + }) + + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, 1, repo.updateCalls) + require.NotNil(t, repo.account.Extra) + require.NotContains(t, repo.account.Extra, "quota_limit") + require.NotContains(t, repo.account.Extra, "quota_daily_limit") + require.NotContains(t, repo.account.Extra, "quota_weekly_limit") + require.Len(t, repo.account.Extra, 0) +} From fa782e70a43916e374bd53444208fdf52f7bca8d Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:22:42 +0800 Subject: [PATCH 2/5] fix: always attach OpenAI 5h/7d window stats regardless of zero values Removes hasMeaningfulWindowStats guard so the /usage endpoint consistently returns WindowStats for both time windows. The frontend now controls zero-value display filtering at the component level. --- .../internal/service/account_usage_service.go | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index f117abfd..959c1182 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -446,23 +446,17 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou } if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-5*time.Hour)); err == nil { - windowStats := windowStatsFromAccountStats(stats) - if hasMeaningfulWindowStats(windowStats) { - if usage.FiveHour == nil { - usage.FiveHour = &UsageProgress{Utilization: 0} - } - usage.FiveHour.WindowStats = windowStats + if usage.FiveHour == nil { + usage.FiveHour = &UsageProgress{Utilization: 0} } + usage.FiveHour.WindowStats = windowStatsFromAccountStats(stats) } if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-7*24*time.Hour)); err == nil { - windowStats := windowStatsFromAccountStats(stats) - if hasMeaningfulWindowStats(windowStats) { - if usage.SevenDay == nil { - usage.SevenDay = &UsageProgress{Utilization: 0} - } - usage.SevenDay.WindowStats = windowStats + if usage.SevenDay == nil { + usage.SevenDay = &UsageProgress{Utilization: 0} } + usage.SevenDay.WindowStats = windowStatsFromAccountStats(stats) } return usage, nil @@ -992,13 +986,6 @@ func windowStatsFromAccountStats(stats *usagestats.AccountStats) *WindowStats { } } -func hasMeaningfulWindowStats(stats *WindowStats) bool { - if stats == nil { - return false - } - return stats.Requests > 0 || stats.Tokens > 0 || stats.Cost > 0 || stats.StandardCost > 0 || stats.UserCost > 0 -} - func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now time.Time) *UsageProgress { if len(extra) == 0 { return nil From 8640a62319e1764a9ec29465f568ca203c698620 Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:22:51 +0800 Subject: [PATCH 3/5] refactor: extract formatCompactNumber util and add last_used_at to refresh key - Add formatCompactNumber() for consistent large-number formatting (K/M/B) - Include last_used_at in OpenAI usage refresh key for better change detection - Add .gitattributes eol=lf rules for frontend source files --- .gitattributes | 7 ++++++ .../__tests__/accountUsageRefresh.spec.ts | 24 +++++++++++++++++++ .../__tests__/formatCompactNumber.spec.ts | 22 +++++++++++++++++ frontend/src/utils/accountUsageRefresh.ts | 3 ++- frontend/src/utils/format.ts | 20 ++++++++++++++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/__tests__/formatCompactNumber.spec.ts diff --git a/.gitattributes b/.gitattributes index 3db3b83d..37e3bee2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,13 @@ backend/migrations/*.sql text eol=lf # Go 源代码文件 *.go text eol=lf +# 前端 源代码文件 +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.vue text eol=lf + # Shell 脚本 *.sh text eol=lf diff --git a/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts b/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts index ae13d690..aef73b0f 100644 --- a/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts +++ b/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts @@ -8,6 +8,7 @@ describe('buildOpenAIUsageRefreshKey', () => { platform: 'openai', type: 'oauth', updated_at: '2026-03-07T10:00:00Z', + last_used_at: '2026-03-07T09:59:00Z', extra: { codex_usage_updated_at: '2026-03-07T10:00:00Z', codex_5h_used_percent: 0, @@ -27,12 +28,35 @@ describe('buildOpenAIUsageRefreshKey', () => { expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next)) }) + it('会在 last_used_at 变化时生成不同 key', () => { + const base = { + id: 3, + platform: 'openai', + type: 'oauth', + updated_at: '2026-03-07T10:00:00Z', + last_used_at: '2026-03-07T10:00:00Z', + extra: { + codex_usage_updated_at: '2026-03-07T10:00:00Z', + codex_5h_used_percent: 12, + codex_7d_used_percent: 24 + } + } as any + + const next = { + ...base, + last_used_at: '2026-03-07T10:02:00Z' + } + + expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next)) + }) + it('非 OpenAI OAuth 账号返回空 key', () => { expect(buildOpenAIUsageRefreshKey({ id: 2, platform: 'anthropic', type: 'oauth', updated_at: '2026-03-07T10:00:00Z', + last_used_at: '2026-03-07T10:00:00Z', extra: {} } as any)).toBe('') }) diff --git a/frontend/src/utils/__tests__/formatCompactNumber.spec.ts b/frontend/src/utils/__tests__/formatCompactNumber.spec.ts new file mode 100644 index 00000000..a5a9ed9f --- /dev/null +++ b/frontend/src/utils/__tests__/formatCompactNumber.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { formatCompactNumber } from '../format' + +describe('formatCompactNumber', () => { + it('formats boundary values with K/M/B', () => { + expect(formatCompactNumber(0)).toBe('0') + expect(formatCompactNumber(999)).toBe('999') + expect(formatCompactNumber(1000)).toBe('1.0K') + expect(formatCompactNumber(999999)).toBe('1000.0K') + expect(formatCompactNumber(1000000)).toBe('1.0M') + expect(formatCompactNumber(1000000000)).toBe('1.0B') + }) + + it('supports disabling billion unit (requests style)', () => { + expect(formatCompactNumber(1000000000, { allowBillions: false })).toBe('1000.0M') + }) + + it('returns 0 for nullish input', () => { + expect(formatCompactNumber(null)).toBe('0') + expect(formatCompactNumber(undefined)).toBe('0') + }) +}) diff --git a/frontend/src/utils/accountUsageRefresh.ts b/frontend/src/utils/accountUsageRefresh.ts index 219ac57f..3406c7a5 100644 --- a/frontend/src/utils/accountUsageRefresh.ts +++ b/frontend/src/utils/accountUsageRefresh.ts @@ -5,7 +5,7 @@ const normalizeUsageRefreshValue = (value: unknown): string => { return String(value) } -export const buildOpenAIUsageRefreshKey = (account: Pick): string => { +export const buildOpenAIUsageRefreshKey = (account: Pick): string => { if (account.platform !== 'openai' || account.type !== 'oauth') { return '' } @@ -14,6 +14,7 @@ export const buildOpenAIUsageRefreshKey = (account: Pick= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B` + if (abs >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M` + if (abs >= 1_000) return `${(num / 1_000).toFixed(1)}K` + return num.toString() +} + /** * 格式化倒计时(从现在到目标时间的剩余时间) * @param targetDate 目标日期字符串或 Date 对象 From fbffb08aae4a8bea2db6a6392c9ef30f777a14d0 Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:23:00 +0800 Subject: [PATCH 4/5] feat: add today-stats and manual refresh token propagation to usage cells - Pass todayStats/todayStatsLoading to AccountUsageCell for key accounts - Propagate usageManualRefreshToken to force usage reload on explicit refresh - Refresh today stats when toggling usage/today_stats columns visible --- frontend/src/views/admin/AccountsView.vue | 39 +++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index dd342a5b..2ec5b47d 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -203,7 +203,12 @@ @@ -423,12 +442,23 @@ import { adminAPI } from '@/api/admin' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { resolveCodexUsageWindow } from '@/utils/codexUsage' +import { formatCompactNumber } from '@/utils/format' import UsageProgressBar from './UsageProgressBar.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue' -const props = defineProps<{ - account: Account -}>() +const props = withDefaults( + defineProps<{ + account: Account + todayStats?: WindowStats | null + todayStatsLoading?: boolean + manualRefreshToken?: number + }>(), + { + todayStats: null, + todayStatsLoading: false, + manualRefreshToken: 0 + } +) const { t } = useI18n() @@ -490,26 +520,9 @@ const isActiveOpenAIRateLimited = computed(() => { return !Number.isNaN(resetAt) && resetAt > Date.now() }) -const preferFetchedOpenAIUsage = computed(() => { - return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value -}) - const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account)) -const isOpenAICodexSnapshotStale = computed(() => { - if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false - const extra = props.account.extra as Record | undefined - const updatedAtRaw = extra?.codex_usage_updated_at - if (!updatedAtRaw) return true - const updatedAt = Date.parse(String(updatedAtRaw)) - if (Number.isNaN(updatedAt)) return true - return Date.now() - updatedAt >= 10 * 60 * 1000 -}) - const shouldAutoLoadUsageOnMount = computed(() => { - if (props.account.platform === 'openai' && props.account.type === 'oauth') { - return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value - } return shouldFetchUsage.value }) @@ -1006,6 +1019,28 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => { return makeQuotaBar(props.account.quota_used ?? 0, limit) }) +// ===== Key account today stats formatters ===== + +const formatKeyRequests = computed(() => { + if (!props.todayStats) return '' + return formatCompactNumber(props.todayStats.requests, { allowBillions: false }) +}) + +const formatKeyTokens = computed(() => { + if (!props.todayStats) return '' + return formatCompactNumber(props.todayStats.tokens) +}) + +const formatKeyCost = computed(() => { + if (!props.todayStats) return '0.00' + return props.todayStats.cost.toFixed(2) +}) + +const formatKeyUserCost = computed(() => { + if (!props.todayStats || props.todayStats.user_cost == null) return '0.00' + return props.todayStats.user_cost.toFixed(2) +}) + onMounted(() => { if (!shouldAutoLoadUsageOnMount.value) return loadUsage() @@ -1014,10 +1049,21 @@ onMounted(() => { watch(openAIUsageRefreshKey, (nextKey, prevKey) => { if (!prevKey || nextKey === prevKey) return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return - if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return loadUsage().catch((e) => { console.error('Failed to refresh OpenAI usage:', e) }) }) + +watch( + () => props.manualRefreshToken, + (nextToken, prevToken) => { + if (nextToken === prevToken) return + if (!shouldFetchUsage.value) return + + loadUsage().catch((e) => { + console.error('Failed to refresh usage after manual refresh:', e) + }) + } +) diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index cd5c991f..5ce8bfe0 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -2,7 +2,7 @@
@@ -12,12 +12,13 @@ {{ formatTokens }} - + A ${{ formatAccountCost }} U ${{ formatUserCost }} @@ -56,7 +57,9 @@