mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
refactor(quota): consolidate quota sections into config-driven components
This commit is contained in:
@@ -1,114 +0,0 @@
|
|||||||
/**
|
|
||||||
* Individual Antigravity quota card component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type {
|
|
||||||
AntigravityQuotaState,
|
|
||||||
AuthFileItem,
|
|
||||||
ResolvedTheme,
|
|
||||||
ThemeColors
|
|
||||||
} from '@/types';
|
|
||||||
import { TYPE_COLORS, formatQuotaResetTime } from '@/utils/quota';
|
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
|
||||||
|
|
||||||
interface AntigravityCardProps {
|
|
||||||
item: AuthFileItem;
|
|
||||||
quota?: AntigravityQuotaState;
|
|
||||||
resolvedTheme: ResolvedTheme;
|
|
||||||
getQuotaErrorMessage: (status: number | undefined, fallback: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AntigravityCard({
|
|
||||||
item,
|
|
||||||
quota,
|
|
||||||
resolvedTheme,
|
|
||||||
getQuotaErrorMessage
|
|
||||||
}: AntigravityCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const displayType = item.type || item.provider || 'antigravity';
|
|
||||||
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
|
||||||
const typeColor: ThemeColors =
|
|
||||||
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
|
||||||
|
|
||||||
const quotaStatus = quota?.status ?? 'idle';
|
|
||||||
const quotaGroups = quota?.groups ?? [];
|
|
||||||
const quotaErrorMessage = getQuotaErrorMessage(
|
|
||||||
quota?.errorStatus,
|
|
||||||
quota?.error || t('common.unknown_error')
|
|
||||||
);
|
|
||||||
|
|
||||||
const getTypeLabel = (type: string): string => {
|
|
||||||
const key = `auth_files.filter_${type}`;
|
|
||||||
const translated = t(key);
|
|
||||||
if (translated !== key) return translated;
|
|
||||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.fileCard} ${styles.antigravityCard}`}>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<span
|
|
||||||
className={styles.typeBadge}
|
|
||||||
style={{
|
|
||||||
backgroundColor: typeColor.bg,
|
|
||||||
color: typeColor.text,
|
|
||||||
...(typeColor.border ? { border: typeColor.border } : {})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getTypeLabel(displayType)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.fileName}>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.quotaSection}>
|
|
||||||
{quotaStatus === 'loading' ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('antigravity_quota.loading')}</div>
|
|
||||||
) : quotaStatus === 'idle' ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('antigravity_quota.idle')}</div>
|
|
||||||
) : quotaStatus === 'error' ? (
|
|
||||||
<div className={styles.quotaError}>
|
|
||||||
{t('antigravity_quota.load_failed', {
|
|
||||||
message: quotaErrorMessage
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : quotaGroups.length === 0 ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('antigravity_quota.empty_models')}</div>
|
|
||||||
) : (
|
|
||||||
quotaGroups.map((group) => {
|
|
||||||
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
|
||||||
const percent = Math.round(clamped * 100);
|
|
||||||
const resetLabel = formatQuotaResetTime(group.resetTime);
|
|
||||||
const quotaBarClass =
|
|
||||||
percent >= 60
|
|
||||||
? styles.quotaBarFillHigh
|
|
||||||
: percent >= 20
|
|
||||||
? styles.quotaBarFillMedium
|
|
||||||
: styles.quotaBarFillLow;
|
|
||||||
return (
|
|
||||||
<div key={group.id} className={styles.quotaRow}>
|
|
||||||
<div className={styles.quotaRowHeader}>
|
|
||||||
<span className={styles.quotaModel} title={group.models.join(', ')}>
|
|
||||||
{group.label}
|
|
||||||
</span>
|
|
||||||
<div className={styles.quotaMeta}>
|
|
||||||
<span className={styles.quotaPercent}>{percent}%</span>
|
|
||||||
<span className={styles.quotaReset}>{resetLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.quotaBar}>
|
|
||||||
<div
|
|
||||||
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
|
||||||
style={{ width: `${percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* Antigravity quota section component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
|
||||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
|
||||||
import type { AntigravityQuotaState, AuthFileItem, ResolvedTheme } from '@/types';
|
|
||||||
import { isAntigravityFile } from '@/utils/quota';
|
|
||||||
import { useQuotaSection } from '../hooks/useQuotaSection';
|
|
||||||
import { useAntigravityQuota } from './useAntigravityQuota';
|
|
||||||
import { AntigravityCard } from './AntigravityCard';
|
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
|
||||||
|
|
||||||
interface AntigravitySectionProps {
|
|
||||||
files: AuthFileItem[];
|
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AntigravitySection({ files, loading, disabled }: AntigravitySectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
|
||||||
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
|
||||||
|
|
||||||
const antigravityFiles = useMemo(
|
|
||||||
() => files.filter((file) => isAntigravityFile(file)),
|
|
||||||
[files]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
currentPage,
|
|
||||||
pageItems,
|
|
||||||
setPageSize,
|
|
||||||
goToPrev,
|
|
||||||
goToNext,
|
|
||||||
loading: sectionLoading,
|
|
||||||
loadingScope,
|
|
||||||
setLoading
|
|
||||||
} = useQuotaSection({ items: antigravityFiles });
|
|
||||||
|
|
||||||
const { quota, loadQuota } = useAntigravityQuota();
|
|
||||||
|
|
||||||
const handleRefreshPage = useCallback(() => {
|
|
||||||
loadQuota(pageItems, 'page', setLoading);
|
|
||||||
}, [loadQuota, pageItems, setLoading]);
|
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(() => {
|
|
||||||
loadQuota(antigravityFiles, 'all', setLoading);
|
|
||||||
}, [loadQuota, antigravityFiles, setLoading]);
|
|
||||||
|
|
||||||
const getQuotaErrorMessage = useCallback(
|
|
||||||
(status: number | undefined, fallback: string) => {
|
|
||||||
if (status === 404) return t('common.quota_update_required');
|
|
||||||
if (status === 403) return t('common.quota_check_credential');
|
|
||||||
return fallback;
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync quota state when files change
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return;
|
|
||||||
if (antigravityFiles.length === 0) {
|
|
||||||
setAntigravityQuota({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAntigravityQuota((prev) => {
|
|
||||||
const nextState: Record<string, AntigravityQuotaState> = {};
|
|
||||||
antigravityFiles.forEach((file) => {
|
|
||||||
const cached = prev[file.name];
|
|
||||||
if (cached) {
|
|
||||||
nextState[file.name] = cached;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
}, [antigravityFiles, loading, setAntigravityQuota]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title={t('antigravity_quota.title')}
|
|
||||||
extra={
|
|
||||||
<div className={styles.headerActions}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshPage}
|
|
||||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'page'}
|
|
||||||
>
|
|
||||||
{t('antigravity_quota.refresh_button')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshAll}
|
|
||||||
disabled={disabled || sectionLoading || antigravityFiles.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'all'}
|
|
||||||
>
|
|
||||||
{t('antigravity_quota.fetch_all')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{antigravityFiles.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t('antigravity_quota.empty_title')}
|
|
||||||
description={t('antigravity_quota.empty_desc')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.antigravityControls}>
|
|
||||||
<div className={styles.antigravityControl}>
|
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
|
||||||
<select
|
|
||||||
className={styles.pageSizeSelect}
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
|
|
||||||
>
|
|
||||||
<option value={6}>6</option>
|
|
||||||
<option value={9}>9</option>
|
|
||||||
<option value={12}>12</option>
|
|
||||||
<option value={18}>18</option>
|
|
||||||
<option value={24}>24</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={styles.antigravityControl}>
|
|
||||||
<label>{t('common.info')}</label>
|
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{antigravityFiles.length} {t('auth_files.files_count')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.antigravityGrid}>
|
|
||||||
{pageItems.map((item) => (
|
|
||||||
<AntigravityCard
|
|
||||||
key={item.name}
|
|
||||||
item={item}
|
|
||||||
quota={quota[item.name]}
|
|
||||||
resolvedTheme={resolvedTheme}
|
|
||||||
getQuotaErrorMessage={getQuotaErrorMessage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{antigravityFiles.length > pageSize && (
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToPrev}
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_prev')}
|
|
||||||
</Button>
|
|
||||||
<div className={styles.pageInfo}>
|
|
||||||
{t('auth_files.pagination_info', {
|
|
||||||
current: currentPage,
|
|
||||||
total: totalPages,
|
|
||||||
count: antigravityFiles.length
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToNext}
|
|
||||||
disabled={currentPage >= totalPages}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { AntigravitySection } from './AntigravitySection';
|
|
||||||
export { AntigravityCard } from './AntigravityCard';
|
|
||||||
export { useAntigravityQuota } from './useAntigravityQuota';
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook for Antigravity quota data fetching and management.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import { useQuotaStore } from '@/stores';
|
|
||||||
import type { AntigravityQuotaGroup, AntigravityModelsPayload, AuthFileItem } from '@/types';
|
|
||||||
import {
|
|
||||||
ANTIGRAVITY_QUOTA_URLS,
|
|
||||||
ANTIGRAVITY_REQUEST_HEADERS,
|
|
||||||
normalizeAuthIndexValue,
|
|
||||||
parseAntigravityPayload,
|
|
||||||
buildAntigravityQuotaGroups,
|
|
||||||
createStatusError,
|
|
||||||
getStatusFromError
|
|
||||||
} from '@/utils/quota';
|
|
||||||
|
|
||||||
interface UseAntigravityQuotaReturn {
|
|
||||||
quota: Record<string, import('@/types').AntigravityQuotaState>;
|
|
||||||
loadQuota: (
|
|
||||||
targets: AuthFileItem[],
|
|
||||||
scope: 'page' | 'all',
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAntigravityQuota(): UseAntigravityQuotaReturn {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
|
|
||||||
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
|
||||||
const requestIdRef = useRef(0);
|
|
||||||
|
|
||||||
const fetchQuota = useCallback(
|
|
||||||
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
|
|
||||||
let lastError = '';
|
|
||||||
let lastStatus: number | undefined;
|
|
||||||
let priorityStatus: number | undefined;
|
|
||||||
let hadSuccess = false;
|
|
||||||
|
|
||||||
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
|
||||||
try {
|
|
||||||
const result = await apiCallApi.request({
|
|
||||||
authIndex,
|
|
||||||
method: 'POST',
|
|
||||||
url,
|
|
||||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
|
||||||
data: '{}'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
lastError = getApiCallErrorMessage(result);
|
|
||||||
lastStatus = result.statusCode;
|
|
||||||
if (result.statusCode === 403 || result.statusCode === 404) {
|
|
||||||
priorityStatus ??= result.statusCode;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
hadSuccess = true;
|
|
||||||
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
|
||||||
const models = payload?.models;
|
|
||||||
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
|
||||||
lastError = t('antigravity_quota.empty_models');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
|
||||||
if (groups.length === 0) {
|
|
||||||
lastError = t('antigravity_quota.empty_models');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
} catch (err: unknown) {
|
|
||||||
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
|
||||||
const status = getStatusFromError(err);
|
|
||||||
if (status) {
|
|
||||||
lastStatus = status;
|
|
||||||
if (status === 403 || status === 404) {
|
|
||||||
priorityStatus ??= status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hadSuccess) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadQuota = useCallback(
|
|
||||||
async (
|
|
||||||
targets: AuthFileItem[],
|
|
||||||
scope: 'page' | 'all',
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
|
|
||||||
) => {
|
|
||||||
if (loadingRef.current) return;
|
|
||||||
loadingRef.current = true;
|
|
||||||
const requestId = ++requestIdRef.current;
|
|
||||||
setLoading(true, scope);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (targets.length === 0) return;
|
|
||||||
|
|
||||||
setAntigravityQuota((prev) => {
|
|
||||||
const nextState = { ...prev };
|
|
||||||
targets.forEach((file) => {
|
|
||||||
nextState[file.name] = { status: 'loading', groups: [] };
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
targets.map(async (file) => {
|
|
||||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
|
||||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
|
||||||
if (!authIndex) {
|
|
||||||
return {
|
|
||||||
name: file.name,
|
|
||||||
status: 'error' as const,
|
|
||||||
error: t('antigravity_quota.missing_auth_index')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const groups = await fetchQuota(authIndex);
|
|
||||||
return { name: file.name, status: 'success' as const, groups };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
|
||||||
const errorStatus = getStatusFromError(err);
|
|
||||||
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requestId !== requestIdRef.current) return;
|
|
||||||
|
|
||||||
setAntigravityQuota((prev) => {
|
|
||||||
const nextState = { ...prev };
|
|
||||||
results.forEach((result) => {
|
|
||||||
if (result.status === 'success') {
|
|
||||||
nextState[result.name] = {
|
|
||||||
status: 'success',
|
|
||||||
groups: result.groups
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
nextState[result.name] = {
|
|
||||||
status: 'error',
|
|
||||||
groups: [],
|
|
||||||
error: result.error,
|
|
||||||
errorStatus: result.errorStatus
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
loadingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchQuota, setAntigravityQuota, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { quota: antigravityQuota, loadQuota };
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* Individual Codex quota card component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type {
|
|
||||||
CodexQuotaState,
|
|
||||||
AuthFileItem,
|
|
||||||
ResolvedTheme,
|
|
||||||
ThemeColors
|
|
||||||
} from '@/types';
|
|
||||||
import { TYPE_COLORS, normalizePlanType } from '@/utils/quota';
|
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
|
||||||
|
|
||||||
interface CodexCardProps {
|
|
||||||
item: AuthFileItem;
|
|
||||||
quota?: CodexQuotaState;
|
|
||||||
resolvedTheme: ResolvedTheme;
|
|
||||||
getQuotaErrorMessage: (status: number | undefined, fallback: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexCard({
|
|
||||||
item,
|
|
||||||
quota,
|
|
||||||
resolvedTheme,
|
|
||||||
getQuotaErrorMessage
|
|
||||||
}: CodexCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const displayType = item.type || item.provider || 'codex';
|
|
||||||
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
|
||||||
const typeColor: ThemeColors =
|
|
||||||
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
|
||||||
|
|
||||||
const quotaStatus = quota?.status ?? 'idle';
|
|
||||||
const windows = quota?.windows ?? [];
|
|
||||||
const planType = quota?.planType ?? null;
|
|
||||||
const quotaErrorMessage = getQuotaErrorMessage(
|
|
||||||
quota?.errorStatus,
|
|
||||||
quota?.error || t('common.unknown_error')
|
|
||||||
);
|
|
||||||
|
|
||||||
const getTypeLabel = (type: string): string => {
|
|
||||||
const key = `auth_files.filter_${type}`;
|
|
||||||
const translated = t(key);
|
|
||||||
if (translated !== key) return translated;
|
|
||||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlanLabel = (pt?: string | null): string | null => {
|
|
||||||
const normalized = normalizePlanType(pt);
|
|
||||||
if (!normalized) return null;
|
|
||||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
|
||||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
|
||||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
|
||||||
return pt || normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const planLabel = getPlanLabel(planType);
|
|
||||||
const isFreePlan = normalizePlanType(planType) === 'free';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.fileCard} ${styles.codexCard}`}>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<span
|
|
||||||
className={styles.typeBadge}
|
|
||||||
style={{
|
|
||||||
backgroundColor: typeColor.bg,
|
|
||||||
color: typeColor.text,
|
|
||||||
...(typeColor.border ? { border: typeColor.border } : {})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getTypeLabel(displayType)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.fileName}>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.quotaSection}>
|
|
||||||
{quotaStatus === 'loading' ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('codex_quota.loading')}</div>
|
|
||||||
) : quotaStatus === 'idle' ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('codex_quota.idle')}</div>
|
|
||||||
) : quotaStatus === 'error' ? (
|
|
||||||
<div className={styles.quotaError}>
|
|
||||||
{t('codex_quota.load_failed', {
|
|
||||||
message: quotaErrorMessage
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{planLabel && (
|
|
||||||
<div className={styles.codexPlan}>
|
|
||||||
<span className={styles.codexPlanLabel}>{t('codex_quota.plan_label')}</span>
|
|
||||||
<span className={styles.codexPlanValue}>{planLabel}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isFreePlan ? (
|
|
||||||
<div className={styles.quotaWarning}>{t('codex_quota.no_access')}</div>
|
|
||||||
) : windows.length === 0 ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('codex_quota.empty_windows')}</div>
|
|
||||||
) : (
|
|
||||||
windows.map((window) => {
|
|
||||||
const used = window.usedPercent;
|
|
||||||
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
|
||||||
const remaining =
|
|
||||||
clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
|
||||||
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
|
||||||
const quotaBarClass =
|
|
||||||
remaining === null
|
|
||||||
? styles.quotaBarFillMedium
|
|
||||||
: remaining >= 80
|
|
||||||
? styles.quotaBarFillHigh
|
|
||||||
: remaining >= 50
|
|
||||||
? styles.quotaBarFillMedium
|
|
||||||
: styles.quotaBarFillLow;
|
|
||||||
|
|
||||||
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={window.id} className={styles.quotaRow}>
|
|
||||||
<div className={styles.quotaRowHeader}>
|
|
||||||
<span className={styles.quotaModel}>{windowLabel}</span>
|
|
||||||
<div className={styles.quotaMeta}>
|
|
||||||
<span className={styles.quotaPercent}>{percentLabel}</span>
|
|
||||||
<span className={styles.quotaReset}>{window.resetLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.quotaBar}>
|
|
||||||
<div
|
|
||||||
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
|
||||||
style={{ width: `${Math.round(remaining ?? 0)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* Codex quota section component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
|
||||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
|
||||||
import type { CodexQuotaState, AuthFileItem, ResolvedTheme } from '@/types';
|
|
||||||
import { isCodexFile } from '@/utils/quota';
|
|
||||||
import { useQuotaSection } from '../hooks/useQuotaSection';
|
|
||||||
import { useCodexQuota } from './useCodexQuota';
|
|
||||||
import { CodexCard } from './CodexCard';
|
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
|
||||||
|
|
||||||
interface CodexSectionProps {
|
|
||||||
files: AuthFileItem[];
|
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexSection({ files, loading, disabled }: CodexSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
|
||||||
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
|
||||||
|
|
||||||
const codexFiles = useMemo(
|
|
||||||
() => files.filter((file) => isCodexFile(file)),
|
|
||||||
[files]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
currentPage,
|
|
||||||
pageItems,
|
|
||||||
setPageSize,
|
|
||||||
goToPrev,
|
|
||||||
goToNext,
|
|
||||||
loading: sectionLoading,
|
|
||||||
loadingScope,
|
|
||||||
setLoading
|
|
||||||
} = useQuotaSection({ items: codexFiles });
|
|
||||||
|
|
||||||
const { quota, loadQuota } = useCodexQuota();
|
|
||||||
|
|
||||||
const handleRefreshPage = useCallback(() => {
|
|
||||||
loadQuota(pageItems, 'page', setLoading);
|
|
||||||
}, [loadQuota, pageItems, setLoading]);
|
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(() => {
|
|
||||||
loadQuota(codexFiles, 'all', setLoading);
|
|
||||||
}, [loadQuota, codexFiles, setLoading]);
|
|
||||||
|
|
||||||
const getQuotaErrorMessage = useCallback(
|
|
||||||
(status: number | undefined, fallback: string) => {
|
|
||||||
if (status === 404) return t('common.quota_update_required');
|
|
||||||
if (status === 403) return t('common.quota_check_credential');
|
|
||||||
return fallback;
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync quota state when files change
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return;
|
|
||||||
if (codexFiles.length === 0) {
|
|
||||||
setCodexQuota({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCodexQuota((prev) => {
|
|
||||||
const nextState: Record<string, CodexQuotaState> = {};
|
|
||||||
codexFiles.forEach((file) => {
|
|
||||||
const cached = prev[file.name];
|
|
||||||
if (cached) {
|
|
||||||
nextState[file.name] = cached;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
}, [codexFiles, loading, setCodexQuota]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title={t('codex_quota.title')}
|
|
||||||
extra={
|
|
||||||
<div className={styles.headerActions}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshPage}
|
|
||||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'page'}
|
|
||||||
>
|
|
||||||
{t('codex_quota.refresh_button')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshAll}
|
|
||||||
disabled={disabled || sectionLoading || codexFiles.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'all'}
|
|
||||||
>
|
|
||||||
{t('codex_quota.fetch_all')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{codexFiles.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t('codex_quota.empty_title')}
|
|
||||||
description={t('codex_quota.empty_desc')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.codexControls}>
|
|
||||||
<div className={styles.codexControl}>
|
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
|
||||||
<select
|
|
||||||
className={styles.pageSizeSelect}
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
|
|
||||||
>
|
|
||||||
<option value={6}>6</option>
|
|
||||||
<option value={9}>9</option>
|
|
||||||
<option value={12}>12</option>
|
|
||||||
<option value={18}>18</option>
|
|
||||||
<option value={24}>24</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={styles.codexControl}>
|
|
||||||
<label>{t('common.info')}</label>
|
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{codexFiles.length} {t('auth_files.files_count')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.codexGrid}>
|
|
||||||
{pageItems.map((item) => (
|
|
||||||
<CodexCard
|
|
||||||
key={item.name}
|
|
||||||
item={item}
|
|
||||||
quota={quota[item.name]}
|
|
||||||
resolvedTheme={resolvedTheme}
|
|
||||||
getQuotaErrorMessage={getQuotaErrorMessage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{codexFiles.length > pageSize && (
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToPrev}
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_prev')}
|
|
||||||
</Button>
|
|
||||||
<div className={styles.pageInfo}>
|
|
||||||
{t('auth_files.pagination_info', {
|
|
||||||
current: currentPage,
|
|
||||||
total: totalPages,
|
|
||||||
count: codexFiles.length
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToNext}
|
|
||||||
disabled={currentPage >= totalPages}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { CodexSection } from './CodexSection';
|
|
||||||
export { CodexCard } from './CodexCard';
|
|
||||||
export { useCodexQuota } from './useCodexQuota';
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook for Codex quota data fetching and management.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import { useQuotaStore } from '@/stores';
|
|
||||||
import type { AuthFileItem, CodexQuotaWindow, CodexUsagePayload } from '@/types';
|
|
||||||
import {
|
|
||||||
CODEX_USAGE_URL,
|
|
||||||
CODEX_REQUEST_HEADERS,
|
|
||||||
normalizeAuthIndexValue,
|
|
||||||
normalizeNumberValue,
|
|
||||||
normalizePlanType,
|
|
||||||
parseCodexUsagePayload,
|
|
||||||
resolveCodexChatgptAccountId,
|
|
||||||
resolveCodexPlanType,
|
|
||||||
formatCodexResetLabel,
|
|
||||||
createStatusError,
|
|
||||||
getStatusFromError
|
|
||||||
} from '@/utils/quota';
|
|
||||||
|
|
||||||
interface UseCodexQuotaReturn {
|
|
||||||
quota: Record<string, import('@/types').CodexQuotaState>;
|
|
||||||
loadQuota: (
|
|
||||||
targets: AuthFileItem[],
|
|
||||||
scope: 'page' | 'all',
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCodexQuota(): UseCodexQuotaReturn {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const codexQuota = useQuotaStore((state) => state.codexQuota);
|
|
||||||
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
|
||||||
const requestIdRef = useRef(0);
|
|
||||||
|
|
||||||
const buildQuotaWindows = useCallback(
|
|
||||||
(payload: CodexUsagePayload): CodexQuotaWindow[] => {
|
|
||||||
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
|
||||||
const codeReviewLimit =
|
|
||||||
payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
|
||||||
const windows: CodexQuotaWindow[] = [];
|
|
||||||
|
|
||||||
const addWindow = (
|
|
||||||
id: string,
|
|
||||||
labelKey: string,
|
|
||||||
window?: import('@/types').CodexUsageWindow | null,
|
|
||||||
limitReached?: boolean,
|
|
||||||
allowed?: boolean
|
|
||||||
) => {
|
|
||||||
if (!window) return;
|
|
||||||
const resetLabel = formatCodexResetLabel(window);
|
|
||||||
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
|
||||||
const isLimitReached = Boolean(limitReached) || allowed === false;
|
|
||||||
const usedPercent =
|
|
||||||
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
|
||||||
windows.push({
|
|
||||||
id,
|
|
||||||
label: t(labelKey),
|
|
||||||
labelKey,
|
|
||||||
usedPercent,
|
|
||||||
resetLabel
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
addWindow(
|
|
||||||
'primary',
|
|
||||||
'codex_quota.primary_window',
|
|
||||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
|
||||||
rateLimit?.allowed
|
|
||||||
);
|
|
||||||
addWindow(
|
|
||||||
'secondary',
|
|
||||||
'codex_quota.secondary_window',
|
|
||||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
|
||||||
rateLimit?.allowed
|
|
||||||
);
|
|
||||||
addWindow(
|
|
||||||
'code-review',
|
|
||||||
'codex_quota.code_review_window',
|
|
||||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
|
||||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
|
||||||
codeReviewLimit?.allowed
|
|
||||||
);
|
|
||||||
|
|
||||||
return windows;
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchQuota = useCallback(
|
|
||||||
async (file: AuthFileItem): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
|
||||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
|
||||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
|
||||||
if (!authIndex) {
|
|
||||||
throw new Error(t('codex_quota.missing_auth_index'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const planTypeFromFile = resolveCodexPlanType(file);
|
|
||||||
const accountId = resolveCodexChatgptAccountId(file);
|
|
||||||
if (!accountId) {
|
|
||||||
throw new Error(t('codex_quota.missing_account_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestHeader: Record<string, string> = {
|
|
||||||
...CODEX_REQUEST_HEADERS,
|
|
||||||
'Chatgpt-Account-Id': accountId
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await apiCallApi.request({
|
|
||||||
authIndex,
|
|
||||||
method: 'GET',
|
|
||||||
url: CODEX_USAGE_URL,
|
|
||||||
header: requestHeader
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
|
||||||
if (!payload) {
|
|
||||||
throw new Error(t('codex_quota.empty_windows'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
|
||||||
const windows = buildQuotaWindows(payload);
|
|
||||||
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
|
||||||
},
|
|
||||||
[buildQuotaWindows, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadQuota = useCallback(
|
|
||||||
async (
|
|
||||||
targets: AuthFileItem[],
|
|
||||||
scope: 'page' | 'all',
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
|
|
||||||
) => {
|
|
||||||
if (loadingRef.current) return;
|
|
||||||
loadingRef.current = true;
|
|
||||||
const requestId = ++requestIdRef.current;
|
|
||||||
setLoading(true, scope);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (targets.length === 0) return;
|
|
||||||
|
|
||||||
setCodexQuota((prev) => {
|
|
||||||
const nextState = { ...prev };
|
|
||||||
targets.forEach((file) => {
|
|
||||||
nextState[file.name] = { status: 'loading', windows: [] };
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
targets.map(async (file) => {
|
|
||||||
try {
|
|
||||||
const { planType, windows } = await fetchQuota(file);
|
|
||||||
return { name: file.name, status: 'success' as const, planType, windows };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
|
||||||
const errorStatus = getStatusFromError(err);
|
|
||||||
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requestId !== requestIdRef.current) return;
|
|
||||||
|
|
||||||
setCodexQuota((prev) => {
|
|
||||||
const nextState = { ...prev };
|
|
||||||
results.forEach((result) => {
|
|
||||||
if (result.status === 'success') {
|
|
||||||
nextState[result.name] = {
|
|
||||||
status: 'success',
|
|
||||||
windows: result.windows,
|
|
||||||
planType: result.planType
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
nextState[result.name] = {
|
|
||||||
status: 'error',
|
|
||||||
windows: [],
|
|
||||||
error: result.error,
|
|
||||||
errorStatus: result.errorStatus
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
loadingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchQuota, setCodexQuota, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { quota: codexQuota, loadQuota };
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* Individual Gemini CLI quota card component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type {
|
|
||||||
GeminiCliQuotaState,
|
|
||||||
AuthFileItem,
|
|
||||||
ResolvedTheme,
|
|
||||||
ThemeColors
|
|
||||||
} from '@/types';
|
|
||||||
import { TYPE_COLORS, formatQuotaResetTime } from '@/utils/quota';
|
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
|
||||||
|
|
||||||
interface GeminiCliCardProps {
|
|
||||||
item: AuthFileItem;
|
|
||||||
quota?: GeminiCliQuotaState;
|
|
||||||
resolvedTheme: ResolvedTheme;
|
|
||||||
getQuotaErrorMessage: (status: number | undefined, fallback: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeminiCliCard({
|
|
||||||
item,
|
|
||||||
quota,
|
|
||||||
resolvedTheme,
|
|
||||||
getQuotaErrorMessage
|
|
||||||
}: GeminiCliCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const displayType = item.type || item.provider || 'gemini-cli';
|
|
||||||
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
|
||||||
const typeColor: ThemeColors =
|
|
||||||
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
|
||||||
|
|
||||||
const quotaStatus = quota?.status ?? 'idle';
|
|
||||||
const buckets = quota?.buckets ?? [];
|
|
||||||
const quotaErrorMessage = getQuotaErrorMessage(
|
|
||||||
quota?.errorStatus,
|
|
||||||
quota?.error || t('common.unknown_error')
|
|
||||||
);
|
|
||||||
|
|
||||||
const getTypeLabel = (type: string): string => {
|
|
||||||
const key = `auth_files.filter_${type}`;
|
|
||||||
const translated = t(key);
|
|
||||||
if (translated !== key) return translated;
|
|
||||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.fileCard} ${styles.geminiCliCard}`}>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<span
|
|
||||||
className={styles.typeBadge}
|
|
||||||
style={{
|
|
||||||
backgroundColor: typeColor.bg,
|
|
||||||
color: typeColor.text,
|
|
||||||
...(typeColor.border ? { border: typeColor.border } : {})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getTypeLabel(displayType)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.fileName}>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.quotaSection}>
|
|
||||||
{quotaStatus === 'loading' ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('gemini_cli_quota.loading')}</div>
|
|
||||||
) : quotaStatus === 'idle' ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('gemini_cli_quota.idle')}</div>
|
|
||||||
) : quotaStatus === 'error' ? (
|
|
||||||
<div className={styles.quotaError}>
|
|
||||||
{t('gemini_cli_quota.load_failed', {
|
|
||||||
message: quotaErrorMessage
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : buckets.length === 0 ? (
|
|
||||||
<div className={styles.quotaMessage}>{t('gemini_cli_quota.empty_buckets')}</div>
|
|
||||||
) : (
|
|
||||||
buckets.map((bucket) => {
|
|
||||||
const fraction = bucket.remainingFraction;
|
|
||||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
|
||||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
|
||||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
|
||||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
|
||||||
const remainingAmountLabel =
|
|
||||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
|
||||||
? null
|
|
||||||
: t('gemini_cli_quota.remaining_amount', {
|
|
||||||
count: bucket.remainingAmount
|
|
||||||
});
|
|
||||||
const titleBase =
|
|
||||||
bucket.modelIds && bucket.modelIds.length > 0
|
|
||||||
? bucket.modelIds.join(', ')
|
|
||||||
: bucket.label;
|
|
||||||
const quotaBarClass =
|
|
||||||
percent === null
|
|
||||||
? styles.quotaBarFillMedium
|
|
||||||
: percent >= 60
|
|
||||||
? styles.quotaBarFillHigh
|
|
||||||
: percent >= 20
|
|
||||||
? styles.quotaBarFillMedium
|
|
||||||
: styles.quotaBarFillLow;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={bucket.id} className={styles.quotaRow}>
|
|
||||||
<div className={styles.quotaRowHeader}>
|
|
||||||
<span
|
|
||||||
className={styles.quotaModel}
|
|
||||||
title={bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase}
|
|
||||||
>
|
|
||||||
{bucket.label}
|
|
||||||
</span>
|
|
||||||
<div className={styles.quotaMeta}>
|
|
||||||
<span className={styles.quotaPercent}>{percentLabel}</span>
|
|
||||||
{remainingAmountLabel && (
|
|
||||||
<span className={styles.quotaAmount}>{remainingAmountLabel}</span>
|
|
||||||
)}
|
|
||||||
<span className={styles.quotaReset}>{resetLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.quotaBar}>
|
|
||||||
<div
|
|
||||||
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
|
||||||
style={{ width: `${percent ?? 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* Gemini CLI quota section component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
|
||||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
|
||||||
import type { GeminiCliQuotaState, AuthFileItem, ResolvedTheme } from '@/types';
|
|
||||||
import { isGeminiCliFile, isRuntimeOnlyAuthFile } from '@/utils/quota';
|
|
||||||
import { useQuotaSection } from '../hooks/useQuotaSection';
|
|
||||||
import { useGeminiCliQuota } from './useGeminiCliQuota';
|
|
||||||
import { GeminiCliCard } from './GeminiCliCard';
|
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
|
||||||
|
|
||||||
interface GeminiCliSectionProps {
|
|
||||||
files: AuthFileItem[];
|
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeminiCliSection({ files, loading, disabled }: GeminiCliSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
|
||||||
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
|
||||||
|
|
||||||
const geminiCliFiles = useMemo(
|
|
||||||
() => files.filter((file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file)),
|
|
||||||
[files]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
currentPage,
|
|
||||||
pageItems,
|
|
||||||
setPageSize,
|
|
||||||
goToPrev,
|
|
||||||
goToNext,
|
|
||||||
loading: sectionLoading,
|
|
||||||
loadingScope,
|
|
||||||
setLoading
|
|
||||||
} = useQuotaSection({ items: geminiCliFiles });
|
|
||||||
|
|
||||||
const { quota, loadQuota } = useGeminiCliQuota();
|
|
||||||
|
|
||||||
const handleRefreshPage = useCallback(() => {
|
|
||||||
loadQuota(pageItems, 'page', setLoading);
|
|
||||||
}, [loadQuota, pageItems, setLoading]);
|
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(() => {
|
|
||||||
loadQuota(geminiCliFiles, 'all', setLoading);
|
|
||||||
}, [loadQuota, geminiCliFiles, setLoading]);
|
|
||||||
|
|
||||||
const getQuotaErrorMessage = useCallback(
|
|
||||||
(status: number | undefined, fallback: string) => {
|
|
||||||
if (status === 404) return t('common.quota_update_required');
|
|
||||||
if (status === 403) return t('common.quota_check_credential');
|
|
||||||
return fallback;
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync quota state when files change
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return;
|
|
||||||
if (geminiCliFiles.length === 0) {
|
|
||||||
setGeminiCliQuota({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGeminiCliQuota((prev) => {
|
|
||||||
const nextState: Record<string, GeminiCliQuotaState> = {};
|
|
||||||
geminiCliFiles.forEach((file) => {
|
|
||||||
const cached = prev[file.name];
|
|
||||||
if (cached) {
|
|
||||||
nextState[file.name] = cached;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
}, [geminiCliFiles, loading, setGeminiCliQuota]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title={t('gemini_cli_quota.title')}
|
|
||||||
extra={
|
|
||||||
<div className={styles.headerActions}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshPage}
|
|
||||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'page'}
|
|
||||||
>
|
|
||||||
{t('gemini_cli_quota.refresh_button')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshAll}
|
|
||||||
disabled={disabled || sectionLoading || geminiCliFiles.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'all'}
|
|
||||||
>
|
|
||||||
{t('gemini_cli_quota.fetch_all')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{geminiCliFiles.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t('gemini_cli_quota.empty_title')}
|
|
||||||
description={t('gemini_cli_quota.empty_desc')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.geminiCliControls}>
|
|
||||||
<div className={styles.geminiCliControl}>
|
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
|
||||||
<select
|
|
||||||
className={styles.pageSizeSelect}
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
|
|
||||||
>
|
|
||||||
<option value={6}>6</option>
|
|
||||||
<option value={9}>9</option>
|
|
||||||
<option value={12}>12</option>
|
|
||||||
<option value={18}>18</option>
|
|
||||||
<option value={24}>24</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={styles.geminiCliControl}>
|
|
||||||
<label>{t('common.info')}</label>
|
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{geminiCliFiles.length} {t('auth_files.files_count')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.geminiCliGrid}>
|
|
||||||
{pageItems.map((item) => (
|
|
||||||
<GeminiCliCard
|
|
||||||
key={item.name}
|
|
||||||
item={item}
|
|
||||||
quota={quota[item.name]}
|
|
||||||
resolvedTheme={resolvedTheme}
|
|
||||||
getQuotaErrorMessage={getQuotaErrorMessage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{geminiCliFiles.length > pageSize && (
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToPrev}
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_prev')}
|
|
||||||
</Button>
|
|
||||||
<div className={styles.pageInfo}>
|
|
||||||
{t('auth_files.pagination_info', {
|
|
||||||
current: currentPage,
|
|
||||||
total: totalPages,
|
|
||||||
count: geminiCliFiles.length
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToNext}
|
|
||||||
disabled={currentPage >= totalPages}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { GeminiCliSection } from './GeminiCliSection';
|
|
||||||
export { GeminiCliCard } from './GeminiCliCard';
|
|
||||||
export { useGeminiCliQuota } from './useGeminiCliQuota';
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook for Gemini CLI quota data fetching and management.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import { useQuotaStore } from '@/stores';
|
|
||||||
import type {
|
|
||||||
AuthFileItem,
|
|
||||||
GeminiCliQuotaBucketState,
|
|
||||||
GeminiCliParsedBucket
|
|
||||||
} from '@/types';
|
|
||||||
import {
|
|
||||||
GEMINI_CLI_QUOTA_URL,
|
|
||||||
GEMINI_CLI_REQUEST_HEADERS,
|
|
||||||
normalizeAuthIndexValue,
|
|
||||||
normalizeStringValue,
|
|
||||||
normalizeQuotaFraction,
|
|
||||||
normalizeNumberValue,
|
|
||||||
parseGeminiCliQuotaPayload,
|
|
||||||
resolveGeminiCliProjectId,
|
|
||||||
buildGeminiCliQuotaBuckets,
|
|
||||||
createStatusError,
|
|
||||||
getStatusFromError
|
|
||||||
} from '@/utils/quota';
|
|
||||||
|
|
||||||
interface UseGeminiCliQuotaReturn {
|
|
||||||
quota: Record<string, import('@/types').GeminiCliQuotaState>;
|
|
||||||
loadQuota: (
|
|
||||||
targets: AuthFileItem[],
|
|
||||||
scope: 'page' | 'all',
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeminiCliQuota(): UseGeminiCliQuotaReturn {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
|
|
||||||
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
|
||||||
const requestIdRef = useRef(0);
|
|
||||||
|
|
||||||
const fetchQuota = useCallback(
|
|
||||||
async (file: AuthFileItem): Promise<GeminiCliQuotaBucketState[]> => {
|
|
||||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
|
||||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
|
||||||
if (!authIndex) {
|
|
||||||
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = resolveGeminiCliProjectId(file);
|
|
||||||
if (!projectId) {
|
|
||||||
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await apiCallApi.request({
|
|
||||||
authIndex,
|
|
||||||
method: 'POST',
|
|
||||||
url: GEMINI_CLI_QUOTA_URL,
|
|
||||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
|
||||||
data: JSON.stringify({ project: projectId })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
|
||||||
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
|
||||||
if (buckets.length === 0) return [];
|
|
||||||
|
|
||||||
const parsedBuckets = buckets
|
|
||||||
.map((bucket) => {
|
|
||||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
|
||||||
if (!modelId) return null;
|
|
||||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
|
||||||
const remainingFractionRaw = normalizeQuotaFraction(
|
|
||||||
bucket.remainingFraction ?? bucket.remaining_fraction
|
|
||||||
);
|
|
||||||
const remainingAmount = normalizeNumberValue(
|
|
||||||
bucket.remainingAmount ?? bucket.remaining_amount
|
|
||||||
);
|
|
||||||
const resetTime =
|
|
||||||
normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
|
||||||
let fallbackFraction: number | null = null;
|
|
||||||
if (remainingAmount !== null) {
|
|
||||||
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
|
||||||
} else if (resetTime) {
|
|
||||||
fallbackFraction = 0;
|
|
||||||
}
|
|
||||||
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
|
||||||
return {
|
|
||||||
modelId,
|
|
||||||
tokenType,
|
|
||||||
remainingFraction,
|
|
||||||
remainingAmount,
|
|
||||||
resetTime
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
|
||||||
|
|
||||||
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadQuota = useCallback(
|
|
||||||
async (
|
|
||||||
targets: AuthFileItem[],
|
|
||||||
scope: 'page' | 'all',
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void
|
|
||||||
) => {
|
|
||||||
if (loadingRef.current) return;
|
|
||||||
loadingRef.current = true;
|
|
||||||
const requestId = ++requestIdRef.current;
|
|
||||||
setLoading(true, scope);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (targets.length === 0) return;
|
|
||||||
|
|
||||||
setGeminiCliQuota((prev) => {
|
|
||||||
const nextState = { ...prev };
|
|
||||||
targets.forEach((file) => {
|
|
||||||
nextState[file.name] = { status: 'loading', buckets: [] };
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
targets.map(async (file) => {
|
|
||||||
try {
|
|
||||||
const buckets = await fetchQuota(file);
|
|
||||||
return { name: file.name, status: 'success' as const, buckets };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
|
||||||
const errorStatus = getStatusFromError(err);
|
|
||||||
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requestId !== requestIdRef.current) return;
|
|
||||||
|
|
||||||
setGeminiCliQuota((prev) => {
|
|
||||||
const nextState = { ...prev };
|
|
||||||
results.forEach((result) => {
|
|
||||||
if (result.status === 'success') {
|
|
||||||
nextState[result.name] = {
|
|
||||||
status: 'success',
|
|
||||||
buckets: result.buckets
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
nextState[result.name] = {
|
|
||||||
status: 'error',
|
|
||||||
buckets: [],
|
|
||||||
error: result.error,
|
|
||||||
errorStatus: result.errorStatus
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
loadingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchQuota, setGeminiCliQuota, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { quota: geminiCliQuota, loadQuota };
|
|
||||||
}
|
|
||||||
145
src/components/quota/QuotaCard.tsx
Normal file
145
src/components/quota/QuotaCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Generic quota card component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
|
||||||
|
import { TYPE_COLORS } from '@/utils/quota';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface QuotaStatusState {
|
||||||
|
status: QuotaStatus;
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaProgressBarProps {
|
||||||
|
percent: number | null;
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaProgressBar({
|
||||||
|
percent,
|
||||||
|
highThreshold,
|
||||||
|
mediumThreshold
|
||||||
|
}: QuotaProgressBarProps) {
|
||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, value));
|
||||||
|
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||||
|
const fillClass =
|
||||||
|
normalized === null
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: normalized >= highThreshold
|
||||||
|
? styles.quotaBarFillHigh
|
||||||
|
: normalized >= mediumThreshold
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: styles.quotaBarFillLow;
|
||||||
|
const widthPercent = Math.round(normalized ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaBar}>
|
||||||
|
<div
|
||||||
|
className={`${styles.quotaBarFill} ${fillClass}`}
|
||||||
|
style={{ width: `${widthPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaRenderHelpers {
|
||||||
|
styles: typeof styles;
|
||||||
|
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||||
|
item: AuthFileItem;
|
||||||
|
quota?: TState;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
i18nPrefix: string;
|
||||||
|
cardClassName: string;
|
||||||
|
defaultType: string;
|
||||||
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaCard<TState extends QuotaStatusState>({
|
||||||
|
item,
|
||||||
|
quota,
|
||||||
|
resolvedTheme,
|
||||||
|
i18nPrefix,
|
||||||
|
cardClassName,
|
||||||
|
defaultType,
|
||||||
|
renderQuotaItems
|
||||||
|
}: QuotaCardProps<TState>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const displayType = item.type || item.provider || defaultType;
|
||||||
|
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
||||||
|
const typeColor: ThemeColors =
|
||||||
|
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
||||||
|
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string): string => {
|
||||||
|
const key = `auth_files.filter_${type}`;
|
||||||
|
const translated = t(key);
|
||||||
|
if (translated !== key) return translated;
|
||||||
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.fileCard} ${cardClassName}`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(displayType)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveQuotaErrorMessage = (
|
||||||
|
t: TFunction,
|
||||||
|
status: number | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string => {
|
||||||
|
if (status === 404) return t('common.quota_update_required');
|
||||||
|
if (status === 403) return t('common.quota_check_credential');
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
250
src/components/quota/QuotaSection.tsx
Normal file
250
src/components/quota/QuotaSection.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* Generic quota section component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||||
|
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||||
|
import { QuotaCard } from './QuotaCard';
|
||||||
|
import type { QuotaStatusState } from './QuotaCard';
|
||||||
|
import { useQuotaLoader } from './useQuotaLoader';
|
||||||
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
|
interface QuotaPaginationState<T> {
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageItems: T[];
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
goToPrev: () => void;
|
||||||
|
goToNext: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
loadingScope: 'page' | 'all' | null;
|
||||||
|
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||||
|
const [loading, setLoadingState] = useState(false);
|
||||||
|
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
||||||
|
[items.length, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
|
||||||
|
|
||||||
|
const pageItems = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return items.slice(start, start + pageSize);
|
||||||
|
}, [items, currentPage, pageSize]);
|
||||||
|
|
||||||
|
const setPageSize = useCallback((size: number) => {
|
||||||
|
setPageSizeState(size);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrev = useCallback(() => {
|
||||||
|
setPage((prev) => Math.max(1, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
|
||||||
|
setLoadingState(isLoading);
|
||||||
|
setLoadingScope(isLoading ? (scope ?? null) : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
pageItems,
|
||||||
|
setPageSize,
|
||||||
|
goToPrev,
|
||||||
|
goToNext,
|
||||||
|
loading,
|
||||||
|
loadingScope,
|
||||||
|
setLoading
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
|
||||||
|
config: QuotaConfig<TState, TData>;
|
||||||
|
files: AuthFileItem[];
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||||
|
config,
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
disabled
|
||||||
|
}: QuotaSectionProps<TState, TData>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||||
|
Record<string, TState>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||||
|
files,
|
||||||
|
config.filterFn
|
||||||
|
]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
pageItems,
|
||||||
|
setPageSize,
|
||||||
|
goToPrev,
|
||||||
|
goToNext,
|
||||||
|
loading: sectionLoading,
|
||||||
|
loadingScope,
|
||||||
|
setLoading
|
||||||
|
} = useQuotaPagination(filteredFiles);
|
||||||
|
|
||||||
|
const { quota, loadQuota } = useQuotaLoader(config);
|
||||||
|
|
||||||
|
const handleRefreshPage = useCallback(() => {
|
||||||
|
loadQuota(pageItems, 'page', setLoading);
|
||||||
|
}, [loadQuota, pageItems, setLoading]);
|
||||||
|
|
||||||
|
const handleRefreshAll = useCallback(() => {
|
||||||
|
loadQuota(filteredFiles, 'all', setLoading);
|
||||||
|
}, [loadQuota, filteredFiles, setLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (filteredFiles.length === 0) {
|
||||||
|
setQuota({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState: Record<string, TState> = {};
|
||||||
|
filteredFiles.forEach((file) => {
|
||||||
|
const cached = prev[file.name];
|
||||||
|
if (cached) {
|
||||||
|
nextState[file.name] = cached;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}, [filteredFiles, loading, setQuota]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t(`${config.i18nPrefix}.title`)}
|
||||||
|
extra={
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefreshPage}
|
||||||
|
disabled={disabled || sectionLoading || pageItems.length === 0}
|
||||||
|
loading={sectionLoading && loadingScope === 'page'}
|
||||||
|
>
|
||||||
|
{t(`${config.i18nPrefix}.refresh_button`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
||||||
|
loading={sectionLoading && loadingScope === 'all'}
|
||||||
|
>
|
||||||
|
{t(`${config.i18nPrefix}.fetch_all`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredFiles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t(`${config.i18nPrefix}.empty_title`)}
|
||||||
|
description={t(`${config.i18nPrefix}.empty_desc`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={config.controlsClassName}>
|
||||||
|
<div className={config.controlClassName}>
|
||||||
|
<label>{t('auth_files.page_size_label')}</label>
|
||||||
|
<select
|
||||||
|
className={styles.pageSizeSelect}
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => setPageSize(Number(e.target.value) || 6)}
|
||||||
|
>
|
||||||
|
<option value={6}>6</option>
|
||||||
|
<option value={9}>9</option>
|
||||||
|
<option value={12}>12</option>
|
||||||
|
<option value={18}>18</option>
|
||||||
|
<option value={24}>24</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={config.controlClassName}>
|
||||||
|
<label>{t('common.info')}</label>
|
||||||
|
<div className={styles.statsInfo}>
|
||||||
|
{filteredFiles.length} {t('auth_files.files_count')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={config.gridClassName}>
|
||||||
|
{pageItems.map((item) => (
|
||||||
|
<QuotaCard
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
quota={quota[item.name]}
|
||||||
|
resolvedTheme={resolvedTheme}
|
||||||
|
i18nPrefix={config.i18nPrefix}
|
||||||
|
cardClassName={config.cardClassName}
|
||||||
|
defaultType={config.type}
|
||||||
|
renderQuotaItems={config.renderQuotaItems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filteredFiles.length > pageSize && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrev}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_prev')}
|
||||||
|
</Button>
|
||||||
|
<div className={styles.pageInfo}>
|
||||||
|
{t('auth_files.pagination_info', {
|
||||||
|
current: currentPage,
|
||||||
|
total: totalPages,
|
||||||
|
count: filteredFiles.length
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNext}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared hook for quota section pagination and loading state management.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
|
||||||
|
|
||||||
interface UseQuotaSectionOptions<T> {
|
|
||||||
items: T[];
|
|
||||||
defaultPageSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseQuotaSectionReturn<T> {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
currentPage: number;
|
|
||||||
pageItems: T[];
|
|
||||||
setPage: (page: number) => void;
|
|
||||||
setPageSize: (size: number) => void;
|
|
||||||
goToPrev: () => void;
|
|
||||||
goToNext: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
loadingScope: 'page' | 'all' | null;
|
|
||||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useQuotaSection<T>(
|
|
||||||
options: UseQuotaSectionOptions<T>
|
|
||||||
): UseQuotaSectionReturn<T> {
|
|
||||||
const { items, defaultPageSize = 6 } = options;
|
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
|
||||||
const [loading, setLoadingState] = useState(false);
|
|
||||||
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
|
||||||
|
|
||||||
const totalPages = useMemo(
|
|
||||||
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
|
||||||
[items.length, pageSize]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentPage = useMemo(
|
|
||||||
() => Math.min(page, totalPages),
|
|
||||||
[page, totalPages]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageItems = useMemo(() => {
|
|
||||||
const start = (currentPage - 1) * pageSize;
|
|
||||||
return items.slice(start, start + pageSize);
|
|
||||||
}, [items, currentPage, pageSize]);
|
|
||||||
|
|
||||||
const handleSetPageSize = useCallback((size: number) => {
|
|
||||||
setPageSizeState(size);
|
|
||||||
setPage(1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const goToPrev = useCallback(() => {
|
|
||||||
setPage((p) => Math.max(1, p - 1));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
|
||||||
setPage((p) => Math.min(totalPages, p + 1));
|
|
||||||
}, [totalPages]);
|
|
||||||
|
|
||||||
const setLoading = useCallback(
|
|
||||||
(isLoading: boolean, scope?: 'page' | 'all' | null) => {
|
|
||||||
setLoadingState(isLoading);
|
|
||||||
setLoadingScope(isLoading ? (scope ?? null) : null);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
currentPage,
|
|
||||||
pageItems,
|
|
||||||
setPage,
|
|
||||||
setPageSize: handleSetPageSize,
|
|
||||||
goToPrev,
|
|
||||||
goToNext,
|
|
||||||
loading,
|
|
||||||
loadingScope,
|
|
||||||
setLoading
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
* Quota components barrel export.
|
* Quota components barrel export.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { AntigravitySection } from './AntigravitySection';
|
export { QuotaSection } from './QuotaSection';
|
||||||
export { CodexSection } from './CodexSection';
|
export { QuotaCard } from './QuotaCard';
|
||||||
export { GeminiCliSection } from './GeminiCliSection';
|
export { useQuotaLoader } from './useQuotaLoader';
|
||||||
export { useQuotaSection } from './hooks/useQuotaSection';
|
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||||
|
export type { QuotaConfig } from './quotaConfigs';
|
||||||
|
|||||||
553
src/components/quota/quotaConfigs.ts
Normal file
553
src/components/quota/quotaConfigs.ts
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
/**
|
||||||
|
* Quota configuration definitions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type {
|
||||||
|
AntigravityQuotaGroup,
|
||||||
|
AntigravityModelsPayload,
|
||||||
|
AntigravityQuotaState,
|
||||||
|
AuthFileItem,
|
||||||
|
CodexQuotaState,
|
||||||
|
CodexUsageWindow,
|
||||||
|
CodexQuotaWindow,
|
||||||
|
CodexUsagePayload,
|
||||||
|
GeminiCliParsedBucket,
|
||||||
|
GeminiCliQuotaBucketState,
|
||||||
|
GeminiCliQuotaState
|
||||||
|
} from '@/types';
|
||||||
|
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
|
import {
|
||||||
|
ANTIGRAVITY_QUOTA_URLS,
|
||||||
|
ANTIGRAVITY_REQUEST_HEADERS,
|
||||||
|
CODEX_USAGE_URL,
|
||||||
|
CODEX_REQUEST_HEADERS,
|
||||||
|
GEMINI_CLI_QUOTA_URL,
|
||||||
|
GEMINI_CLI_REQUEST_HEADERS,
|
||||||
|
normalizeAuthIndexValue,
|
||||||
|
normalizeNumberValue,
|
||||||
|
normalizePlanType,
|
||||||
|
normalizeQuotaFraction,
|
||||||
|
normalizeStringValue,
|
||||||
|
parseAntigravityPayload,
|
||||||
|
parseCodexUsagePayload,
|
||||||
|
parseGeminiCliQuotaPayload,
|
||||||
|
resolveCodexChatgptAccountId,
|
||||||
|
resolveCodexPlanType,
|
||||||
|
resolveGeminiCliProjectId,
|
||||||
|
formatCodexResetLabel,
|
||||||
|
formatQuotaResetTime,
|
||||||
|
buildAntigravityQuotaGroups,
|
||||||
|
buildGeminiCliQuotaBuckets,
|
||||||
|
createStatusError,
|
||||||
|
getStatusFromError,
|
||||||
|
isAntigravityFile,
|
||||||
|
isCodexFile,
|
||||||
|
isGeminiCliFile,
|
||||||
|
isRuntimeOnlyAuthFile
|
||||||
|
} from '@/utils/quota';
|
||||||
|
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
export interface QuotaStore {
|
||||||
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
|
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||||
|
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||||
|
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||||
|
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||||
|
clearQuotaCache: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaConfig<TState, TData> {
|
||||||
|
type: QuotaType;
|
||||||
|
i18nPrefix: string;
|
||||||
|
filterFn: (file: AuthFileItem) => boolean;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||||
|
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||||
|
storeSetter: keyof QuotaStore;
|
||||||
|
buildLoadingState: () => TState;
|
||||||
|
buildSuccessState: (data: TData) => TState;
|
||||||
|
buildErrorState: (message: string, status?: number) => TState;
|
||||||
|
cardClassName: string;
|
||||||
|
controlsClassName: string;
|
||||||
|
controlClassName: string;
|
||||||
|
gridClassName: string;
|
||||||
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAntigravityQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<AntigravityQuotaGroup[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = '';
|
||||||
|
let lastStatus: number | undefined;
|
||||||
|
let priorityStatus: number | undefined;
|
||||||
|
let hadSuccess = false;
|
||||||
|
|
||||||
|
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||||
|
try {
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url,
|
||||||
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||||
|
data: '{}'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
lastError = getApiCallErrorMessage(result);
|
||||||
|
lastStatus = result.statusCode;
|
||||||
|
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||||
|
priorityStatus ??= result.statusCode;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hadSuccess = true;
|
||||||
|
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||||
|
const models = payload?.models;
|
||||||
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||||
|
lastError = t('antigravity_quota.empty_models');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
lastError = t('antigravity_quota.empty_models');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
if (status) {
|
||||||
|
lastStatus = status;
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
priorityStatus ??= status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadSuccess) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||||
|
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||||
|
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||||
|
const windows: CodexQuotaWindow[] = [];
|
||||||
|
|
||||||
|
const addWindow = (
|
||||||
|
id: string,
|
||||||
|
labelKey: string,
|
||||||
|
window?: CodexUsageWindow | null,
|
||||||
|
limitReached?: boolean,
|
||||||
|
allowed?: boolean
|
||||||
|
) => {
|
||||||
|
if (!window) return;
|
||||||
|
const resetLabel = formatCodexResetLabel(window);
|
||||||
|
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
||||||
|
const isLimitReached = Boolean(limitReached) || allowed === false;
|
||||||
|
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||||
|
windows.push({
|
||||||
|
id,
|
||||||
|
label: t(labelKey),
|
||||||
|
labelKey,
|
||||||
|
usedPercent,
|
||||||
|
resetLabel
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addWindow(
|
||||||
|
'primary',
|
||||||
|
'codex_quota.primary_window',
|
||||||
|
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||||
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
|
rateLimit?.allowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
'secondary',
|
||||||
|
'codex_quota.secondary_window',
|
||||||
|
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||||
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
|
rateLimit?.allowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
'code-review',
|
||||||
|
'codex_quota.code_review_window',
|
||||||
|
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||||
|
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||||
|
codeReviewLimit?.allowed
|
||||||
|
);
|
||||||
|
|
||||||
|
return windows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCodexQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('codex_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeFromFile = resolveCodexPlanType(file);
|
||||||
|
const accountId = resolveCodexChatgptAccountId(file);
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error(t('codex_quota.missing_account_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHeader: Record<string, string> = {
|
||||||
|
...CODEX_REQUEST_HEADERS,
|
||||||
|
'Chatgpt-Account-Id': accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'GET',
|
||||||
|
url: CODEX_USAGE_URL,
|
||||||
|
header: requestHeader
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error(t('codex_quota.empty_windows'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
||||||
|
const windows = buildCodexQuotaWindows(payload, t);
|
||||||
|
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGeminiCliQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = resolveGeminiCliProjectId(file);
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url: GEMINI_CLI_QUOTA_URL,
|
||||||
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||||
|
data: JSON.stringify({ project: projectId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||||
|
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||||
|
if (buckets.length === 0) return [];
|
||||||
|
|
||||||
|
const parsedBuckets = buckets
|
||||||
|
.map((bucket) => {
|
||||||
|
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||||
|
if (!modelId) return null;
|
||||||
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||||
|
);
|
||||||
|
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
||||||
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||||
|
let fallbackFraction: number | null = null;
|
||||||
|
if (remainingAmount !== null) {
|
||||||
|
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||||
|
} else if (resetTime) {
|
||||||
|
fallbackFraction = 0;
|
||||||
|
}
|
||||||
|
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
||||||
|
return {
|
||||||
|
modelId,
|
||||||
|
tokenType,
|
||||||
|
remainingFraction,
|
||||||
|
remainingAmount,
|
||||||
|
resetTime
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||||
|
|
||||||
|
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAntigravityItems = (
|
||||||
|
quota: AntigravityQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h } = React;
|
||||||
|
const groups = quota.groups ?? [];
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map((group) => {
|
||||||
|
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
||||||
|
const percent = Math.round(clamped * 100);
|
||||||
|
const resetLabel = formatQuotaResetTime(group.resetTime);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: group.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
||||||
|
group.label
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
|
||||||
|
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCodexItems = (
|
||||||
|
quota: CodexQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h, Fragment } = React;
|
||||||
|
const windows = quota.windows ?? [];
|
||||||
|
const planType = quota.planType ?? null;
|
||||||
|
|
||||||
|
const getPlanLabel = (pt?: string | null): string | null => {
|
||||||
|
const normalized = normalizePlanType(pt);
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||||
|
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||||
|
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||||
|
return pt || normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const planLabel = getPlanLabel(planType);
|
||||||
|
const isFreePlan = normalizePlanType(planType) === 'free';
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
|
if (planLabel) {
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'plan', className: styleMap.codexPlan },
|
||||||
|
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||||
|
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFreePlan) {
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'warning', className: styleMap.quotaWarning },
|
||||||
|
t('codex_quota.no_access')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windows.length === 0) {
|
||||||
|
nodes.push(
|
||||||
|
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
||||||
|
);
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
...windows.map((window) => {
|
||||||
|
const used = window.usedPercent;
|
||||||
|
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
||||||
|
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
||||||
|
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
||||||
|
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: window.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h('span', { className: styleMap.quotaModel }, windowLabel),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||||
|
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGeminiCliItems = (
|
||||||
|
quota: GeminiCliQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h } = React;
|
||||||
|
const buckets = quota.buckets ?? [];
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.map((bucket) => {
|
||||||
|
const fraction = bucket.remainingFraction;
|
||||||
|
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||||
|
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||||
|
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||||
|
const remainingAmountLabel =
|
||||||
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||||
|
? null
|
||||||
|
: t('gemini_cli_quota.remaining_amount', {
|
||||||
|
count: bucket.remainingAmount
|
||||||
|
});
|
||||||
|
const titleBase =
|
||||||
|
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||||
|
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||||
|
|
||||||
|
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: bucket.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||||
|
remainingAmountLabel
|
||||||
|
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||||
|
: null,
|
||||||
|
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||||
|
type: 'antigravity',
|
||||||
|
i18nPrefix: 'antigravity_quota',
|
||||||
|
filterFn: (file) => isAntigravityFile(file),
|
||||||
|
fetchQuota: fetchAntigravityQuota,
|
||||||
|
storeSelector: (state) => state.antigravityQuota,
|
||||||
|
storeSetter: 'setAntigravityQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', groups: [] }),
|
||||||
|
buildSuccessState: (groups) => ({ status: 'success', groups }),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
groups: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.antigravityCard,
|
||||||
|
controlsClassName: styles.antigravityControls,
|
||||||
|
controlClassName: styles.antigravityControl,
|
||||||
|
gridClassName: styles.antigravityGrid,
|
||||||
|
renderQuotaItems: renderAntigravityItems
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CODEX_CONFIG: QuotaConfig<
|
||||||
|
CodexQuotaState,
|
||||||
|
{ planType: string | null; windows: CodexQuotaWindow[] }
|
||||||
|
> = {
|
||||||
|
type: 'codex',
|
||||||
|
i18nPrefix: 'codex_quota',
|
||||||
|
filterFn: (file) => isCodexFile(file),
|
||||||
|
fetchQuota: fetchCodexQuota,
|
||||||
|
storeSelector: (state) => state.codexQuota,
|
||||||
|
storeSetter: 'setCodexQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||||
|
buildSuccessState: (data) => ({
|
||||||
|
status: 'success',
|
||||||
|
windows: data.windows,
|
||||||
|
planType: data.planType
|
||||||
|
}),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
windows: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.codexCard,
|
||||||
|
controlsClassName: styles.codexControls,
|
||||||
|
controlClassName: styles.codexControl,
|
||||||
|
gridClassName: styles.codexGrid,
|
||||||
|
renderQuotaItems: renderCodexItems
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||||
|
type: 'gemini-cli',
|
||||||
|
i18nPrefix: 'gemini_cli_quota',
|
||||||
|
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
||||||
|
fetchQuota: fetchGeminiCliQuota,
|
||||||
|
storeSelector: (state) => state.geminiCliQuota,
|
||||||
|
storeSetter: 'setGeminiCliQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||||
|
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
buckets: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.geminiCliCard,
|
||||||
|
controlsClassName: styles.geminiCliControls,
|
||||||
|
controlClassName: styles.geminiCliControl,
|
||||||
|
gridClassName: styles.geminiCliGrid,
|
||||||
|
renderQuotaItems: renderGeminiCliItems
|
||||||
|
};
|
||||||
98
src/components/quota/useQuotaLoader.ts
Normal file
98
src/components/quota/useQuotaLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Generic hook for quota data fetching and management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { useQuotaStore } from '@/stores';
|
||||||
|
import { getStatusFromError } from '@/utils/quota';
|
||||||
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
|
||||||
|
type QuotaScope = 'page' | 'all';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
|
interface LoadQuotaResult<TData> {
|
||||||
|
name: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: TData;
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const quota = useQuotaStore(config.storeSelector);
|
||||||
|
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||||
|
Record<string, TState>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const loadQuota = useCallback(
|
||||||
|
async (
|
||||||
|
targets: AuthFileItem[],
|
||||||
|
scope: QuotaScope,
|
||||||
|
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
|
||||||
|
) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true, scope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
targets.forEach((file) => {
|
||||||
|
nextState[file.name] = config.buildLoadingState();
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
return { name: file.name, status: 'success', data };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const errorStatus = getStatusFromError(err);
|
||||||
|
return { name: file.name, status: 'error', error: message, errorStatus };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'success') {
|
||||||
|
nextState[result.name] = config.buildSuccessState(result.data as TData);
|
||||||
|
} else {
|
||||||
|
nextState[result.name] = config.buildErrorState(
|
||||||
|
result.error || t('common.unknown_error'),
|
||||||
|
result.errorStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, setQuota, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { quota, loadQuota };
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { useAuthStore } from '@/stores';
|
import { useAuthStore } from '@/stores';
|
||||||
import { authFilesApi } from '@/services/api';
|
import { authFilesApi } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
AntigravitySection,
|
QuotaSection,
|
||||||
CodexSection,
|
ANTIGRAVITY_CONFIG,
|
||||||
GeminiCliSection
|
CODEX_CONFIG,
|
||||||
|
GEMINI_CLI_CONFIG
|
||||||
} from '@/components/quota';
|
} from '@/components/quota';
|
||||||
import type { AuthFileItem } from '@/types';
|
import type { AuthFileItem } from '@/types';
|
||||||
import styles from './QuotaPage.module.scss';
|
import styles from './QuotaPage.module.scss';
|
||||||
@@ -57,9 +58,24 @@ export function QuotaPage() {
|
|||||||
|
|
||||||
{error && <div className={styles.errorBox}>{error}</div>}
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
<AntigravitySection files={files} loading={loading} disabled={disableControls} />
|
<QuotaSection
|
||||||
<CodexSection files={files} loading={loading} disabled={disableControls} />
|
config={ANTIGRAVITY_CONFIG}
|
||||||
<GeminiCliSection files={files} loading={loading} disabled={disableControls} />
|
files={files}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableControls}
|
||||||
|
/>
|
||||||
|
<QuotaSection
|
||||||
|
config={CODEX_CONFIG}
|
||||||
|
files={files}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableControls}
|
||||||
|
/>
|
||||||
|
<QuotaSection
|
||||||
|
config={GEMINI_CLI_CONFIG}
|
||||||
|
files={files}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableControls}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user