diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index fd27f9d..124d6c2 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -812,6 +812,31 @@ describe('oauth routes', { timeout: 15_000 }, () => { accountId: 'chatgpt-account-123', email: 'codex-user@example.com', planType: 'team', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'team', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-17T08:00:00.000Z', + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, modelDiscoveryStatus: 'abnormal', lastModelSyncAt: '2026-03-17T08:00:00.000Z', lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', @@ -853,6 +878,16 @@ describe('oauth routes', { timeout: 15_000 }, () => { provider: 'codex', email: 'codex-user@example.com', status: 'abnormal', + quota: expect.objectContaining({ + status: 'supported', + source: 'reverse_engineered', + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + subscription: expect.objectContaining({ + planType: 'team', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }), + }), routeChannelCount: 1, lastModelSyncAt: '2026-03-17T08:00:00.000Z', lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', @@ -874,6 +909,96 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(accounts).toEqual([]); }); + it('refreshes oauth quota snapshots and marks unsupported providers explicitly', async () => { + const codexSite = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + const antigravitySite = await db.insert(schema.sites).values({ + name: 'Antigravity OAuth', + url: 'https://example.com/antigravity', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const codexAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + }, + }), + }).returning().get(); + + const antigravityAccount = await db.insert(schema.accounts).values({ + siteId: antigravitySite.id, + username: 'ag-user@example.com', + accessToken: 'oauth-access-token', + status: 'active', + oauthProvider: 'antigravity', + oauthAccountKey: 'ag-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + accountId: 'ag-account-123', + email: 'ag-user@example.com', + planType: 'pro', + }, + }), + }).returning().get(); + + const codexRefresh = await app.inject({ + method: 'POST', + url: `/api/oauth/connections/${codexAccount.id}/quota/refresh`, + }); + expect(codexRefresh.statusCode).toBe(200); + expect(codexRefresh.json()).toMatchObject({ + success: true, + quota: expect.objectContaining({ + status: 'supported', + subscription: expect.objectContaining({ + planType: 'plus', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }), + }), + }); + + const antigravityRefresh = await app.inject({ + method: 'POST', + url: `/api/oauth/connections/${antigravityAccount.id}/quota/refresh`, + }); + expect(antigravityRefresh.statusCode).toBe(200); + expect(antigravityRefresh.json()).toMatchObject({ + success: true, + quota: expect.objectContaining({ + status: 'unsupported', + providerMessage: 'current local CLIProxyAPI evidence does not expose official quota windows for antigravity oauth', + }), + }); + }); + it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => { const buildTokenExchange = (accountId: string) => ({ ok: true, diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts index 5eb942e..32976eb 100644 --- a/src/server/routes/api/oauth.ts +++ b/src/server/routes/api/oauth.ts @@ -6,6 +6,7 @@ import { handleOauthCallback, listOauthConnections, listOauthProviders, + refreshOauthConnectionQuota, startOauthProviderFlow, startOauthRebindFlow, submitOauthManualCallback, @@ -234,6 +235,22 @@ export async function oauthRoutes(app: FastifyInstance) { }, ); + app.post<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId/quota/refresh', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await refreshOauthConnectionQuota(accountId); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>( '/api/oauth/callback/:provider', { preHandler: [limitOauthCallback] }, diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index fc0f07f..95ec12d 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -68,6 +68,9 @@ describe('backupService', () => { username: 'roundtrip-user', accessToken: 'session-token', apiToken: 'api-token', + oauthProvider: 'codex', + oauthAccountKey: 'roundtrip-account-key', + oauthProjectId: 'roundtrip-project-id', balance: 12.3, balanceUsed: 4.5, quota: 99.9, @@ -141,6 +144,9 @@ describe('backupService', () => { expect(restoredAccount?.isPinned).toBe(true); expect(restoredAccount?.sortOrder).toBe(7); + expect(restoredAccount?.oauthProvider).toBe('codex'); + expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key'); + expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id'); expect(restoredRoute?.displayName).toBe('gpt-route'); expect(restoredRoute?.displayIcon).toBe('icon-gpt'); @@ -203,4 +209,80 @@ describe('backupService', () => { expect(accounts[0].username).toBe('legacy-user'); expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); }); + + it('backfills oauth columns from extraConfig when importing older backups', async () => { + const payload = { + timestamp: Date.now(), + accounts: { + sites: [ + { + id: 1, + name: 'codex-site', + url: 'https://codex.example.com', + platform: 'chatgpt-account', + proxyUrl: null, + status: 'active', + isPinned: false, + sortOrder: 0, + apiKey: null, + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + externalCheckinUrl: null, + useSystemProxy: false, + globalWeight: 1, + customHeaders: null, + }, + ], + accounts: [ + { + id: 10, + siteId: 1, + username: 'oauth-user', + accessToken: 'oauth-access-token', + apiToken: null, + balance: 0, + balanceUsed: 0, + quota: 0, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: 0, + checkinEnabled: true, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + accountId: 'oauth-user@example.com', + accountKey: 'oauth-user@example.com', + projectId: 'oauth-project-id', + refreshToken: 'oauth-refresh-token', + }, + }), + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + }, + ], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + }, + } as Record; + + const result = await backupService.importBackup(payload); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get(); + + expect(restoredAccount?.oauthProvider).toBe('gemini-cli'); + expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com'); + expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id'); + }); }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 09f5b75..5e23fec 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm'; import cron from 'node-cron'; import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; +import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; const BACKUP_VERSION = '2.0'; @@ -104,6 +105,18 @@ function normalizeLegacyQuota(raw: unknown): number { return value; } +function resolveImportedOauthColumns(row: Pick) { + const oauth = getOauthInfoFromExtraConfig(row.extraConfig); + const oauthProvider = row.oauthProvider || oauth?.provider || null; + const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null; + const oauthProjectId = row.oauthProjectId || oauth?.projectId || null; + return { + oauthProvider, + oauthAccountKey, + oauthProjectId, + }; +} + function normalizeLegacyPlatform(raw: string): string { const value = raw.trim().toLowerCase(); if (!value) return 'new-api'; @@ -494,12 +507,16 @@ async function importAccountsSection(section: AccountsBackupSection): Promise; + quota?: OauthQuotaSnapshot; modelDiscoveryStatus?: OauthModelDiscoveryStatus; lastModelSyncAt?: string; lastModelSyncError?: string; @@ -88,6 +91,11 @@ function asRecord(value: unknown): Record | undefined { return value as Record; } +function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as OauthQuotaSnapshot; +} + function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined { if (typeof value !== 'string') return undefined; const normalized = value.trim().toLowerCase(); @@ -113,6 +121,7 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI refreshToken: asTrimmedString(parsed.refreshToken), idToken: asTrimmedString(parsed.idToken), providerData: asRecord(parsed.providerData), + quota: asQuotaSnapshot(parsed.quota), modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus), lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt), lastModelSyncError: asTrimmedString(parsed.lastModelSyncError), diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index 41f0e77..9a73d70 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -16,6 +16,7 @@ import { } from './providers.js'; import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js'; import { buildCodexOauthInfo } from './codexAccount.js'; +import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js'; type OAuthProviderMetadata = ReturnType[number]; const MANUAL_CALLBACK_DELAY_MS = 15_000; @@ -503,6 +504,7 @@ export async function listOauthConnections(options: { projectId: oauth.projectId, modelCount: models.length, modelsPreview: models.slice(0, 10), + quota: buildQuotaSnapshotFromOauthInfo(oauth), status, routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0, lastModelSyncAt: oauth.lastModelSyncAt, @@ -535,6 +537,11 @@ export async function deleteOauthConnection(accountId: number) { return { success: true }; } +export async function refreshOauthConnectionQuota(accountId: number) { + const quota = await refreshOauthQuotaSnapshot(accountId); + return { success: true, quota }; +} + export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) { const account = await db.select().from(schema.accounts) .where(eq(schema.accounts.id, accountId)) diff --git a/src/web/api.ts b/src/web/api.ts index 8c90f4d..fd39f2e 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -283,6 +283,33 @@ export type OAuthSessionInfo = { error?: string; }; +export type OAuthQuotaWindowInfo = { + supported: boolean; + limit?: number | null; + used?: number | null; + remaining?: number | null; + resetAt?: string | null; + message?: string | null; +}; + +export type OAuthQuotaInfo = { + status: 'supported' | 'unsupported' | 'error'; + source: 'official' | 'reverse_engineered'; + lastSyncAt?: string | null; + lastError?: string | null; + providerMessage?: string | null; + subscription?: { + planType?: string | null; + activeStart?: string | null; + activeUntil?: string | null; + } | null; + windows: { + fiveHour: OAuthQuotaWindowInfo; + sevenDay: OAuthQuotaWindowInfo; + }; + lastLimitResetAt?: string | null; +}; + export type OAuthConnectionInfo = { accountId: number; siteId: number; @@ -295,6 +322,7 @@ export type OAuthConnectionInfo = { modelCount: number; modelsPreview: string[]; status: 'healthy' | 'abnormal'; + quota?: OAuthQuotaInfo | null; routeChannelCount?: number; lastModelSyncAt?: string | null; lastModelSyncError?: string | null; @@ -432,6 +460,10 @@ export const api = { }) as Promise<{ success: true }>, getOAuthConnections: (params?: { limit?: number; offset?: number }) => request(`/api/oauth/connections${buildQueryString(params)}`) as Promise, + refreshOAuthConnectionQuota: (accountId: number) => request(`/api/oauth/connections/${accountId}/quota/refresh`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise<{ success: true; quota: OAuthQuotaInfo }>, rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, { method: 'POST', body: JSON.stringify({}), diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index 296c65b..b3de514 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -11,6 +11,7 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() startOAuthProvider: vi.fn(), getOAuthSession: vi.fn(), submitOAuthManualCallback: vi.fn(), + refreshOAuthConnectionQuota: vi.fn(), rebindOAuthConnection: vi.fn(), deleteOAuthConnection: vi.fn(), }, @@ -684,6 +685,26 @@ describe('OAuthManagement page', () => { modelCount: 11, modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'], status: 'abnormal', + quota: { + status: 'supported', + source: 'reverse_engineered', + subscription: { + planType: 'team', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }, + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, routeChannelCount: 1, lastModelSyncAt: '2026-03-17T08:00:00.000Z', lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', @@ -713,6 +734,12 @@ describe('OAuthManagement page', () => { expect(text).toContain('异常'); expect(text).toContain('1 条路由'); expect(text).toContain('Codex 模型获取失败'); + expect(text).toContain('额度状态'); + expect(text).toContain('team'); + expect(text).toContain('2026-03-01T00:00:00.000Z'); + expect(text).toContain('2026-04-01T00:00:00.000Z'); + expect(text).toContain('2026-03-17T13:00:00.000Z'); + expect(text).toContain('官方未提供'); }); const deleteButton = root!.root.find((node) => ( @@ -735,4 +762,139 @@ describe('OAuthManagement page', () => { root?.unmount(); } }); + + it('refreshes quota for a connection and reloads the connection list', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + provider: 'codex', + email: 'codex-user@example.com', + planType: 'team', + modelCount: 11, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + quota: { + status: 'supported', + source: 'reverse_engineered', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + }, + ], + total: 1, + limit: 100, + offset: 0, + }) + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + provider: 'codex', + email: 'codex-user@example.com', + planType: 'team', + modelCount: 11, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.refreshOAuthConnectionQuota.mockResolvedValue({ + success: true, + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('刷新额度'); + }); + + const refreshButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('刷新额度') + )); + + await act(async () => { + await refreshButton.props.onClick(); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + expect(apiMock.refreshOAuthConnectionQuota).toHaveBeenCalledWith(7); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + expect(collectText(root!.root)).toContain('额度信息已刷新'); + expect(collectText(root!.root)).toContain('2026-03-18T01:00:00.000Z'); + } finally { + root?.unmount(); + } + }); }); diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index 7fde182..16190a9 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -1,5 +1,12 @@ import { useEffect, useState, type ReactNode } from 'react'; -import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js'; +import { + api, + type OAuthConnectionInfo, + type OAuthProviderInfo, + type OAuthQuotaInfo, + type OAuthQuotaWindowInfo, + type OAuthStartInstructions, +} from '../api.js'; const POLL_INTERVAL_MS = 1500; const CONNECTION_PAGE_LIMIT = 100; @@ -57,6 +64,35 @@ function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string { return email; } +function resolveQuotaStatusLabel(status?: OAuthQuotaInfo['status']): string { + if (status === 'unsupported') return '不支持'; + if (status === 'error') return '获取失败'; + return '支持'; +} + +function resolveQuotaSourceLabel(source?: OAuthQuotaInfo['source']): string { + return source === 'official' ? '官方' : '本地信号'; +} + +function resolveQuotaWindowSummary(window?: OAuthQuotaWindowInfo | null): string { + if (!window || !window.supported) return '官方未提供'; + + const parts: string[] = []; + if (typeof window.remaining === 'number') { + parts.push(`剩余 ${window.remaining}`); + } + if (typeof window.used === 'number' && typeof window.limit === 'number') { + parts.push(`已用 ${window.used} / ${window.limit}`); + } else if (typeof window.limit === 'number') { + parts.push(`总量 ${window.limit}`); + } + if (window.resetAt) { + parts.push(`重置 ${window.resetAt}`); + } + + return parts.length > 0 ? parts.join(' · ') : '官方未提供'; +} + function renderCodeBlock(value: string) { return ( { + const actionKey = `quota:${accountId}`; + setActionLoadingKey(actionKey); + try { + await api.refreshOAuthConnectionQuota(accountId); + setSessionMessage('额度信息已刷新'); + await loadConnections(); + } catch (error: any) { + setSessionMessage(error?.message || '刷新额度失败'); + } finally { + setActionLoadingKey(''); + } + }; + return (
@@ -416,9 +466,11 @@ export default function OAuthManagement() {
{connections.map((connection) => { const rebindActionKey = `start:${connection.provider}:${connection.accountId}`; + const refreshQuotaActionKey = `quota:${connection.accountId}`; const deleteActionKey = `delete:${connection.accountId}`; const primaryTitle = resolveConnectionPrimaryTitle(connection); const emailLabel = resolveConnectionEmailLabel(connection); + const quota = connection.quota; return (
@@ -460,8 +512,72 @@ export default function OAuthManagement() { {connection.modelsPreview.join(', ')}
)} + {quota && ( +
+
额度状态
+
+ {resolveQuotaStatusLabel(quota.status)} · {resolveQuotaSourceLabel(quota.source)} + {quota.lastSyncAt ? ` · 最近刷新: ${quota.lastSyncAt}` : ''} +
+ {quota.subscription?.planType && ( +
+ 订阅类型: {quota.subscription.planType} +
+ )} + {quota.subscription?.activeStart && ( +
+ 订阅开始: {quota.subscription.activeStart} +
+ )} + {quota.subscription?.activeUntil && ( +
+ 订阅到期: {quota.subscription.activeUntil} +
+ )} + {quota.lastLimitResetAt && ( +
+ 最近重置提示: {quota.lastLimitResetAt} +
+ )} +
+ 5 小时窗口: {resolveQuotaWindowSummary(quota.windows?.fiveHour)} +
+
+ 7 天窗口: {resolveQuotaWindowSummary(quota.windows?.sevenDay)} +
+ {quota.providerMessage && ( +
+ {quota.providerMessage} +
+ )} + {quota.lastError && ( +
+ {quota.lastError} +
+ )} +
+ )}
+ @@ -487,7 +607,11 @@ export default function OAuthManagement() { type="button" className="btn btn-ghost" onClick={() => handleDelete(connection.accountId)} - disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey} + disabled={ + actionLoadingKey === rebindActionKey + || actionLoadingKey === refreshQuotaActionKey + || actionLoadingKey === deleteActionKey + } > {actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}