mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40:50 +08:00
refactor(quota): modularize QuotaPage into separate section components
This commit is contained in:
114
src/components/quota/AntigravitySection/AntigravityCard.tsx
Normal file
114
src/components/quota/AntigravitySection/AntigravityCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
182
src/components/quota/AntigravitySection/AntigravitySection.tsx
Normal file
182
src/components/quota/AntigravitySection/AntigravitySection.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
3
src/components/quota/AntigravitySection/index.ts
Normal file
3
src/components/quota/AntigravitySection/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AntigravitySection } from './AntigravitySection';
|
||||
export { AntigravityCard } from './AntigravityCard';
|
||||
export { useAntigravityQuota } from './useAntigravityQuota';
|
||||
176
src/components/quota/AntigravitySection/useAntigravityQuota.ts
Normal file
176
src/components/quota/AntigravitySection/useAntigravityQuota.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
144
src/components/quota/CodexSection/CodexCard.tsx
Normal file
144
src/components/quota/CodexSection/CodexCard.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
182
src/components/quota/CodexSection/CodexSection.tsx
Normal file
182
src/components/quota/CodexSection/CodexSection.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
3
src/components/quota/CodexSection/index.ts
Normal file
3
src/components/quota/CodexSection/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CodexSection } from './CodexSection';
|
||||
export { CodexCard } from './CodexCard';
|
||||
export { useCodexQuota } from './useCodexQuota';
|
||||
207
src/components/quota/CodexSection/useCodexQuota.ts
Normal file
207
src/components/quota/CodexSection/useCodexQuota.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
135
src/components/quota/GeminiCliSection/GeminiCliCard.tsx
Normal file
135
src/components/quota/GeminiCliSection/GeminiCliCard.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
182
src/components/quota/GeminiCliSection/GeminiCliSection.tsx
Normal file
182
src/components/quota/GeminiCliSection/GeminiCliSection.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
3
src/components/quota/GeminiCliSection/index.ts
Normal file
3
src/components/quota/GeminiCliSection/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { GeminiCliSection } from './GeminiCliSection';
|
||||
export { GeminiCliCard } from './GeminiCliCard';
|
||||
export { useGeminiCliQuota } from './useGeminiCliQuota';
|
||||
176
src/components/quota/GeminiCliSection/useGeminiCliQuota.ts
Normal file
176
src/components/quota/GeminiCliSection/useGeminiCliQuota.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
87
src/components/quota/hooks/useQuotaSection.ts
Normal file
87
src/components/quota/hooks/useQuotaSection.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
8
src/components/quota/index.ts
Normal file
8
src/components/quota/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Quota components barrel export.
|
||||
*/
|
||||
|
||||
export { AntigravitySection } from './AntigravitySection';
|
||||
export { CodexSection } from './CodexSection';
|
||||
export { GeminiCliSection } from './GeminiCliSection';
|
||||
export { useQuotaSection } from './hooks/useQuotaSection';
|
||||
Reference in New Issue
Block a user