Skip to content

Commit 1b267d9

Browse files
committed
feat(quota): load quota page from cached snapshots
Hydrate quota cards from backend cache snapshots, route manual refresh actions through quota-cache APIs, and surface cache metadata in the management UI.
1 parent 4c9845c commit 1b267d9

13 files changed

Lines changed: 774 additions & 132 deletions

File tree

src/components/quota/QuotaCard.tsx

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
/**
2-
* Generic quota card component.
2+
* 通用 quota 卡片组件。
33
*/
44

55
import { useTranslation } from 'react-i18next';
66
import type { ReactElement, ReactNode } from 'react';
77
import type { TFunction } from 'i18next';
8-
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
9-
import { TYPE_COLORS } from '@/utils/quota';
8+
import type {
9+
AuthFileItem,
10+
QuotaCacheStatus,
11+
QuotaStateBase,
12+
ResolvedTheme,
13+
ThemeColors
14+
} from '@/types';
15+
import { formatQuotaResetTime, TYPE_COLORS } from '@/utils/quota';
1016
import styles from '@/pages/QuotaPage.module.scss';
1117

12-
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
13-
14-
export interface QuotaStatusState {
15-
status: QuotaStatus;
16-
error?: string;
17-
errorStatus?: number;
18-
}
18+
export type QuotaStatusState = QuotaStateBase;
1919

2020
export interface QuotaProgressBarProps {
2121
percent: number | null;
@@ -69,6 +69,28 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
6969
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
7070
}
7171

72+
/**
73+
* 根据后端缓存状态选择对应的徽标样式,方便在卡片底部统一展示缓存健康度。
74+
*/
75+
const resolveCacheStatusClassName = (status: QuotaCacheStatus): string => {
76+
switch (status) {
77+
case 'fresh':
78+
return styles.quotaCacheBadgeFresh;
79+
case 'refreshing':
80+
return styles.quotaCacheBadgeRefreshing;
81+
case 'rate_limited':
82+
return styles.quotaCacheBadgeRateLimited;
83+
case 'unauthorized':
84+
return styles.quotaCacheBadgeUnauthorized;
85+
case 'error':
86+
return styles.quotaCacheBadgeError;
87+
case 'pending':
88+
return styles.quotaCacheBadgePending;
89+
default:
90+
return '';
91+
}
92+
};
93+
7294
export function QuotaCard<TState extends QuotaStatusState>({
7395
item,
7496
quota,
@@ -95,6 +117,11 @@ export function QuotaCard<TState extends QuotaStatusState>({
95117
quota?.error || t('common.unknown_error')
96118
);
97119
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
120+
const cacheStatus = quota?.cacheStatus;
121+
const cacheStatusLabel = cacheStatus ? t(`quota_management.cache_status_${cacheStatus}`) : null;
122+
const lastRefreshLabel = quota?.lastRefreshAt ? formatQuotaResetTime(quota.lastRefreshAt) : null;
123+
const quotaRecoverLabel = quota?.quotaRecoverAt ? formatQuotaResetTime(quota.quotaRecoverAt) : null;
124+
const showCacheMeta = Boolean(cacheStatusLabel || lastRefreshLabel || quotaRecoverLabel);
98125

99126
const getTypeLabel = (type: string): string => {
100127
const key = `auth_files.filter_${type}`;
@@ -148,6 +175,33 @@ export function QuotaCard<TState extends QuotaStatusState>({
148175
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
149176
)}
150177
</div>
178+
179+
{showCacheMeta && (
180+
<div className={styles.quotaCacheMeta}>
181+
{cacheStatusLabel && cacheStatus && (
182+
<div className={styles.quotaCacheItem}>
183+
<span className={styles.quotaCacheLabel}>{t('quota_management.cache_status_label')}</span>
184+
<span
185+
className={`${styles.quotaCacheBadge} ${resolveCacheStatusClassName(cacheStatus)}`}
186+
>
187+
{cacheStatusLabel}
188+
</span>
189+
</div>
190+
)}
191+
{lastRefreshLabel && (
192+
<div className={styles.quotaCacheItem}>
193+
<span className={styles.quotaCacheLabel}>{t('quota_management.last_refresh')}</span>
194+
<span className={styles.quotaCacheValue}>{lastRefreshLabel}</span>
195+
</div>
196+
)}
197+
{quotaRecoverLabel && (
198+
<div className={styles.quotaCacheItem}>
199+
<span className={styles.quotaCacheLabel}>{t('quota_management.quota_recover_at')}</span>
200+
<span className={styles.quotaCacheValue}>{quotaRecoverLabel}</span>
201+
</div>
202+
)}
203+
</div>
204+
)}
151205
</div>
152206
);
153207
}

src/components/quota/QuotaSection.tsx

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
/**
2-
* Generic quota section component.
2+
* 通用 quota 分区组件。
33
*/
44

5-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5+
import { useCallback, useEffect, useMemo, useState } from 'react';
66
import { useTranslation } from 'react-i18next';
77
import { Card } from '@/components/ui/Card';
88
import { Button } from '@/components/ui/Button';
99
import { EmptyState } from '@/components/ui/EmptyState';
10-
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
1110
import { useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
1211
import type { AuthFileItem, ResolvedTheme } from '@/types';
13-
import { getStatusFromError } from '@/utils/quota';
1412
import { QuotaCard } from './QuotaCard';
1513
import type { QuotaStatusState } from './QuotaCard';
1614
import { useQuotaLoader } from './useQuotaLoader';
@@ -111,8 +109,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
111109
Record<string, TState>
112110
>;
113111

114-
/* Removed useRef */
115-
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
112+
const [columns, gridRef] = useGridColumns(380);
116113
const [viewMode, setViewMode] = useState<ViewMode>('paged');
117114
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
118115

@@ -151,40 +148,25 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
151148
};
152149
}, [showAllAllowed, viewMode]);
153150

154-
// Update page size based on view mode and columns
155151
useEffect(() => {
156152
if (effectiveViewMode === 'all') {
157153
setPageSize(Math.max(1, filteredFiles.length));
158154
} else {
159-
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
160155
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
161156
}
162157
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
163158

164159
const { quota, loadQuota } = useQuotaLoader(config);
165160

166-
const pendingQuotaRefreshRef = useRef(false);
167-
const prevFilesLoadingRef = useRef(loading);
168-
169-
const handleRefresh = useCallback(() => {
170-
pendingQuotaRefreshRef.current = true;
171-
void triggerHeaderRefresh();
172-
}, []);
173-
174-
useEffect(() => {
175-
const wasLoading = prevFilesLoadingRef.current;
176-
prevFilesLoadingRef.current = loading;
177-
178-
if (!pendingQuotaRefreshRef.current) return;
179-
if (loading) return;
180-
if (!wasLoading) return;
181-
182-
pendingQuotaRefreshRef.current = false;
183-
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
184-
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
185-
if (targets.length === 0) return;
186-
loadQuota(targets, scope, setLoading);
187-
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
161+
const refreshScope = effectiveViewMode === 'all' ? 'all' : 'page';
162+
const refreshTargets = useMemo(
163+
() => (refreshScope === 'all' ? filteredFiles : pageItems),
164+
[filteredFiles, pageItems, refreshScope]
165+
);
166+
const refreshButtonLabel =
167+
refreshScope === 'all'
168+
? t('quota_management.refresh_all_credentials')
169+
: t('quota_management.refresh_current_page_credentials');
188170

189171
useEffect(() => {
190172
if (loading) return;
@@ -204,37 +186,46 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
204186
});
205187
}, [filteredFiles, loading, setQuota]);
206188

189+
const handleRefresh = useCallback(async () => {
190+
if (disabled || refreshTargets.length === 0) return;
191+
192+
try {
193+
await loadQuota(refreshTargets, refreshScope, setLoading);
194+
showNotification(
195+
t('quota_management.refresh_selection_success', { count: refreshTargets.length }),
196+
'success'
197+
);
198+
} catch (err: unknown) {
199+
const message = err instanceof Error ? err.message : t('common.unknown_error');
200+
showNotification(t('quota_management.refresh_selection_failed', { message }), 'error');
201+
}
202+
}, [
203+
disabled,
204+
loadQuota,
205+
refreshScope,
206+
refreshTargets,
207+
setLoading,
208+
showNotification,
209+
t
210+
]);
211+
207212
const refreshQuotaForFile = useCallback(
208213
async (file: AuthFileItem) => {
209214
if (disabled || file.disabled) return;
210215
if (quota[file.name]?.status === 'loading') return;
211216

212-
setQuota((prev) => ({
213-
...prev,
214-
[file.name]: config.buildLoadingState()
215-
}));
216-
217217
try {
218-
const data = await config.fetchQuota(file, t);
219-
setQuota((prev) => ({
220-
...prev,
221-
[file.name]: config.buildSuccessState(data)
222-
}));
218+
await loadQuota([file], 'page', setLoading);
223219
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
224220
} catch (err: unknown) {
225221
const message = err instanceof Error ? err.message : t('common.unknown_error');
226-
const status = getStatusFromError(err);
227-
setQuota((prev) => ({
228-
...prev,
229-
[file.name]: config.buildErrorState(message, status)
230-
}));
231222
showNotification(
232223
t('auth_files.quota_refresh_failed', { name: file.name, message }),
233224
'error'
234225
);
235226
}
236227
},
237-
[config, disabled, quota, setQuota, showNotification, t]
228+
[disabled, loadQuota, quota, setLoading, showNotification, t]
238229
);
239230

240231
const titleNode = (
@@ -287,14 +278,14 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
287278
variant="secondary"
288279
size="sm"
289280
className={styles.refreshAllButton}
290-
onClick={handleRefresh}
291-
disabled={disabled || isRefreshing}
281+
onClick={() => void handleRefresh()}
282+
disabled={disabled || isRefreshing || refreshTargets.length === 0}
292283
loading={isRefreshing}
293-
title={t('quota_management.refresh_all_credentials')}
294-
aria-label={t('quota_management.refresh_all_credentials')}
284+
title={refreshButtonLabel}
285+
aria-label={refreshButtonLabel}
295286
>
296287
{!isRefreshing && <IconRefreshCw size={16} />}
297-
{t('quota_management.refresh_all_credentials')}
288+
{refreshButtonLabel}
298289
</Button>
299290
</div>
300291
}

src/components/quota/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,13 @@
55
export { QuotaSection } from './QuotaSection';
66
export { QuotaCard } from './QuotaCard';
77
export { useQuotaLoader } from './useQuotaLoader';
8-
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from './quotaConfigs';
8+
export {
9+
ANTIGRAVITY_CONFIG,
10+
CLAUDE_CONFIG,
11+
CODEX_CONFIG,
12+
GEMINI_CLI_CONFIG,
13+
KIMI_CONFIG,
14+
buildQuotaStateMapFromEntries,
15+
buildQuotaStoresFromSnapshot,
16+
} from './quotaConfigs';
917
export type { QuotaConfig } from './quotaConfigs';

0 commit comments

Comments
 (0)