From f3959a0b1989e766ca0d67cb0bb1057c784f2bda Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 12 Jun 2026 17:26:23 +0800 Subject: [PATCH] feat(quota): implement manual quota reset functionality with confirmation and UI updates --- src/components/quota/QuotaCard.tsx | 5 +- src/components/quota/QuotaSection.tsx | 102 +++++++++++--- src/components/quota/quotaConfigs.ts | 127 ++++++++++++++++-- .../components/AuthFileQuotaSection.tsx | 80 ++++++++++- src/i18n/locales/en.json | 7 + src/i18n/locales/zh-CN.json | 7 + src/i18n/locales/zh-TW.json | 7 + src/pages/AuthFilesPage.module.scss | 24 ++++ src/pages/QuotaPage.module.scss | 7 + src/types/quota.ts | 8 ++ src/utils/quota/constants.ts | 2 + 11 files changed, 347 insertions(+), 29 deletions(-) diff --git a/src/components/quota/QuotaCard.tsx b/src/components/quota/QuotaCard.tsx index 48664fa..c04d4e3 100644 --- a/src/components/quota/QuotaCard.tsx +++ b/src/components/quota/QuotaCard.tsx @@ -56,6 +56,7 @@ export function QuotaProgressBar({ export interface QuotaRenderHelpers { styles: typeof styles; QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement; + resetQuotaAction?: ReactNode; } interface QuotaCardProps { @@ -68,6 +69,7 @@ interface QuotaCardProps { defaultType: string; canRefresh?: boolean; onRefresh?: () => void; + resetQuotaAction?: ReactNode; renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; } @@ -81,6 +83,7 @@ export function QuotaCard({ defaultType, canRefresh = false, onRefresh, + resetQuotaAction, renderQuotaItems }: QuotaCardProps) { const { t } = useTranslation(); @@ -146,7 +149,7 @@ export function QuotaCard({ })} ) : quota ? ( - renderQuotaItems(quota, t, { styles, QuotaProgressBar }) + renderQuotaItems(quota, t, { styles, QuotaProgressBar, resetQuotaAction }) ) : (
{t(idleMessageKey)}
)} diff --git a/src/components/quota/QuotaSection.tsx b/src/components/quota/QuotaSection.tsx index c424381..4fc983f 100644 --- a/src/components/quota/QuotaSection.tsx +++ b/src/components/quota/QuotaSection.tsx @@ -107,6 +107,7 @@ export function QuotaSection({ const { t } = useTranslation(); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); const showNotification = useNotificationStore((state) => state.showNotification); + const showConfirmation = useNotificationStore((state) => state.showConfirmation); const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter< Record >; @@ -115,6 +116,7 @@ export function QuotaSection({ const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS const [viewMode, setViewMode] = useState('paged'); const [showTooManyWarning, setShowTooManyWarning] = useState(false); + const [resettingQuotaName, setResettingQuotaName] = useState(null); const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [ files, @@ -237,6 +239,52 @@ export function QuotaSection({ [config, disabled, quota, setQuota, showNotification, t] ); + const resetQuotaForFile = useCallback( + (file: AuthFileItem) => { + const resetQuota = config.resetQuota; + if (!resetQuota) return; + if (disabled || file.disabled) return; + if (quota[file.name]?.status === 'loading') return; + if (resettingQuotaName === file.name) return; + + showConfirmation({ + title: t('codex_quota.reset_confirm_title'), + message: t('codex_quota.reset_confirm_message', { name: file.name }), + confirmText: t('codex_quota.reset_confirm_button'), + variant: 'primary', + onConfirm: async () => { + setResettingQuotaName(file.name); + try { + const data = await resetQuota(file, t); + setQuota((prev) => ({ + ...prev, + [file.name]: config.buildSuccessState(data) + })); + showNotification(t('codex_quota.reset_success', { name: file.name }), 'success'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + showNotification( + t('codex_quota.reset_failed', { name: file.name, message }), + 'error' + ); + } finally { + setResettingQuotaName((current) => (current === file.name ? null : current)); + } + } + }); + }, + [ + config, + disabled, + quota, + resettingQuotaName, + setQuota, + showConfirmation, + showNotification, + t + ] + ); + const titleNode = (
{t(`${config.i18nPrefix}.title`)} @@ -307,21 +355,45 @@ export function QuotaSection({ ) : ( <>
- {pageItems.map((item) => ( - void refreshQuotaForFile(item)} - renderQuotaItems={config.renderQuotaItems} - /> - ))} + {pageItems.map((item) => { + const itemQuota = quota[item.name]; + const isResettingQuota = resettingQuotaName === item.name; + const canUseQuotaAction = + !disabled && !item.disabled && itemQuota?.status !== 'loading'; + const resetQuotaAction = config.resetQuota ? ( + + ) : undefined; + + return ( + void refreshQuotaForFile(item)} + resetQuotaAction={resetQuotaAction} + renderQuotaItems={config.renderQuotaItems} + /> + ); + })}
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 3672c52..7b61e51 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -41,6 +41,7 @@ import { CLAUDE_USAGE_URL, CLAUDE_REQUEST_HEADERS, CLAUDE_USAGE_WINDOW_KEYS, + CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL, CODEX_USAGE_URL, CODEX_REQUEST_HEADERS, GEMINI_CLI_QUOTA_URL, @@ -128,6 +129,7 @@ export interface QuotaConfig { cardIdleMessageKey?: string; filterFn: (file: AuthFileItem) => boolean; fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; storeSelector: (state: QuotaStore) => Record; storeSetter: keyof QuotaStore; buildLoadingState: () => TState; @@ -429,6 +431,7 @@ const fetchCodexQuota = async ( ): Promise<{ planType: string | null; subscriptionActiveUntil: string | number | null; + rateLimitResetCreditsAvailableCount: number | null; windows: CodexQuotaWindow[]; }> => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; @@ -465,8 +468,75 @@ const fetchCodexQuota = async ( } const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); + const resetCredits = payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits ?? null; + const rateLimitResetCreditsAvailableCount = normalizeNumberValue( + resetCredits?.available_count ?? resetCredits?.availableCount + ); const windows = buildCodexQuotaWindows(payload, t); - return { planType: planTypeFromUsage ?? planTypeFromFile, subscriptionActiveUntil, windows }; + return { + planType: planTypeFromUsage ?? planTypeFromFile, + subscriptionActiveUntil, + rateLimitResetCreditsAvailableCount, + windows, + }; +}; + +const createCodexRedeemRequestId = (): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { + const value = Math.floor(Math.random() * 16); + const segment = char === 'x' ? value : (value & 0x3) | 0x8; + return segment.toString(16); + }); +}; + +const consumeCodexRateLimitResetCredit = async ( + file: AuthFileItem, + t: TFunction +): Promise => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndex(rawAuthIndex); + if (!authIndex) { + throw new Error(t('codex_quota.missing_auth_index')); + } + + const accountId = resolveCodexChatgptAccountId(file); + const requestHeader: Record = { + ...CODEX_REQUEST_HEADERS, + }; + if (accountId) { + requestHeader['Chatgpt-Account-Id'] = accountId; + } + + const result = await apiCallApi.request({ + authIndex, + method: 'POST', + url: CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL, + header: requestHeader, + data: JSON.stringify({ + redeem_request_id: createCodexRedeemRequestId(), + }), + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + } +}; + +const resetCodexQuota = async ( + file: AuthFileItem, + t: TFunction +): Promise<{ + planType: string | null; + subscriptionActiveUntil: string | number | null; + rateLimitResetCreditsAvailableCount: number | null; + windows: CodexQuotaWindow[]; +}> => { + await consumeCodexRateLimitResetCredit(file, t); + return fetchCodexQuota(file, t); }; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; @@ -768,6 +838,8 @@ const renderCodexItems = ( const windows = quota.windows ?? []; const planType = quota.planType ?? null; const subscriptionActiveUntil = quota.subscriptionActiveUntil ?? null; + const rateLimitResetCreditsAvailableCount = + quota.rateLimitResetCreditsAvailableCount ?? null; const getPlanLabel = (pt?: string | null): string | null => { const normalized = normalizePlanType(pt); @@ -785,11 +857,24 @@ const renderCodexItems = ( const planLabel = getPlanLabel(planType); const isPremiumPlan = PREMIUM_CODEX_PLAN_TYPES.has(normalizePlanType(planType) ?? ''); const expiryLabel = subscriptionActiveUntil ? formatDateTimeValue(subscriptionActiveUntil) : ''; + const resetQuotaAction = + rateLimitResetCreditsAvailableCount !== null && rateLimitResetCreditsAvailableCount > 0 + ? helpers.resetQuotaAction + : null; const nodes: ReactNode[] = []; - if (planLabel || expiryLabel) { + if (planLabel || expiryLabel || rateLimitResetCreditsAvailableCount !== null) { const planValueClass = isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue; const planNodes: ReactNode[] = []; + const appendSeparator = (key: string) => { + if (planNodes.length === 0) return; + planNodes.push( + h('span', { + key, + className: styleMap.codexPlanSeparator, + }) + ); + }; if (planLabel) { planNodes.push( @@ -803,14 +888,7 @@ const renderCodexItems = ( } if (expiryLabel) { - if (planNodes.length > 0) { - planNodes.push( - h('span', { - key: 'subscription-expiry-separator', - className: styleMap.codexPlanSeparator, - }) - ); - } + appendSeparator('subscription-expiry-separator'); planNodes.push( h( 'span', @@ -825,9 +903,35 @@ const renderCodexItems = ( ); } + if (rateLimitResetCreditsAvailableCount !== null) { + appendSeparator('reset-credits-separator'); + planNodes.push( + h( + 'span', + { key: 'reset-credits-label', className: styleMap.codexPlanLabel }, + t('codex_quota.reset_credits_label') + ), + h( + 'span', + { key: 'reset-credits-value', className: styleMap.codexPlanValue }, + rateLimitResetCreditsAvailableCount.toString() + ) + ); + } + nodes.push(h('div', { key: 'plan', className: styleMap.codexPlan }, ...planNodes)); } + if (resetQuotaAction) { + nodes.push( + h( + 'div', + { key: 'reset-credits-action', className: styleMap.quotaInlineActions }, + resetQuotaAction + ) + ); + } + if (windows.length === 0) { nodes.push( h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows')) @@ -1240,6 +1344,7 @@ export const CODEX_CONFIG: QuotaConfig< { planType: string | null; subscriptionActiveUntil: string | number | null; + rateLimitResetCreditsAvailableCount: number | null; windows: CodexQuotaWindow[]; } > = { @@ -1248,6 +1353,7 @@ export const CODEX_CONFIG: QuotaConfig< cardIdleMessageKey: 'quota_management.card_idle_hint', filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file), fetchQuota: fetchCodexQuota, + resetQuota: resetCodexQuota, storeSelector: (state) => state.codexQuota, storeSetter: 'setCodexQuota', buildLoadingState: () => ({ status: 'loading', windows: [] }), @@ -1256,6 +1362,7 @@ export const CODEX_CONFIG: QuotaConfig< windows: data.windows, planType: data.planType, subscriptionActiveUntil: data.subscriptionActiveUntil, + rateLimitResetCreditsAvailableCount: data.rateLimitResetCreditsAvailableCount, }), buildErrorState: (message, status) => ({ status: 'error', diff --git a/src/features/authFiles/components/AuthFileQuotaSection.tsx b/src/features/authFiles/components/AuthFileQuotaSection.tsx index 2a0fbeb..804095b 100644 --- a/src/features/authFiles/components/AuthFileQuotaSection.tsx +++ b/src/features/authFiles/components/AuthFileQuotaSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, type ReactNode } from 'react'; +import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { @@ -17,6 +17,8 @@ import { resolveQuotaErrorMessage, type QuotaProviderType, } from '@/features/authFiles/constants'; +import { Button } from '@/components/ui/Button'; +import { IconRefreshCw } from '@/components/ui/icons'; import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar'; import styles from '@/pages/AuthFilesPage.module.scss'; @@ -41,6 +43,8 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) { const { file, quotaType, disableControls } = props; const { t } = useTranslation(); const showNotification = useNotificationStore((state) => state.showNotification); + const showConfirmation = useNotificationStore((state) => state.showConfirmation); + const [resettingQuota, setResettingQuota] = useState(false); const quota = useQuotaStore((state) => { if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState; @@ -100,13 +104,79 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) { } }, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]); + const resetQuotaForFile = useCallback(() => { + if (disableControls) return; + if (isRuntimeOnlyAuthFile(file)) return; + if (file.disabled) return; + if (quota?.status === 'loading') return; + if (resettingQuota) return; + + const config = getQuotaConfig(quotaType) as unknown as { + resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; + buildSuccessState: (data: unknown) => unknown; + }; + const resetQuota = config.resetQuota; + if (!resetQuota) return; + + showConfirmation({ + title: t('codex_quota.reset_confirm_title'), + message: t('codex_quota.reset_confirm_message', { name: file.name }), + confirmText: t('codex_quota.reset_confirm_button'), + variant: 'primary', + onConfirm: async () => { + setResettingQuota(true); + try { + const data = await resetQuota(file, t); + updateQuotaState((prev: Record) => ({ + ...prev, + [file.name]: config.buildSuccessState(data), + })); + showNotification(t('codex_quota.reset_success', { name: file.name }), 'success'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + showNotification(t('codex_quota.reset_failed', { name: file.name, message }), 'error'); + } finally { + setResettingQuota(false); + } + }, + }); + }, [ + disableControls, + file, + quota?.status, + quotaType, + resettingQuota, + showConfirmation, + showNotification, + t, + updateQuotaState, + ]); + const config = getQuotaConfig(quotaType) as unknown as { i18nPrefix: string; + resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown; }; const quotaStatus = quota?.status ?? 'idle'; - const canRefreshQuota = !disableControls && !file.disabled; + const canRefreshQuota = !disableControls && !file.disabled && !resettingQuota; + const canResetQuota = canRefreshQuota && quotaStatus !== 'loading'; + const resetQuotaAction = config.resetQuota ? ( + + ) : undefined; const quotaErrorMessage = resolveQuotaErrorMessage( t, quota?.errorStatus, @@ -133,7 +203,11 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) { })}
) : quota ? ( - (config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode) + (config.renderQuotaItems(quota, t, { + styles, + QuotaProgressBar, + resetQuotaAction, + }) as ReactNode) ) : (
{t(`${config.i18nPrefix}.idle`)}
)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5c444b7..ea4d67c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -437,6 +437,13 @@ "additional_secondary_window": "{{name}} weekly limit", "plan_label": "Plan", "expires_label": "Expires", + "reset_credits_label": "Manual resets", + "reset_button": "Reset quota", + "reset_confirm_title": "Reset Codex quota", + "reset_confirm_message": "This will consume 1 manual reset to reset the Codex quota for \"{{name}}\". Continue?", + "reset_confirm_button": "Reset quota", + "reset_success": "Reset Codex quota for \"{{name}}\"", + "reset_failed": "Failed to reset Codex quota for \"{{name}}\": {{message}}", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5c7586d..8447b2c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -437,6 +437,13 @@ "additional_secondary_window": "{{name}} 周限额", "plan_label": "套餐", "expires_label": "到期时间", + "reset_credits_label": "主动重置次数", + "reset_button": "重置额度", + "reset_confirm_title": "重置 Codex 额度", + "reset_confirm_message": "将消耗 1 次主动重置次数来重置 \"{{name}}\" 的 Codex 额度。是否继续?", + "reset_confirm_button": "确认重置", + "reset_success": "已重置 \"{{name}}\" 的 Codex 额度", + "reset_failed": "重置 \"{{name}}\" 的 Codex 额度失败:{{message}}", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5bfe0e3..9fae611 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -437,6 +437,13 @@ "additional_secondary_window": "{{name}} 週限額", "plan_label": "方案", "expires_label": "到期時間", + "reset_credits_label": "主動重置次數", + "reset_button": "重置配額", + "reset_confirm_title": "重置 Codex 配額", + "reset_confirm_message": "將消耗 1 次主動重置次數來重置「{{name}}」的 Codex 配額。是否繼續?", + "reset_confirm_button": "確認重置", + "reset_success": "已重置「{{name}}」的 Codex 配額", + "reset_failed": "重置「{{name}}」的 Codex 配額失敗:{{message}}", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 59fa5ad..c9446a4 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -640,6 +640,24 @@ } } +.quotaInlineActions { + display: flex; + justify-content: flex-start; + padding-top: $spacing-xs; +} + +.quotaResetCreditButton:global(.btn.btn-sm) { + border-radius: 999px; + padding-inline: 12px; + + > span { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } +} + .quotaError { font-size: 12px; color: var(--danger-color); @@ -676,6 +694,12 @@ text-transform: capitalize; } +.codexPlanSeparator { + width: 1px; + height: 12px; + background-color: var(--border-color); +} + .premiumPlanValue { display: inline-flex; align-items: center; diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index a6d3967..01ea345 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -392,12 +392,19 @@ } } +.quotaInlineActions { + display: flex; + justify-content: flex-start; + padding-top: $spacing-xs; +} + .quotaCardActions { display: flex; justify-content: flex-end; padding-top: $spacing-xs; } +.quotaResetCreditButton:global(.btn.btn-sm), .quotaRefreshButton:global(.btn.btn-sm) { border-radius: 999px; padding-inline: 12px; diff --git a/src/types/quota.ts b/src/types/quota.ts index 57145b6..982069c 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -119,6 +119,11 @@ export interface CodexAdditionalRateLimit { rateLimit?: CodexRateLimitInfo | null; } +export interface CodexRateLimitResetCredits { + available_count?: number | string; + availableCount?: number | string; +} + export interface CodexUsagePayload { plan_type?: string; planType?: string; @@ -128,6 +133,8 @@ export interface CodexUsagePayload { codeReviewRateLimit?: CodexRateLimitInfo | null; additional_rate_limits?: CodexAdditionalRateLimit[] | null; additionalRateLimits?: CodexAdditionalRateLimit[] | null; + rate_limit_reset_credits?: CodexRateLimitResetCredits | null; + rateLimitResetCredits?: CodexRateLimitResetCredits | null; } // Claude API payload types @@ -243,6 +250,7 @@ export interface CodexQuotaState { windows: CodexQuotaWindow[]; planType?: string | null; subscriptionActiveUntil?: string | number | null; + rateLimitResetCreditsAvailableCount?: number | null; error?: string; errorStatus?: number; } diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index 707f543..43643d4 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -193,6 +193,8 @@ export const CLAUDE_USAGE_WINDOW_KEYS = [ // Codex API configuration export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; +export const CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL = + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume'; export const CODEX_REQUEST_HEADERS = { Authorization: 'Bearer $TOKEN$',