refactor(quota): consolidate quota sections into config-driven components

This commit is contained in:
Supra4E8C
2026-01-02 17:14:40 +08:00
parent 47f0042bf0
commit 82bf1806ed
19 changed files with 1073 additions and 1604 deletions

View File

@@ -1,114 +0,0 @@
/**
* Individual Antigravity quota card component.
*/
import { useTranslation } from 'react-i18next';
import type {
AntigravityQuotaState,
AuthFileItem,
ResolvedTheme,
ThemeColors
} from '@/types';
import { TYPE_COLORS, formatQuotaResetTime } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
interface AntigravityCardProps {
item: AuthFileItem;
quota?: AntigravityQuotaState;
resolvedTheme: ResolvedTheme;
getQuotaErrorMessage: (status: number | undefined, fallback: string) => string;
}
export function AntigravityCard({
item,
quota,
resolvedTheme,
getQuotaErrorMessage
}: AntigravityCardProps) {
const { t } = useTranslation();
const displayType = item.type || item.provider || 'antigravity';
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const quotaGroups = quota?.groups ?? [];
const quotaErrorMessage = getQuotaErrorMessage(
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
return (
<div className={`${styles.fileCard} ${styles.antigravityCard}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t('antigravity_quota.loading')}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t('antigravity_quota.idle')}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t('antigravity_quota.load_failed', {
message: quotaErrorMessage
})}
</div>
) : quotaGroups.length === 0 ? (
<div className={styles.quotaMessage}>{t('antigravity_quota.empty_models')}</div>
) : (
quotaGroups.map((group) => {
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
const percent = Math.round(clamped * 100);
const resetLabel = formatQuotaResetTime(group.resetTime);
const quotaBarClass =
percent >= 60
? styles.quotaBarFillHigh
: percent >= 20
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
return (
<div key={group.id} className={styles.quotaRow}>
<div className={styles.quotaRowHeader}>
<span className={styles.quotaModel} title={group.models.join(', ')}>
{group.label}
</span>
<div className={styles.quotaMeta}>
<span className={styles.quotaPercent}>{percent}%</span>
<span className={styles.quotaReset}>{resetLabel}</span>
</div>
</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${quotaBarClass}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,182 +0,0 @@
/**
* Antigravity quota section component.
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AntigravityQuotaState, AuthFileItem, ResolvedTheme } from '@/types';
import { isAntigravityFile } from '@/utils/quota';
import { useQuotaSection } from '../hooks/useQuotaSection';
import { useAntigravityQuota } from './useAntigravityQuota';
import { AntigravityCard } from './AntigravityCard';
import styles from '@/pages/QuotaPage.module.scss';
interface AntigravitySectionProps {
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function AntigravitySection({ files, loading, disabled }: AntigravitySectionProps) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const antigravityFiles = useMemo(
() => files.filter((file) => isAntigravityFile(file)),
[files]
);
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
loadingScope,
setLoading
} = useQuotaSection({ items: antigravityFiles });
const { quota, loadQuota } = useAntigravityQuota();
const handleRefreshPage = useCallback(() => {
loadQuota(pageItems, 'page', setLoading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => {
loadQuota(antigravityFiles, 'all', setLoading);
}, [loadQuota, antigravityFiles, setLoading]);
const getQuotaErrorMessage = useCallback(
(status: number | undefined, fallback: string) => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
},
[t]
);
// Sync quota state when files change
useEffect(() => {
if (loading) return;
if (antigravityFiles.length === 0) {
setAntigravityQuota({});
return;
}
setAntigravityQuota((prev) => {
const nextState: Record<string, AntigravityQuotaState> = {};
antigravityFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [antigravityFiles, loading, setAntigravityQuota]);
return (
<Card
title={t('antigravity_quota.title')}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
>
{t('antigravity_quota.refresh_button')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || antigravityFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t('antigravity_quota.fetch_all')}
</Button>
</div>
}
>
{antigravityFiles.length === 0 ? (
<EmptyState
title={t('antigravity_quota.empty_title')}
description={t('antigravity_quota.empty_desc')}
/>
) : (
<>
<div className={styles.antigravityControls}>
<div className={styles.antigravityControl}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={styles.antigravityControl}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{antigravityFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={styles.antigravityGrid}>
{pageItems.map((item) => (
<AntigravityCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
getQuotaErrorMessage={getQuotaErrorMessage}
/>
))}
</div>
{antigravityFiles.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: antigravityFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
);
}

View File

@@ -1,3 +0,0 @@
export { AntigravitySection } from './AntigravitySection';
export { AntigravityCard } from './AntigravityCard';
export { useAntigravityQuota } from './useAntigravityQuota';

View File

@@ -1,176 +0,0 @@
/**
* Hook for Antigravity quota data fetching and management.
*/
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { useQuotaStore } from '@/stores';
import type { AntigravityQuotaGroup, AntigravityModelsPayload, AuthFileItem } from '@/types';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
normalizeAuthIndexValue,
parseAntigravityPayload,
buildAntigravityQuotaGroups,
createStatusError,
getStatusFromError
} from '@/utils/quota';
interface UseAntigravityQuotaReturn {
quota: Record<string, import('@/types').AntigravityQuotaState>;
loadQuota: (
targets: AuthFileItem[],
scope: 'page' | 'all',
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
) => Promise<void>;
}
export function useAntigravityQuota(): UseAntigravityQuotaReturn {
const { t } = useTranslation();
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const fetchQuota = useCallback(
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}'
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}
if (hadSuccess) {
return [];
}
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
},
[t]
);
const loadQuota = useCallback(
async (
targets: AuthFileItem[],
scope: 'page' | 'all',
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
) => {
if (loadingRef.current) return;
loadingRef.current = true;
const requestId = ++requestIdRef.current;
setLoading(true, scope);
try {
if (targets.length === 0) return;
setAntigravityQuota((prev) => {
const nextState = { ...prev };
targets.forEach((file) => {
nextState[file.name] = { status: 'loading', groups: [] };
});
return nextState;
});
const results = await Promise.all(
targets.map(async (file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
return {
name: file.name,
status: 'error' as const,
error: t('antigravity_quota.missing_auth_index')
};
}
try {
const groups = await fetchQuota(authIndex);
return { name: file.name, status: 'success' as const, groups };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const errorStatus = getStatusFromError(err);
return { name: file.name, status: 'error' as const, error: message, errorStatus };
}
})
);
if (requestId !== requestIdRef.current) return;
setAntigravityQuota((prev) => {
const nextState = { ...prev };
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = {
status: 'success',
groups: result.groups
};
} else {
nextState[result.name] = {
status: 'error',
groups: [],
error: result.error,
errorStatus: result.errorStatus
};
}
});
return nextState;
});
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
loadingRef.current = false;
}
}
},
[fetchQuota, setAntigravityQuota, t]
);
return { quota: antigravityQuota, loadQuota };
}

View File

@@ -1,144 +0,0 @@
/**
* Individual Codex quota card component.
*/
import { useTranslation } from 'react-i18next';
import type {
CodexQuotaState,
AuthFileItem,
ResolvedTheme,
ThemeColors
} from '@/types';
import { TYPE_COLORS, normalizePlanType } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
interface CodexCardProps {
item: AuthFileItem;
quota?: CodexQuotaState;
resolvedTheme: ResolvedTheme;
getQuotaErrorMessage: (status: number | undefined, fallback: string) => string;
}
export function CodexCard({
item,
quota,
resolvedTheme,
getQuotaErrorMessage
}: CodexCardProps) {
const { t } = useTranslation();
const displayType = item.type || item.provider || 'codex';
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const windows = quota?.windows ?? [];
const planType = quota?.planType ?? null;
const quotaErrorMessage = getQuotaErrorMessage(
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
const getPlanLabel = (pt?: string | null): string | null => {
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'plus') return t('codex_quota.plan_plus');
if (normalized === 'team') return t('codex_quota.plan_team');
if (normalized === 'free') return t('codex_quota.plan_free');
return pt || normalized;
};
const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
return (
<div className={`${styles.fileCard} ${styles.codexCard}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t('codex_quota.loading')}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t('codex_quota.idle')}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t('codex_quota.load_failed', {
message: quotaErrorMessage
})}
</div>
) : (
<>
{planLabel && (
<div className={styles.codexPlan}>
<span className={styles.codexPlanLabel}>{t('codex_quota.plan_label')}</span>
<span className={styles.codexPlanValue}>{planLabel}</span>
</div>
)}
{isFreePlan ? (
<div className={styles.quotaWarning}>{t('codex_quota.no_access')}</div>
) : windows.length === 0 ? (
<div className={styles.quotaMessage}>{t('codex_quota.empty_windows')}</div>
) : (
windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining =
clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const quotaBarClass =
remaining === null
? styles.quotaBarFillMedium
: remaining >= 80
? styles.quotaBarFillHigh
: remaining >= 50
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return (
<div key={window.id} className={styles.quotaRow}>
<div className={styles.quotaRowHeader}>
<span className={styles.quotaModel}>{windowLabel}</span>
<div className={styles.quotaMeta}>
<span className={styles.quotaPercent}>{percentLabel}</span>
<span className={styles.quotaReset}>{window.resetLabel}</span>
</div>
</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${quotaBarClass}`}
style={{ width: `${Math.round(remaining ?? 0)}%` }}
/>
</div>
</div>
);
})
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,182 +0,0 @@
/**
* Codex quota section component.
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { CodexQuotaState, AuthFileItem, ResolvedTheme } from '@/types';
import { isCodexFile } from '@/utils/quota';
import { useQuotaSection } from '../hooks/useQuotaSection';
import { useCodexQuota } from './useCodexQuota';
import { CodexCard } from './CodexCard';
import styles from '@/pages/QuotaPage.module.scss';
interface CodexSectionProps {
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function CodexSection({ files, loading, disabled }: CodexSectionProps) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
const codexFiles = useMemo(
() => files.filter((file) => isCodexFile(file)),
[files]
);
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
loadingScope,
setLoading
} = useQuotaSection({ items: codexFiles });
const { quota, loadQuota } = useCodexQuota();
const handleRefreshPage = useCallback(() => {
loadQuota(pageItems, 'page', setLoading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => {
loadQuota(codexFiles, 'all', setLoading);
}, [loadQuota, codexFiles, setLoading]);
const getQuotaErrorMessage = useCallback(
(status: number | undefined, fallback: string) => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
},
[t]
);
// Sync quota state when files change
useEffect(() => {
if (loading) return;
if (codexFiles.length === 0) {
setCodexQuota({});
return;
}
setCodexQuota((prev) => {
const nextState: Record<string, CodexQuotaState> = {};
codexFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [codexFiles, loading, setCodexQuota]);
return (
<Card
title={t('codex_quota.title')}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
>
{t('codex_quota.refresh_button')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || codexFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t('codex_quota.fetch_all')}
</Button>
</div>
}
>
{codexFiles.length === 0 ? (
<EmptyState
title={t('codex_quota.empty_title')}
description={t('codex_quota.empty_desc')}
/>
) : (
<>
<div className={styles.codexControls}>
<div className={styles.codexControl}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={styles.codexControl}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{codexFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={styles.codexGrid}>
{pageItems.map((item) => (
<CodexCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
getQuotaErrorMessage={getQuotaErrorMessage}
/>
))}
</div>
{codexFiles.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: codexFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
);
}

View File

@@ -1,3 +0,0 @@
export { CodexSection } from './CodexSection';
export { CodexCard } from './CodexCard';
export { useCodexQuota } from './useCodexQuota';

View File

@@ -1,207 +0,0 @@
/**
* Hook for Codex quota data fetching and management.
*/
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { useQuotaStore } from '@/stores';
import type { AuthFileItem, CodexQuotaWindow, CodexUsagePayload } from '@/types';
import {
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeNumberValue,
normalizePlanType,
parseCodexUsagePayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
formatCodexResetLabel,
createStatusError,
getStatusFromError
} from '@/utils/quota';
interface UseCodexQuotaReturn {
quota: Record<string, import('@/types').CodexQuotaState>;
loadQuota: (
targets: AuthFileItem[],
scope: 'page' | 'all',
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
) => Promise<void>;
}
export function useCodexQuota(): UseCodexQuotaReturn {
const { t } = useTranslation();
const codexQuota = useQuotaStore((state) => state.codexQuota);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const buildQuotaWindows = useCallback(
(payload: CodexUsagePayload): CodexQuotaWindow[] => {
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit =
payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
window?: import('@/types').CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
) => {
if (!window) return;
const resetLabel = formatCodexResetLabel(window);
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
const isLimitReached = Boolean(limitReached) || allowed === false;
const usedPercent =
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel
});
};
addWindow(
'primary',
'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'secondary',
'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'code-review',
'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
);
return windows;
},
[t]
);
const fetchQuota = useCallback(
async (file: AuthFileItem): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('codex_quota.missing_auth_index'));
}
const planTypeFromFile = resolveCodexPlanType(file);
const accountId = resolveCodexChatgptAccountId(file);
if (!accountId) {
throw new Error(t('codex_quota.missing_account_id'));
}
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('codex_quota.empty_windows'));
}
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
const windows = buildQuotaWindows(payload);
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
},
[buildQuotaWindows, t]
);
const loadQuota = useCallback(
async (
targets: AuthFileItem[],
scope: 'page' | 'all',
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
) => {
if (loadingRef.current) return;
loadingRef.current = true;
const requestId = ++requestIdRef.current;
setLoading(true, scope);
try {
if (targets.length === 0) return;
setCodexQuota((prev) => {
const nextState = { ...prev };
targets.forEach((file) => {
nextState[file.name] = { status: 'loading', windows: [] };
});
return nextState;
});
const results = await Promise.all(
targets.map(async (file) => {
try {
const { planType, windows } = await fetchQuota(file);
return { name: file.name, status: 'success' as const, planType, windows };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const errorStatus = getStatusFromError(err);
return { name: file.name, status: 'error' as const, error: message, errorStatus };
}
})
);
if (requestId !== requestIdRef.current) return;
setCodexQuota((prev) => {
const nextState = { ...prev };
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = {
status: 'success',
windows: result.windows,
planType: result.planType
};
} else {
nextState[result.name] = {
status: 'error',
windows: [],
error: result.error,
errorStatus: result.errorStatus
};
}
});
return nextState;
});
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
loadingRef.current = false;
}
}
},
[fetchQuota, setCodexQuota, t]
);
return { quota: codexQuota, loadQuota };
}

View File

@@ -1,135 +0,0 @@
/**
* Individual Gemini CLI quota card component.
*/
import { useTranslation } from 'react-i18next';
import type {
GeminiCliQuotaState,
AuthFileItem,
ResolvedTheme,
ThemeColors
} from '@/types';
import { TYPE_COLORS, formatQuotaResetTime } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
interface GeminiCliCardProps {
item: AuthFileItem;
quota?: GeminiCliQuotaState;
resolvedTheme: ResolvedTheme;
getQuotaErrorMessage: (status: number | undefined, fallback: string) => string;
}
export function GeminiCliCard({
item,
quota,
resolvedTheme,
getQuotaErrorMessage
}: GeminiCliCardProps) {
const { t } = useTranslation();
const displayType = item.type || item.provider || 'gemini-cli';
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const buckets = quota?.buckets ?? [];
const quotaErrorMessage = getQuotaErrorMessage(
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
return (
<div className={`${styles.fileCard} ${styles.geminiCliCard}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t('gemini_cli_quota.loading')}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t('gemini_cli_quota.idle')}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t('gemini_cli_quota.load_failed', {
message: quotaErrorMessage
})}
</div>
) : buckets.length === 0 ? (
<div className={styles.quotaMessage}>{t('gemini_cli_quota.empty_buckets')}</div>
) : (
buckets.map((bucket) => {
const fraction = bucket.remainingFraction;
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
const percent = clamped === null ? null : Math.round(clamped * 100);
const percentLabel = percent === null ? '--' : `${percent}%`;
const resetLabel = formatQuotaResetTime(bucket.resetTime);
const remainingAmountLabel =
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0
? bucket.modelIds.join(', ')
: bucket.label;
const quotaBarClass =
percent === null
? styles.quotaBarFillMedium
: percent >= 60
? styles.quotaBarFillHigh
: percent >= 20
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
return (
<div key={bucket.id} className={styles.quotaRow}>
<div className={styles.quotaRowHeader}>
<span
className={styles.quotaModel}
title={bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase}
>
{bucket.label}
</span>
<div className={styles.quotaMeta}>
<span className={styles.quotaPercent}>{percentLabel}</span>
{remainingAmountLabel && (
<span className={styles.quotaAmount}>{remainingAmountLabel}</span>
)}
<span className={styles.quotaReset}>{resetLabel}</span>
</div>
</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${quotaBarClass}`}
style={{ width: `${percent ?? 0}%` }}
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,182 +0,0 @@
/**
* Gemini CLI quota section component.
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { GeminiCliQuotaState, AuthFileItem, ResolvedTheme } from '@/types';
import { isGeminiCliFile, isRuntimeOnlyAuthFile } from '@/utils/quota';
import { useQuotaSection } from '../hooks/useQuotaSection';
import { useGeminiCliQuota } from './useGeminiCliQuota';
import { GeminiCliCard } from './GeminiCliCard';
import styles from '@/pages/QuotaPage.module.scss';
interface GeminiCliSectionProps {
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function GeminiCliSection({ files, loading, disabled }: GeminiCliSectionProps) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
const geminiCliFiles = useMemo(
() => files.filter((file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file)),
[files]
);
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
loadingScope,
setLoading
} = useQuotaSection({ items: geminiCliFiles });
const { quota, loadQuota } = useGeminiCliQuota();
const handleRefreshPage = useCallback(() => {
loadQuota(pageItems, 'page', setLoading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => {
loadQuota(geminiCliFiles, 'all', setLoading);
}, [loadQuota, geminiCliFiles, setLoading]);
const getQuotaErrorMessage = useCallback(
(status: number | undefined, fallback: string) => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
},
[t]
);
// Sync quota state when files change
useEffect(() => {
if (loading) return;
if (geminiCliFiles.length === 0) {
setGeminiCliQuota({});
return;
}
setGeminiCliQuota((prev) => {
const nextState: Record<string, GeminiCliQuotaState> = {};
geminiCliFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [geminiCliFiles, loading, setGeminiCliQuota]);
return (
<Card
title={t('gemini_cli_quota.title')}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
>
{t('gemini_cli_quota.refresh_button')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || geminiCliFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t('gemini_cli_quota.fetch_all')}
</Button>
</div>
}
>
{geminiCliFiles.length === 0 ? (
<EmptyState
title={t('gemini_cli_quota.empty_title')}
description={t('gemini_cli_quota.empty_desc')}
/>
) : (
<>
<div className={styles.geminiCliControls}>
<div className={styles.geminiCliControl}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={styles.geminiCliControl}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{geminiCliFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={styles.geminiCliGrid}>
{pageItems.map((item) => (
<GeminiCliCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
getQuotaErrorMessage={getQuotaErrorMessage}
/>
))}
</div>
{geminiCliFiles.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: geminiCliFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
);
}

View File

@@ -1,3 +0,0 @@
export { GeminiCliSection } from './GeminiCliSection';
export { GeminiCliCard } from './GeminiCliCard';
export { useGeminiCliQuota } from './useGeminiCliQuota';

View File

@@ -1,176 +0,0 @@
/**
* Hook for Gemini CLI quota data fetching and management.
*/
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { useQuotaStore } from '@/stores';
import type {
AuthFileItem,
GeminiCliQuotaBucketState,
GeminiCliParsedBucket
} from '@/types';
import {
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeStringValue,
normalizeQuotaFraction,
normalizeNumberValue,
parseGeminiCliQuotaPayload,
resolveGeminiCliProjectId,
buildGeminiCliQuotaBuckets,
createStatusError,
getStatusFromError
} from '@/utils/quota';
interface UseGeminiCliQuotaReturn {
quota: Record<string, import('@/types').GeminiCliQuotaState>;
loadQuota: (
targets: AuthFileItem[],
scope: 'page' | 'all',
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
) => Promise<void>;
}
export function useGeminiCliQuota(): UseGeminiCliQuotaReturn {
const { t } = useTranslation();
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const fetchQuota = useCallback(
async (file: AuthFileItem): Promise<GeminiCliQuotaBucketState[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('gemini_cli_quota.missing_auth_index'));
}
const projectId = resolveGeminiCliProjectId(file);
if (!projectId) {
throw new Error(t('gemini_cli_quota.missing_project_id'));
}
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId })
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return [];
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(
bucket.remainingAmount ?? bucket.remaining_amount
);
const resetTime =
normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return {
modelId,
tokenType,
remainingFraction,
remainingAmount,
resetTime
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
},
[t]
);
const loadQuota = useCallback(
async (
targets: AuthFileItem[],
scope: 'page' | 'all',
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
) => {
if (loadingRef.current) return;
loadingRef.current = true;
const requestId = ++requestIdRef.current;
setLoading(true, scope);
try {
if (targets.length === 0) return;
setGeminiCliQuota((prev) => {
const nextState = { ...prev };
targets.forEach((file) => {
nextState[file.name] = { status: 'loading', buckets: [] };
});
return nextState;
});
const results = await Promise.all(
targets.map(async (file) => {
try {
const buckets = await fetchQuota(file);
return { name: file.name, status: 'success' as const, buckets };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const errorStatus = getStatusFromError(err);
return { name: file.name, status: 'error' as const, error: message, errorStatus };
}
})
);
if (requestId !== requestIdRef.current) return;
setGeminiCliQuota((prev) => {
const nextState = { ...prev };
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = {
status: 'success',
buckets: result.buckets
};
} else {
nextState[result.name] = {
status: 'error',
buckets: [],
error: result.error,
errorStatus: result.errorStatus
};
}
});
return nextState;
});
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
loadingRef.current = false;
}
}
},
[fetchQuota, setGeminiCliQuota, t]
);
return { quota: geminiCliQuota, loadQuota };
}

View File

@@ -0,0 +1,145 @@
/**
* Generic quota card component.
*/
import { useTranslation } from 'react-i18next';
import type { ReactElement, ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
import { TYPE_COLORS } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
export interface QuotaStatusState {
status: QuotaStatus;
error?: string;
errorStatus?: number;
}
export interface QuotaProgressBarProps {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
}
export function QuotaProgressBar({
percent,
highThreshold,
mediumThreshold
}: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${fillClass}`}
style={{ width: `${widthPercent}%` }}
/>
</div>
);
}
export interface QuotaRenderHelpers {
styles: typeof styles;
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
}
interface QuotaCardProps<TState extends QuotaStatusState> {
item: AuthFileItem;
quota?: TState;
resolvedTheme: ResolvedTheme;
i18nPrefix: string;
cardClassName: string;
defaultType: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
export function QuotaCard<TState extends QuotaStatusState>({
item,
quota,
resolvedTheme,
i18nPrefix,
cardClassName,
defaultType,
renderQuotaItems
}: QuotaCardProps<TState>) {
const { t } = useTranslation();
const displayType = item.type || item.provider || defaultType;
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
return (
<div className={`${styles.fileCard} ${cardClassName}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
) : (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
)}
</div>
</div>
);
}
const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};

View File

@@ -0,0 +1,250 @@
/**
* Generic quota section component.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
interface QuotaPaginationState<T> {
pageSize: number;
totalPages: number;
currentPage: number;
pageItems: T[];
setPageSize: (size: number) => void;
goToPrev: () => void;
goToNext: () => void;
loading: boolean;
loadingScope: 'page' | 'all' | null;
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
}
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(items.length / pageSize)),
[items.length, pageSize]
);
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(size);
setPage(1);
}, []);
const goToPrev = useCallback(() => {
setPage((prev) => Math.max(1, prev - 1));
}, []);
const goToNext = useCallback(() => {
setPage((prev) => Math.min(totalPages, prev + 1));
}, [totalPages]);
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
setLoadingState(isLoading);
setLoadingScope(isLoading ? (scope ?? null) : null);
}, []);
return {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading,
loadingScope,
setLoading
};
};
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
config: QuotaConfig<TState, TData>;
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function QuotaSection<TState extends QuotaStatusState, TData>({
config,
files,
loading,
disabled
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
config.filterFn
]);
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
loadingScope,
setLoading
} = useQuotaPagination(filteredFiles);
const { quota, loadQuota } = useQuotaLoader(config);
const handleRefreshPage = useCallback(() => {
loadQuota(pageItems, 'page', setLoading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => {
loadQuota(filteredFiles, 'all', setLoading);
}, [loadQuota, filteredFiles, setLoading]);
useEffect(() => {
if (loading) return;
if (filteredFiles.length === 0) {
setQuota({});
return;
}
setQuota((prev) => {
const nextState: Record<string, TState> = {};
filteredFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [filteredFiles, loading, setQuota]);
return (
<Card
title={t(`${config.i18nPrefix}.title`)}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
>
{t(`${config.i18nPrefix}.refresh_button`)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || filteredFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t(`${config.i18nPrefix}.fetch_all`)}
</Button>
</div>
}
>
{filteredFiles.length === 0 ? (
<EmptyState
title={t(`${config.i18nPrefix}.empty_title`)}
description={t(`${config.i18nPrefix}.empty_desc`)}
/>
) : (
<>
<div className={config.controlsClassName}>
<div className={config.controlClassName}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={config.controlClassName}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{filteredFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardClassName={config.cardClassName}
defaultType={config.type}
renderQuotaItems={config.renderQuotaItems}
/>
))}
</div>
{filteredFiles.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filteredFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
);
}

View File

@@ -1,87 +0,0 @@
/**
* Shared hook for quota section pagination and loading state management.
*/
import { useState, useMemo, useCallback } from 'react';
interface UseQuotaSectionOptions<T> {
items: T[];
defaultPageSize?: number;
}
interface UseQuotaSectionReturn<T> {
page: number;
pageSize: number;
totalPages: number;
currentPage: number;
pageItems: T[];
setPage: (page: number) => void;
setPageSize: (size: number) => void;
goToPrev: () => void;
goToNext: () => void;
loading: boolean;
loadingScope: 'page' | 'all' | null;
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
}
export function useQuotaSection<T>(
options: UseQuotaSectionOptions<T>
): UseQuotaSectionReturn<T> {
const { items, defaultPageSize = 6 } = options;
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(items.length / pageSize)),
[items.length, pageSize]
);
const currentPage = useMemo(
() => Math.min(page, totalPages),
[page, totalPages]
);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, currentPage, pageSize]);
const handleSetPageSize = useCallback((size: number) => {
setPageSizeState(size);
setPage(1);
}, []);
const goToPrev = useCallback(() => {
setPage((p) => Math.max(1, p - 1));
}, []);
const goToNext = useCallback(() => {
setPage((p) => Math.min(totalPages, p + 1));
}, [totalPages]);
const setLoading = useCallback(
(isLoading: boolean, scope?: 'page' | 'all' | null) => {
setLoadingState(isLoading);
setLoadingScope(isLoading ? (scope ?? null) : null);
},
[]
);
return {
page,
pageSize,
totalPages,
currentPage,
pageItems,
setPage,
setPageSize: handleSetPageSize,
goToPrev,
goToNext,
loading,
loadingScope,
setLoading
};
}

View File

@@ -2,7 +2,8 @@
* Quota components barrel export. * Quota components barrel export.
*/ */
export { AntigravitySection } from './AntigravitySection'; export { QuotaSection } from './QuotaSection';
export { CodexSection } from './CodexSection'; export { QuotaCard } from './QuotaCard';
export { GeminiCliSection } from './GeminiCliSection'; export { useQuotaLoader } from './useQuotaLoader';
export { useQuotaSection } from './hooks/useQuotaSection'; export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';

View File

@@ -0,0 +1,553 @@
/**
* Quota configuration definitions.
*/
import React from 'react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type {
AntigravityQuotaGroup,
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
CodexQuotaState,
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState
} from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeNumberValue,
normalizePlanType,
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
formatCodexResetLabel,
formatQuotaResetTime,
buildAntigravityQuotaGroups,
buildGeminiCliQuotaBuckets,
createStatusError,
getStatusFromError,
isAntigravityFile,
isCodexFile,
isGeminiCliFile,
isRuntimeOnlyAuthFile
} from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
}
export interface QuotaConfig<TState, TData> {
type: QuotaType;
i18nPrefix: string;
filterFn: (file: AuthFileItem) => boolean;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
storeSelector: (state: QuotaStore) => Record<string, TState>;
storeSetter: keyof QuotaStore;
buildLoadingState: () => TState;
buildSuccessState: (data: TData) => TState;
buildErrorState: (message: string, status?: number) => TState;
cardClassName: string;
controlsClassName: string;
controlClassName: string;
gridClassName: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
const fetchAntigravityQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<AntigravityQuotaGroup[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('antigravity_quota.missing_auth_index'));
}
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}'
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}
if (hadSuccess) {
return [];
}
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
};
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
) => {
if (!window) return;
const resetLabel = formatCodexResetLabel(window);
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
const isLimitReached = Boolean(limitReached) || allowed === false;
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel
});
};
addWindow(
'primary',
'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'secondary',
'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'code-review',
'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
);
return windows;
};
const fetchCodexQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('codex_quota.missing_auth_index'));
}
const planTypeFromFile = resolveCodexPlanType(file);
const accountId = resolveCodexChatgptAccountId(file);
if (!accountId) {
throw new Error(t('codex_quota.missing_account_id'));
}
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('codex_quota.empty_windows'));
}
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
const windows = buildCodexQuotaWindows(payload, t);
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
};
const fetchGeminiCliQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<GeminiCliQuotaBucketState[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('gemini_cli_quota.missing_auth_index'));
}
const projectId = resolveGeminiCliProjectId(file);
if (!projectId) {
throw new Error(t('gemini_cli_quota.missing_project_id'));
}
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId })
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return [];
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return {
modelId,
tokenType,
remainingFraction,
remainingAmount,
resetTime
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
};
const renderAntigravityItems = (
quota: AntigravityQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const groups = quota.groups ?? [];
if (groups.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
}
return groups.map((group) => {
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
const percent = Math.round(clamped * 100);
const resetLabel = formatQuotaResetTime(group.resetTime);
return h(
'div',
{ key: group.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h(
'span',
{ className: styleMap.quotaModel, title: group.models.join(', ') },
group.label
),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
const renderCodexItems = (
quota: CodexQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const planType = quota.planType ?? null;
const getPlanLabel = (pt?: string | null): string | null => {
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'plus') return t('codex_quota.plan_plus');
if (normalized === 'team') return t('codex_quota.plan_team');
if (normalized === 'free') return t('codex_quota.plan_free');
return pt || normalized;
};
const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
const nodes: ReactNode[] = [];
if (planLabel) {
nodes.push(
h(
'div',
{ key: 'plan', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
h('span', { className: styleMap.codexPlanValue }, planLabel)
)
);
}
if (isFreePlan) {
nodes.push(
h(
'div',
{ key: 'warning', className: styleMap.quotaWarning },
t('codex_quota.no_access')
)
);
return h(Fragment, null, ...nodes);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
const renderGeminiCliItems = (
quota: GeminiCliQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const buckets = quota.buckets ?? [];
if (buckets.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
}
return buckets.map((bucket) => {
const fraction = bucket.remainingFraction;
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
const percent = clamped === null ? null : Math.round(clamped * 100);
const percentLabel = percent === null ? '--' : `${percent}%`;
const remainingAmountLabel =
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
const resetLabel = formatQuotaResetTime(bucket.resetTime);
return h(
'div',
{ key: bucket.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel, title }, bucket.label),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
remainingAmountLabel
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
: null,
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file),
fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota',
buildLoadingState: () => ({ status: 'loading', groups: [] }),
buildSuccessState: (groups) => ({ status: 'success', groups }),
buildErrorState: (message, status) => ({
status: 'error',
groups: [],
error: message,
errorStatus: status
}),
cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems
};
export const CODEX_CONFIG: QuotaConfig<
CodexQuotaState,
{ planType: string | null; windows: CodexQuotaWindow[] }
> = {
type: 'codex',
i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file),
fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
planType: data.planType
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status
}),
cardClassName: styles.codexCard,
controlsClassName: styles.codexControls,
controlClassName: styles.codexControl,
gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems
};
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
buildErrorState: (message, status) => ({
status: 'error',
buckets: [],
error: message,
errorStatus: status
}),
cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems
};

View File

@@ -0,0 +1,98 @@
/**
* Generic hook for quota data fetching and management.
*/
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthFileItem } from '@/types';
import { useQuotaStore } from '@/stores';
import { getStatusFromError } from '@/utils/quota';
import type { QuotaConfig } from './quotaConfigs';
type QuotaScope = 'page' | 'all';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
interface LoadQuotaResult<TData> {
name: string;
status: 'success' | 'error';
data?: TData;
error?: string;
errorStatus?: number;
}
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
const { t } = useTranslation();
const quota = useQuotaStore(config.storeSelector);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const loadQuota = useCallback(
async (
targets: AuthFileItem[],
scope: QuotaScope,
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
) => {
if (loadingRef.current) return;
loadingRef.current = true;
const requestId = ++requestIdRef.current;
setLoading(true, scope);
try {
if (targets.length === 0) return;
setQuota((prev) => {
const nextState = { ...prev };
targets.forEach((file) => {
nextState[file.name] = config.buildLoadingState();
});
return nextState;
});
const results = await Promise.all(
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
try {
const data = await config.fetchQuota(file, t);
return { name: file.name, status: 'success', data };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const errorStatus = getStatusFromError(err);
return { name: file.name, status: 'error', error: message, errorStatus };
}
})
);
if (requestId !== requestIdRef.current) return;
setQuota((prev) => {
const nextState = { ...prev };
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = config.buildSuccessState(result.data as TData);
} else {
nextState[result.name] = config.buildErrorState(
result.error || t('common.unknown_error'),
result.errorStatus
);
}
});
return nextState;
});
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
loadingRef.current = false;
}
}
},
[config, setQuota, t]
);
return { quota, loadQuota };
}

View File

@@ -8,9 +8,10 @@ import { Button } from '@/components/ui/Button';
import { useAuthStore } from '@/stores'; import { useAuthStore } from '@/stores';
import { authFilesApi } from '@/services/api'; import { authFilesApi } from '@/services/api';
import { import {
AntigravitySection, QuotaSection,
CodexSection, ANTIGRAVITY_CONFIG,
GeminiCliSection CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota'; } from '@/components/quota';
import type { AuthFileItem } from '@/types'; import type { AuthFileItem } from '@/types';
import styles from './QuotaPage.module.scss'; import styles from './QuotaPage.module.scss';
@@ -57,9 +58,24 @@ export function QuotaPage() {
{error && <div className={styles.errorBox}>{error}</div>} {error && <div className={styles.errorBox}>{error}</div>}
<AntigravitySection files={files} loading={loading} disabled={disableControls} /> <QuotaSection
<CodexSection files={files} loading={loading} disabled={disableControls} /> config={ANTIGRAVITY_CONFIG}
<GeminiCliSection files={files} loading={loading} disabled={disableControls} /> files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={CODEX_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={GEMINI_CLI_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
</div> </div>
); );
} }