diff --git a/src/components/quota/AntigravitySection/AntigravityCard.tsx b/src/components/quota/AntigravitySection/AntigravityCard.tsx deleted file mode 100644 index 908497c..0000000 --- a/src/components/quota/AntigravitySection/AntigravityCard.tsx +++ /dev/null @@ -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 ( -
-
- - {getTypeLabel(displayType)} - - {item.name} -
- -
- {quotaStatus === 'loading' ? ( -
{t('antigravity_quota.loading')}
- ) : quotaStatus === 'idle' ? ( -
{t('antigravity_quota.idle')}
- ) : quotaStatus === 'error' ? ( -
- {t('antigravity_quota.load_failed', { - message: quotaErrorMessage - })} -
- ) : quotaGroups.length === 0 ? ( -
{t('antigravity_quota.empty_models')}
- ) : ( - 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 ( -
-
- - {group.label} - -
- {percent}% - {resetLabel} -
-
-
-
-
-
- ); - }) - )} -
-
- ); -} diff --git a/src/components/quota/AntigravitySection/AntigravitySection.tsx b/src/components/quota/AntigravitySection/AntigravitySection.tsx deleted file mode 100644 index 6ec2e6e..0000000 --- a/src/components/quota/AntigravitySection/AntigravitySection.tsx +++ /dev/null @@ -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 = {}; - antigravityFiles.forEach((file) => { - const cached = prev[file.name]; - if (cached) { - nextState[file.name] = cached; - } - }); - return nextState; - }); - }, [antigravityFiles, loading, setAntigravityQuota]); - - return ( - - - -
- } - > - {antigravityFiles.length === 0 ? ( - - ) : ( - <> -
-
- - -
-
- -
- {antigravityFiles.length} {t('auth_files.files_count')} -
-
-
-
- {pageItems.map((item) => ( - - ))} -
- {antigravityFiles.length > pageSize && ( -
- -
- {t('auth_files.pagination_info', { - current: currentPage, - total: totalPages, - count: antigravityFiles.length - })} -
- -
- )} - - )} - - ); -} diff --git a/src/components/quota/AntigravitySection/index.ts b/src/components/quota/AntigravitySection/index.ts deleted file mode 100644 index a723aa5..0000000 --- a/src/components/quota/AntigravitySection/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AntigravitySection } from './AntigravitySection'; -export { AntigravityCard } from './AntigravityCard'; -export { useAntigravityQuota } from './useAntigravityQuota'; diff --git a/src/components/quota/AntigravitySection/useAntigravityQuota.ts b/src/components/quota/AntigravitySection/useAntigravityQuota.ts deleted file mode 100644 index a3c5929..0000000 --- a/src/components/quota/AntigravitySection/useAntigravityQuota.ts +++ /dev/null @@ -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; - loadQuota: ( - targets: AuthFileItem[], - scope: 'page' | 'all', - setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void - ) => Promise; -} - -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 => { - 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 }; -} diff --git a/src/components/quota/CodexSection/CodexCard.tsx b/src/components/quota/CodexSection/CodexCard.tsx deleted file mode 100644 index e5a3480..0000000 --- a/src/components/quota/CodexSection/CodexCard.tsx +++ /dev/null @@ -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 ( -
-
- - {getTypeLabel(displayType)} - - {item.name} -
- -
- {quotaStatus === 'loading' ? ( -
{t('codex_quota.loading')}
- ) : quotaStatus === 'idle' ? ( -
{t('codex_quota.idle')}
- ) : quotaStatus === 'error' ? ( -
- {t('codex_quota.load_failed', { - message: quotaErrorMessage - })} -
- ) : ( - <> - {planLabel && ( -
- {t('codex_quota.plan_label')} - {planLabel} -
- )} - {isFreePlan ? ( -
{t('codex_quota.no_access')}
- ) : windows.length === 0 ? ( -
{t('codex_quota.empty_windows')}
- ) : ( - 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 ( -
-
- {windowLabel} -
- {percentLabel} - {window.resetLabel} -
-
-
-
-
-
- ); - }) - )} - - )} -
-
- ); -} diff --git a/src/components/quota/CodexSection/CodexSection.tsx b/src/components/quota/CodexSection/CodexSection.tsx deleted file mode 100644 index 62411e7..0000000 --- a/src/components/quota/CodexSection/CodexSection.tsx +++ /dev/null @@ -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 = {}; - codexFiles.forEach((file) => { - const cached = prev[file.name]; - if (cached) { - nextState[file.name] = cached; - } - }); - return nextState; - }); - }, [codexFiles, loading, setCodexQuota]); - - return ( - - - -
- } - > - {codexFiles.length === 0 ? ( - - ) : ( - <> -
-
- - -
-
- -
- {codexFiles.length} {t('auth_files.files_count')} -
-
-
-
- {pageItems.map((item) => ( - - ))} -
- {codexFiles.length > pageSize && ( -
- -
- {t('auth_files.pagination_info', { - current: currentPage, - total: totalPages, - count: codexFiles.length - })} -
- -
- )} - - )} - - ); -} diff --git a/src/components/quota/CodexSection/index.ts b/src/components/quota/CodexSection/index.ts deleted file mode 100644 index 01507e8..0000000 --- a/src/components/quota/CodexSection/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { CodexSection } from './CodexSection'; -export { CodexCard } from './CodexCard'; -export { useCodexQuota } from './useCodexQuota'; diff --git a/src/components/quota/CodexSection/useCodexQuota.ts b/src/components/quota/CodexSection/useCodexQuota.ts deleted file mode 100644 index 5458fe1..0000000 --- a/src/components/quota/CodexSection/useCodexQuota.ts +++ /dev/null @@ -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; - loadQuota: ( - targets: AuthFileItem[], - scope: 'page' | 'all', - setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void - ) => Promise; -} - -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 = { - ...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 }; -} diff --git a/src/components/quota/GeminiCliSection/GeminiCliCard.tsx b/src/components/quota/GeminiCliSection/GeminiCliCard.tsx deleted file mode 100644 index 4a145b7..0000000 --- a/src/components/quota/GeminiCliSection/GeminiCliCard.tsx +++ /dev/null @@ -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 ( -
-
- - {getTypeLabel(displayType)} - - {item.name} -
- -
- {quotaStatus === 'loading' ? ( -
{t('gemini_cli_quota.loading')}
- ) : quotaStatus === 'idle' ? ( -
{t('gemini_cli_quota.idle')}
- ) : quotaStatus === 'error' ? ( -
- {t('gemini_cli_quota.load_failed', { - message: quotaErrorMessage - })} -
- ) : buckets.length === 0 ? ( -
{t('gemini_cli_quota.empty_buckets')}
- ) : ( - 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 ( -
-
- - {bucket.label} - -
- {percentLabel} - {remainingAmountLabel && ( - {remainingAmountLabel} - )} - {resetLabel} -
-
-
-
-
-
- ); - }) - )} -
-
- ); -} diff --git a/src/components/quota/GeminiCliSection/GeminiCliSection.tsx b/src/components/quota/GeminiCliSection/GeminiCliSection.tsx deleted file mode 100644 index 1268e15..0000000 --- a/src/components/quota/GeminiCliSection/GeminiCliSection.tsx +++ /dev/null @@ -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 = {}; - geminiCliFiles.forEach((file) => { - const cached = prev[file.name]; - if (cached) { - nextState[file.name] = cached; - } - }); - return nextState; - }); - }, [geminiCliFiles, loading, setGeminiCliQuota]); - - return ( - - - -
- } - > - {geminiCliFiles.length === 0 ? ( - - ) : ( - <> -
-
- - -
-
- -
- {geminiCliFiles.length} {t('auth_files.files_count')} -
-
-
-
- {pageItems.map((item) => ( - - ))} -
- {geminiCliFiles.length > pageSize && ( -
- -
- {t('auth_files.pagination_info', { - current: currentPage, - total: totalPages, - count: geminiCliFiles.length - })} -
- -
- )} - - )} - - ); -} diff --git a/src/components/quota/GeminiCliSection/index.ts b/src/components/quota/GeminiCliSection/index.ts deleted file mode 100644 index 5ec8d1e..0000000 --- a/src/components/quota/GeminiCliSection/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { GeminiCliSection } from './GeminiCliSection'; -export { GeminiCliCard } from './GeminiCliCard'; -export { useGeminiCliQuota } from './useGeminiCliQuota'; diff --git a/src/components/quota/GeminiCliSection/useGeminiCliQuota.ts b/src/components/quota/GeminiCliSection/useGeminiCliQuota.ts deleted file mode 100644 index 519f291..0000000 --- a/src/components/quota/GeminiCliSection/useGeminiCliQuota.ts +++ /dev/null @@ -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; - loadQuota: ( - targets: AuthFileItem[], - scope: 'page' | 'all', - setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void - ) => Promise; -} - -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 => { - 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 }; -} diff --git a/src/components/quota/QuotaCard.tsx b/src/components/quota/QuotaCard.tsx new file mode 100644 index 0000000..b0aa58d --- /dev/null +++ b/src/components/quota/QuotaCard.tsx @@ -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 ( +
+
+
+ ); +} + +export interface QuotaRenderHelpers { + styles: typeof styles; + QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement; +} + +interface QuotaCardProps { + item: AuthFileItem; + quota?: TState; + resolvedTheme: ResolvedTheme; + i18nPrefix: string; + cardClassName: string; + defaultType: string; + renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; +} + +export function QuotaCard({ + item, + quota, + resolvedTheme, + i18nPrefix, + cardClassName, + defaultType, + renderQuotaItems +}: QuotaCardProps) { + 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 ( +
+
+ + {getTypeLabel(displayType)} + + {item.name} +
+ +
+ {quotaStatus === 'loading' ? ( +
{t(`${i18nPrefix}.loading`)}
+ ) : quotaStatus === 'idle' ? ( +
{t(`${i18nPrefix}.idle`)}
+ ) : quotaStatus === 'error' ? ( +
+ {t(`${i18nPrefix}.load_failed`, { + message: quotaErrorMessage + })} +
+ ) : quota ? ( + renderQuotaItems(quota, t, { styles, QuotaProgressBar }) + ) : ( +
{t(`${i18nPrefix}.idle`)}
+ )} +
+
+ ); +} + +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; +}; diff --git a/src/components/quota/QuotaSection.tsx b/src/components/quota/QuotaSection.tsx new file mode 100644 index 0000000..7564dd0 --- /dev/null +++ b/src/components/quota/QuotaSection.tsx @@ -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 | ((prev: T) => T); + +type QuotaSetter = (updater: QuotaUpdater) => void; + +interface QuotaPaginationState { + 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 = (items: T[], defaultPageSize = 6): QuotaPaginationState => { + 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 { + config: QuotaConfig; + files: AuthFileItem[]; + loading: boolean; + disabled: boolean; +} + +export function QuotaSection({ + config, + files, + loading, + disabled +}: QuotaSectionProps) { + const { t } = useTranslation(); + const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); + const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter< + Record + >; + + 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 = {}; + filteredFiles.forEach((file) => { + const cached = prev[file.name]; + if (cached) { + nextState[file.name] = cached; + } + }); + return nextState; + }); + }, [filteredFiles, loading, setQuota]); + + return ( + + + +
+ } + > + {filteredFiles.length === 0 ? ( + + ) : ( + <> +
+
+ + +
+
+ +
+ {filteredFiles.length} {t('auth_files.files_count')} +
+
+
+
+ {pageItems.map((item) => ( + + ))} +
+ {filteredFiles.length > pageSize && ( +
+ +
+ {t('auth_files.pagination_info', { + current: currentPage, + total: totalPages, + count: filteredFiles.length + })} +
+ +
+ )} + + )} + + ); +} diff --git a/src/components/quota/hooks/useQuotaSection.ts b/src/components/quota/hooks/useQuotaSection.ts deleted file mode 100644 index 090d952..0000000 --- a/src/components/quota/hooks/useQuotaSection.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Shared hook for quota section pagination and loading state management. - */ - -import { useState, useMemo, useCallback } from 'react'; - -interface UseQuotaSectionOptions { - items: T[]; - defaultPageSize?: number; -} - -interface UseQuotaSectionReturn { - 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( - options: UseQuotaSectionOptions -): UseQuotaSectionReturn { - 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 - }; -} diff --git a/src/components/quota/index.ts b/src/components/quota/index.ts index 6f5a252..6e915fa 100644 --- a/src/components/quota/index.ts +++ b/src/components/quota/index.ts @@ -2,7 +2,8 @@ * Quota components barrel export. */ -export { AntigravitySection } from './AntigravitySection'; -export { CodexSection } from './CodexSection'; -export { GeminiCliSection } from './GeminiCliSection'; -export { useQuotaSection } from './hooks/useQuotaSection'; +export { QuotaSection } from './QuotaSection'; +export { QuotaCard } from './QuotaCard'; +export { useQuotaLoader } from './useQuotaLoader'; +export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs'; +export type { QuotaConfig } from './quotaConfigs'; diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts new file mode 100644 index 0000000..6a023d0 --- /dev/null +++ b/src/components/quota/quotaConfigs.ts @@ -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 | ((prev: T) => T); + +type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; + +export interface QuotaStore { + antigravityQuota: Record; + codexQuota: Record; + geminiCliQuota: Record; + setAntigravityQuota: (updater: QuotaUpdater>) => void; + setCodexQuota: (updater: QuotaUpdater>) => void; + setGeminiCliQuota: (updater: QuotaUpdater>) => void; + clearQuotaCache: () => void; +} + +export interface QuotaConfig { + type: QuotaType; + i18nPrefix: string; + filterFn: (file: AuthFileItem) => boolean; + fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + storeSelector: (state: QuotaStore) => Record; + 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 => { + 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 = { + ...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 => { + 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 = { + 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 = { + 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 +}; diff --git a/src/components/quota/useQuotaLoader.ts b/src/components/quota/useQuotaLoader.ts new file mode 100644 index 0000000..e0123ab --- /dev/null +++ b/src/components/quota/useQuotaLoader.ts @@ -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 | ((prev: T) => T); + +type QuotaSetter = (updater: QuotaUpdater) => void; + +interface LoadQuotaResult { + name: string; + status: 'success' | 'error'; + data?: TData; + error?: string; + errorStatus?: number; +} + +export function useQuotaLoader(config: QuotaConfig) { + const { t } = useTranslation(); + const quota = useQuotaStore(config.storeSelector); + const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter< + Record + >; + + 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> => { + 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 }; +} diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index eb5d9fd..66e2cdf 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -8,9 +8,10 @@ import { Button } from '@/components/ui/Button'; import { useAuthStore } from '@/stores'; import { authFilesApi } from '@/services/api'; import { - AntigravitySection, - CodexSection, - GeminiCliSection + QuotaSection, + ANTIGRAVITY_CONFIG, + CODEX_CONFIG, + GEMINI_CLI_CONFIG } from '@/components/quota'; import type { AuthFileItem } from '@/types'; import styles from './QuotaPage.module.scss'; @@ -57,9 +58,24 @@ export function QuotaPage() { {error &&
{error}
} - - - + + + ); }