From 7d41afb5f1d116bdc54098f4361452c8948b90cf Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Thu, 5 Feb 2026 02:22:23 +0800 Subject: [PATCH] feat(auth-files): add quota management features and enhance UI layout --- src/i18n/locales/en.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/pages/AuthFilesPage.module.scss | 72 +++++ src/pages/AuthFilesPage.tsx | 450 +++++++++++++++++++++------- 4 files changed, 423 insertions(+), 113 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e6155e3..46fbd2b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -416,7 +416,12 @@ "prefix_placeholder": "", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", - "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully" + "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully", + "card_tools_title": "Tools", + "quota_refresh_single": "Refresh quota", + "quota_refresh_hint": "Refresh quota for this credential only", + "quota_refresh_success": "Quota refreshed for \"{{name}}\"", + "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}" }, "antigravity_quota": { "title": "Antigravity Quota", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 10f85c6..6bcf788 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -416,7 +416,12 @@ "prefix_placeholder": "", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。", - "prefix_proxy_saved_success": "已更新 \"{{name}}\"" + "prefix_proxy_saved_success": "已更新 \"{{name}}\"", + "card_tools_title": "配置管理", + "quota_refresh_single": "刷新额度", + "quota_refresh_hint": "仅刷新当前凭证的额度数据", + "quota_refresh_success": "已刷新 \"{{name}}\" 的额度", + "quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}" }, "antigravity_quota": { "title": "Antigravity 额度", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 6bfb681..471ae0c 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -184,6 +184,18 @@ } } +.fileGridQuotaManaged { + grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); + + @include tablet { + grid-template-columns: 1fr; + } + + @include mobile { + grid-template-columns: 1fr; + } +} + .antigravityGrid { display: grid; gap: $spacing-md; @@ -469,6 +481,66 @@ } } +.fileCardLayout { + display: flex; + align-items: stretch; + gap: $spacing-md; +} + +.fileCardLayoutQuota { + display: grid; + grid-template-columns: 1fr 156px; + gap: $spacing-md; + align-items: stretch; + + @include mobile { + grid-template-columns: 1fr; + } +} + +.fileCardMain { + display: flex; + flex-direction: column; + gap: $spacing-sm; + flex: 1; + min-width: 0; +} + +.fileCardSidebar { + display: flex; + flex-direction: column; + gap: $spacing-sm; + padding-left: $spacing-md; + border-left: 1px dashed var(--border-color); + + @include mobile { + border-left: none; + border-top: 1px dashed var(--border-color); + padding-left: 0; + padding-top: $spacing-md; + } +} + +.fileCardSidebarHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-xs; +} + +.fileCardSidebarTitle { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.fileCardSidebarHint { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + .cardHeader { display: flex; align-items: center; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 8cc7785..f6cb03b 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useEffect, useMemo, useRef, useState, useCallback, type ReactNode } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useInterval } from '@/hooks/useInterval'; @@ -17,12 +17,16 @@ import { IconChevronUp, IconDownload, IconInfo, + IconRefreshCw, IconTrash2, } from '@/components/ui/icons'; -import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; +import type { TFunction } from 'i18next'; +import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota'; +import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem, OAuthModelAliasEntry } from '@/types'; +import { getStatusFromError, resolveAuthProvider } from '@/utils/quota'; import { calculateStatusBarData, collectUsageDetails, @@ -91,6 +95,49 @@ const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState'; const clampCardPageSize = (value: number) => Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value))); +type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli'; + +const QUOTA_PROVIDER_TYPES = new Set(['antigravity', 'codex', 'gemini-cli']); + +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; +}; + +type QuotaProgressBarProps = { + percent: number | null; + highThreshold: number; + mediumThreshold: number; +}; + +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 ( +
+
+
+ ); +} + type AuthFilesUiState = { filter?: string; search?: string; @@ -195,6 +242,12 @@ export function AuthFilesPage() { const { showNotification, showConfirmation } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); + const antigravityQuota = useQuotaStore((state) => state.antigravityQuota); + const codexQuota = useQuotaStore((state) => state.codexQuota); + const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota); + const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota); + const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); + const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota); const navigate = useNavigate(); const [files, setFiles] = useState([]); @@ -248,6 +301,12 @@ export function AuthFilesPage() { const normalizeProviderKey = (value: string) => value.trim().toLowerCase(); const disableControls = connectionStatus !== 'connected'; + const normalizedFilter = normalizeProviderKey(String(filter)); + const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has( + normalizedFilter as QuotaProviderType + ) + ? (normalizedFilter as QuotaProviderType) + : null; const providerList = useMemo(() => { const providers = new Set(); @@ -1397,6 +1456,124 @@ export function AuthFilesPage() { ); }; + const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => { + const provider = resolveAuthProvider(file); + if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null; + return provider as QuotaProviderType; + }; + + const getQuotaConfig = (type: QuotaProviderType) => { + if (type === 'antigravity') return ANTIGRAVITY_CONFIG; + if (type === 'codex') return CODEX_CONFIG; + return GEMINI_CLI_CONFIG; + }; + + const getQuotaState = (type: QuotaProviderType, fileName: string) => { + if (type === 'antigravity') return antigravityQuota[fileName]; + if (type === 'codex') return codexQuota[fileName]; + return geminiCliQuota[fileName]; + }; + + const updateQuotaState = useCallback( + ( + type: QuotaProviderType, + updater: (prev: Record) => Record + ) => { + if (type === 'antigravity') { + setAntigravityQuota(updater as never); + return; + } + if (type === 'codex') { + setCodexQuota(updater as never); + return; + } + setGeminiCliQuota(updater as never); + }, + [setAntigravityQuota, setCodexQuota, setGeminiCliQuota] + ); + + const refreshQuotaForFile = useCallback( + async (file: AuthFileItem, quotaType: QuotaProviderType) => { + if (disableControls) return; + if (isRuntimeOnlyAuthFile(file)) return; + if (file.disabled) return; + + const currentState = getQuotaState(quotaType, file.name); + if (currentState?.status === 'loading') return; + + const config = getQuotaConfig(quotaType) as unknown as { + i18nPrefix: string; + fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + buildLoadingState: () => unknown; + buildSuccessState: (data: unknown) => unknown; + buildErrorState: (message: string, status?: number) => unknown; + }; + + updateQuotaState(quotaType, (prev) => ({ + ...prev, + [file.name]: config.buildLoadingState() + })); + + try { + const data = await config.fetchQuota(file, t); + updateQuotaState(quotaType, (prev) => ({ + ...prev, + [file.name]: config.buildSuccessState(data) + })); + showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + const status = getStatusFromError(err); + updateQuotaState(quotaType, (prev) => ({ + ...prev, + [file.name]: config.buildErrorState(message, status) + })); + showNotification( + t('auth_files.quota_refresh_failed', { name: file.name, message }), + 'error' + ); + } + }, + [disableControls, getQuotaState, showNotification, t, updateQuotaState] + ); + + const renderQuotaSection = (item: AuthFileItem, quotaType: QuotaProviderType) => { + const config = getQuotaConfig(quotaType) as unknown as { + i18nPrefix: string; + renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown; + }; + + const quota = getQuotaState(quotaType, item.name) as + | { status?: string; error?: string; errorStatus?: number } + | undefined; + const quotaStatus = quota?.status ?? 'idle'; + const quotaErrorMessage = resolveQuotaErrorMessage( + t, + quota?.errorStatus, + quota?.error || t('common.unknown_error') + ); + + return ( +
+ {quotaStatus === 'loading' ? ( +
{t(`${config.i18nPrefix}.loading`)}
+ ) : quotaStatus === 'idle' ? ( +
{t(`${config.i18nPrefix}.idle`)}
+ ) : quotaStatus === 'error' ? ( +
+ {t(`${config.i18nPrefix}.load_failed`, { + message: quotaErrorMessage + })} +
+ ) : quota ? ( + (config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode) + ) : ( +
{t(`${config.i18nPrefix}.idle`)}
+ )} +
+ ); + }; + // 渲染单个认证文件卡片 const renderFileCard = (item: AuthFileItem) => { const fileStats = resolveAuthFileStats(item, keyStats); @@ -1405,120 +1582,167 @@ export function AuthFilesPage() { const showModelsButton = !isRuntimeOnly || isAistudio; const typeColor = getTypeColor(item.type || 'unknown'); + const quotaType = + quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null; + + const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly; + const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined; + const quotaRefreshing = quotaState?.status === 'loading'; + + const providerCardClass = + quotaType === 'antigravity' + ? styles.antigravityCard + : quotaType === 'codex' + ? styles.codexCard + : quotaType === 'gemini-cli' + ? styles.geminiCliCard + : ''; + return (
-
- - {getTypeLabel(item.type || 'unknown')} - - {item.name} -
- -
- - {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} - - - {t('auth_files.file_modified')}: {formatModified(item)} - -
- -
- - {t('stats.success')}: {fileStats.success} - - - {t('stats.failure')}: {fileStats.failure} - -
- - {/* 状态监测栏 */} - {renderStatusBar(item)} - -
- {showModelsButton && ( - - )} - {!isRuntimeOnly && ( - <> - - - - - - )} - {!isRuntimeOnly && ( -
- void handleStatusToggle(item, value)} - /> + {getTypeLabel(item.type || 'unknown')} + + {item.name}
- )} - {isRuntimeOnly && ( -
- {t('auth_files.type_virtual') || '虚拟认证文件'} + +
+ + {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} + + + {t('auth_files.file_modified')}: {formatModified(item)} + +
+ +
+ + {t('stats.success')}: {fileStats.success} + + + {t('stats.failure')}: {fileStats.failure} + +
+ + {/* 状态监测栏 */} + {renderStatusBar(item)} + + {showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)} + +
+ {showModelsButton && ( + + )} + {!isRuntimeOnly && ( + <> + + + + + + )} + {!isRuntimeOnly && ( +
+ void handleStatusToggle(item, value)} + /> +
+ )} + {isRuntimeOnly && ( +
+ {t('auth_files.type_virtual') || '虚拟认证文件'} +
+ )} +
+
+ + {showQuotaLayout && quotaType && ( +
+
+ + {t('auth_files.card_tools_title')} + + +
+
{t('auth_files.quota_refresh_hint')}
)}
@@ -1625,7 +1849,11 @@ export function AuthFilesPage() { description={t('auth_files.search_empty_desc')} /> ) : ( -
{pageItems.map(renderFileCard)}
+
+ {pageItems.map(renderFileCard)} +
)} {/* 分页 */}