feat(quota): implement manual quota reset functionality with confirmation and UI updates

This commit is contained in:
LTbinglingfeng
2026-06-12 17:26:23 +08:00
Unverified
parent 67c4041939
commit f3959a0b19
11 changed files with 347 additions and 29 deletions
+4 -1
View File
@@ -56,6 +56,7 @@ export function QuotaProgressBar({
export interface QuotaRenderHelpers {
styles: typeof styles;
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
resetQuotaAction?: ReactNode;
}
interface QuotaCardProps<TState extends QuotaStatusState> {
@@ -68,6 +69,7 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
defaultType: string;
canRefresh?: boolean;
onRefresh?: () => void;
resetQuotaAction?: ReactNode;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
@@ -81,6 +83,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
defaultType,
canRefresh = false,
onRefresh,
resetQuotaAction,
renderQuotaItems
}: QuotaCardProps<TState>) {
const { t } = useTranslation();
@@ -146,7 +149,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
})}
</div>
) : quota ? (
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
renderQuotaItems(quota, t, { styles, QuotaProgressBar, resetQuotaAction })
) : (
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
)}
+87 -15
View File
@@ -107,6 +107,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
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<string, TState>
>;
@@ -115,6 +116,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
const [viewMode, setViewMode] = useState<ViewMode>('paged');
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
const [resettingQuotaName, setResettingQuotaName] = useState<string | null>(null);
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
@@ -237,6 +239,52 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
[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 = (
<div className={styles.titleWrapper}>
<span>{t(`${config.i18nPrefix}.title`)}</span>
@@ -307,21 +355,45 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
) : (
<>
<div ref={gridRef} className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardIdleMessageKey={config.cardIdleMessageKey}
cardClassName={config.cardClassName}
defaultType={config.type}
canRefresh={!disabled && !item.disabled}
onRefresh={() => 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 ? (
<Button
type="button"
variant="secondary"
size="sm"
className={styles.quotaResetCreditButton}
onClick={() => resetQuotaForFile(item)}
disabled={!canUseQuotaAction || isResettingQuota}
loading={isResettingQuota}
title={t('codex_quota.reset_button')}
aria-label={t('codex_quota.reset_button')}
>
{!isResettingQuota && <IconRefreshCw size={14} />}
{t('codex_quota.reset_button')}
</Button>
) : undefined;
return (
<QuotaCard
key={item.name}
item={item}
quota={itemQuota}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardIdleMessageKey={config.cardIdleMessageKey}
cardClassName={config.cardClassName}
defaultType={config.type}
canRefresh={canUseQuotaAction && !isResettingQuota}
onRefresh={() => void refreshQuotaForFile(item)}
resetQuotaAction={resetQuotaAction}
renderQuotaItems={config.renderQuotaItems}
/>
);
})}
</div>
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
+117 -10
View File
@@ -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<TState, TData> {
cardIdleMessageKey?: string;
filterFn: (file: AuthFileItem) => boolean;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
resetQuota?: (file: AuthFileItem, t: TFunction) => Promise<TData>;
storeSelector: (state: QuotaStore) => Record<string, TState>;
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<void> => {
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<string, string> = {
...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',
@@ -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<unknown>;
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<string, unknown>) => ({
...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<unknown>;
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 ? (
<Button
type="button"
variant="secondary"
size="sm"
className={styles.quotaResetCreditButton}
onClick={() => resetQuotaForFile()}
disabled={!canResetQuota}
loading={resettingQuota}
title={t('codex_quota.reset_button')}
aria-label={t('codex_quota.reset_button')}
>
{!resettingQuota && <IconRefreshCw size={14} />}
{t('codex_quota.reset_button')}
</Button>
) : undefined;
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
@@ -133,7 +203,11 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
})}
</div>
) : quota ? (
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
(config.renderQuotaItems(quota, t, {
styles,
QuotaProgressBar,
resetQuotaAction,
}) as ReactNode)
) : (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
)}
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+24
View File
@@ -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;
+7
View File
@@ -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;
+8
View File
@@ -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;
}
+2
View File
@@ -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$',