From e13d7f5e0f548dfe5be98ffe0314dbc375544b73 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 14 Feb 2026 00:11:41 +0800 Subject: [PATCH] refactor(auth-files): split AuthFilesPage --- src/features/authFiles/clipboard.ts | 28 + .../authFiles/components/AuthFileCard.tsx | 240 ++ .../components/AuthFileDetailModal.tsx | 47 + .../components/AuthFileModelsModal.tsx | 91 + .../components/AuthFileQuotaSection.tsx | 124 + .../AuthFilesPrefixProxyEditorModal.tsx | 125 + .../components/OAuthExcludedCard.tsx | 65 + .../components/OAuthModelAliasCard.tsx | 152 ++ .../authFiles/components/QuotaProgressBar.tsx | 28 + src/features/authFiles/constants.ts | 236 ++ .../authFiles/hooks/useAuthFilesData.ts | 318 +++ .../authFiles/hooks/useAuthFilesModels.ts | 86 + .../authFiles/hooks/useAuthFilesOauth.tsx | 504 ++++ .../hooks/useAuthFilesPrefixProxyEditor.ts | 254 ++ .../authFiles/hooks/useAuthFilesStats.ts | 35 + .../hooks/useAuthFilesStatusBarCache.ts | 28 + src/features/authFiles/uiState.ts | 30 + src/pages/AuthFilesPage.tsx | 2265 ++--------------- 18 files changed, 2594 insertions(+), 2062 deletions(-) create mode 100644 src/features/authFiles/clipboard.ts create mode 100644 src/features/authFiles/components/AuthFileCard.tsx create mode 100644 src/features/authFiles/components/AuthFileDetailModal.tsx create mode 100644 src/features/authFiles/components/AuthFileModelsModal.tsx create mode 100644 src/features/authFiles/components/AuthFileQuotaSection.tsx create mode 100644 src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx create mode 100644 src/features/authFiles/components/OAuthExcludedCard.tsx create mode 100644 src/features/authFiles/components/OAuthModelAliasCard.tsx create mode 100644 src/features/authFiles/components/QuotaProgressBar.tsx create mode 100644 src/features/authFiles/constants.ts create mode 100644 src/features/authFiles/hooks/useAuthFilesData.ts create mode 100644 src/features/authFiles/hooks/useAuthFilesModels.ts create mode 100644 src/features/authFiles/hooks/useAuthFilesOauth.tsx create mode 100644 src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts create mode 100644 src/features/authFiles/hooks/useAuthFilesStats.ts create mode 100644 src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts create mode 100644 src/features/authFiles/uiState.ts diff --git a/src/features/authFiles/clipboard.ts b/src/features/authFiles/clipboard.ts new file mode 100644 index 0000000..21435ce --- /dev/null +++ b/src/features/authFiles/clipboard.ts @@ -0,0 +1,28 @@ +export const copyToClipboard = async (text: string): Promise => { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fallback below + } + + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + return copied; + } catch { + return false; + } +}; + diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx new file mode 100644 index 0000000..c269c65 --- /dev/null +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -0,0 +1,240 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; +import type { AuthFileItem } from '@/types'; +import { resolveAuthProvider } from '@/utils/quota'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { formatFileSize } from '@/utils/format'; +import { + QUOTA_PROVIDER_TYPES, + formatModified, + getTypeColor, + getTypeLabel, + isRuntimeOnlyAuthFile, + normalizeAuthIndexValue, + resolveAuthFileStats, + type QuotaProviderType, + type ResolvedTheme +} from '@/features/authFiles/constants'; +import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache'; +import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection'; +import styles from '@/pages/AuthFilesPage.module.scss'; + +export type AuthFileCardProps = { + file: AuthFileItem; + resolvedTheme: ResolvedTheme; + disableControls: boolean; + deleting: string | null; + statusUpdating: Record; + quotaFilterType: QuotaProviderType | null; + keyStats: KeyStats; + statusBarCache: Map; + onShowModels: (file: AuthFileItem) => void; + onShowDetails: (file: AuthFileItem) => void; + onDownload: (name: string) => void; + onOpenPrefixProxyEditor: (name: string) => void; + onDelete: (name: string) => void; + onToggleStatus: (file: AuthFileItem, enabled: boolean) => void; +}; + +const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => { + const provider = resolveAuthProvider(file); + if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null; + return provider as QuotaProviderType; +}; + +export function AuthFileCard(props: AuthFileCardProps) { + const { t } = useTranslation(); + const { + file, + resolvedTheme, + disableControls, + deleting, + statusUpdating, + quotaFilterType, + keyStats, + statusBarCache, + onShowModels, + onShowDetails, + onDownload, + onOpenPrefixProxyEditor, + onDelete, + onToggleStatus + } = props; + + const fileStats = resolveAuthFileStats(file, keyStats); + const isRuntimeOnly = isRuntimeOnlyAuthFile(file); + const isAistudio = (file.type || '').toLowerCase() === 'aistudio'; + const showModelsButton = !isRuntimeOnly || isAistudio; + const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme); + + const quotaType = + quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null; + + const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly; + + const providerCardClass = + quotaType === 'antigravity' + ? styles.antigravityCard + : quotaType === 'codex' + ? styles.codexCard + : quotaType === 'gemini-cli' + ? styles.geminiCliCard + : ''; + + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndexKey = normalizeAuthIndexValue(rawAuthIndex); + const statusData = + (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]); + const hasData = statusData.totalSuccess + statusData.totalFailure > 0; + const rateClass = !hasData + ? '' + : statusData.successRate >= 90 + ? styles.statusRateHigh + : statusData.successRate >= 50 + ? styles.statusRateMedium + : styles.statusRateLow; + + return ( +
+
+
+
+ + {getTypeLabel(t, file.type || 'unknown')} + + {file.name} +
+ +
+ + {t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'} + + + {t('auth_files.file_modified')}: {formatModified(file)} + +
+ +
+ + {t('stats.success')}: {fileStats.success} + + + {t('stats.failure')}: {fileStats.failure} + +
+ +
+
+ {statusData.blocks.map((state, idx) => { + const blockClass = + state === 'success' + ? styles.statusBlockSuccess + : state === 'failure' + ? styles.statusBlockFailure + : state === 'mixed' + ? styles.statusBlockMixed + : styles.statusBlockIdle; + return
; + })} +
+ + {hasData ? `${statusData.successRate.toFixed(1)}%` : '--'} + +
+ + {showQuotaLayout && quotaType && ( + + )} + +
+ {showModelsButton && ( + + )} + {!isRuntimeOnly && ( + <> + + + + + + )} + {!isRuntimeOnly && ( +
+ onToggleStatus(file, value)} + /> +
+ )} + {isRuntimeOnly && ( +
{t('auth_files.type_virtual') || '虚拟认证文件'}
+ )} +
+
+
+
+ ); +} diff --git a/src/features/authFiles/components/AuthFileDetailModal.tsx b/src/features/authFiles/components/AuthFileDetailModal.tsx new file mode 100644 index 0000000..b6012ba --- /dev/null +++ b/src/features/authFiles/components/AuthFileDetailModal.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import type { AuthFileItem } from '@/types'; +import styles from '@/pages/AuthFilesPage.module.scss'; + +export type AuthFileDetailModalProps = { + open: boolean; + file: AuthFileItem | null; + onClose: () => void; + onCopyText: (text: string) => void; +}; + +export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) { + const { t } = useTranslation(); + + return ( + + + + + } + > + {file && ( +
+
{JSON.stringify(file, null, 2)}
+
+ )} +
+ ); +} + diff --git a/src/features/authFiles/components/AuthFileModelsModal.tsx b/src/features/authFiles/components/AuthFileModelsModal.tsx new file mode 100644 index 0000000..93aeae5 --- /dev/null +++ b/src/features/authFiles/components/AuthFileModelsModal.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { EmptyState } from '@/components/ui/EmptyState'; +import type { AuthFileModelItem } from '@/features/authFiles/constants'; +import { isModelExcluded } from '@/features/authFiles/constants'; +import styles from '@/pages/AuthFilesPage.module.scss'; + +export type AuthFileModelsModalProps = { + open: boolean; + fileName: string; + fileType: string; + loading: boolean; + error: 'unsupported' | null; + models: AuthFileModelItem[]; + excluded: Record; + onClose: () => void; + onCopyText: (text: string) => void; +}; + +export function AuthFileModelsModal(props: AuthFileModelsModalProps) { + const { t } = useTranslation(); + const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props; + + return ( + + {t('common.close')} + + } + > + {loading ? ( +
+ {t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })} +
+ ) : error === 'unsupported' ? ( + + ) : models.length === 0 ? ( + + ) : ( +
+ {models.map((model) => { + const excludedModel = isModelExcluded(model.id, fileType, excluded); + return ( +
{ + onCopyText(model.id); + }} + title={ + excludedModel + ? t('auth_files.models_excluded_hint', { + defaultValue: '此 OAuth 模型已被禁用' + }) + : t('common.copy', { defaultValue: '点击复制' }) + } + > + {model.id} + {model.display_name && model.display_name !== model.id && ( + {model.display_name} + )} + {model.type && {model.type}} + {excludedModel && ( + + {t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })} + + )} +
+ ); + })} +
+ )} +
+ ); +} + diff --git a/src/features/authFiles/components/AuthFileQuotaSection.tsx b/src/features/authFiles/components/AuthFileQuotaSection.tsx new file mode 100644 index 0000000..95d933b --- /dev/null +++ b/src/features/authFiles/components/AuthFileQuotaSection.tsx @@ -0,0 +1,124 @@ +import { useCallback, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota'; +import { useNotificationStore, useQuotaStore } from '@/stores'; +import type { AuthFileItem } from '@/types'; +import { getStatusFromError } from '@/utils/quota'; +import { + isRuntimeOnlyAuthFile, + resolveQuotaErrorMessage, + type QuotaProviderType +} from '@/features/authFiles/constants'; +import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar'; +import styles from '@/pages/AuthFilesPage.module.scss'; + +type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined; + +const getQuotaConfig = (type: QuotaProviderType) => { + if (type === 'antigravity') return ANTIGRAVITY_CONFIG; + if (type === 'codex') return CODEX_CONFIG; + return GEMINI_CLI_CONFIG; +}; + +export type AuthFileQuotaSectionProps = { + file: AuthFileItem; + quotaType: QuotaProviderType; + disableControls: boolean; +}; + +export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) { + const { file, quotaType, disableControls } = props; + const { t } = useTranslation(); + const showNotification = useNotificationStore((state) => state.showNotification); + + const quota = useQuotaStore((state) => { + if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState; + if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState; + return state.geminiCliQuota[file.name] as QuotaState; + }); + + const updateQuotaState = useQuotaStore((state) => { + if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void; + if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void; + return state.setGeminiCliQuota as unknown as (updater: unknown) => void; + }); + + const refreshQuotaForFile = useCallback(async () => { + if (disableControls) return; + if (isRuntimeOnlyAuthFile(file)) return; + if (file.disabled) return; + if (quota?.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; + renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown; + }; + + updateQuotaState((prev: Record) => ({ + ...prev, + [file.name]: config.buildLoadingState() + })); + + try { + const data = await config.fetchQuota(file, t); + updateQuotaState((prev: Record) => ({ + ...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((prev: Record) => ({ + ...prev, + [file.name]: config.buildErrorState(message, status) + })); + showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error'); + } + }, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]); + + const config = getQuotaConfig(quotaType) as unknown as { + i18nPrefix: string; + renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown; + }; + + const quotaStatus = quota?.status ?? 'idle'; + const canRefreshQuota = !disableControls && !file.disabled; + const quotaErrorMessage = resolveQuotaErrorMessage( + t, + quota?.errorStatus, + quota?.error || t('common.unknown_error') + ); + + return ( +
+ {quotaStatus === 'loading' ? ( +
{t(`${config.i18nPrefix}.loading`)}
+ ) : quotaStatus === 'idle' ? ( + + ) : quotaStatus === 'error' ? ( +
+ {t(`${config.i18nPrefix}.load_failed`, { + message: quotaErrorMessage + })} +
+ ) : quota ? ( + (config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode) + ) : ( +
{t(`${config.i18nPrefix}.idle`)}
+ )} +
+ ); +} diff --git a/src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx b/src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx new file mode 100644 index 0000000..72e117f --- /dev/null +++ b/src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx @@ -0,0 +1,125 @@ +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { Input } from '@/components/ui/Input'; +import type { + PrefixProxyEditorField, + PrefixProxyEditorState +} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor'; +import styles from '@/pages/AuthFilesPage.module.scss'; + +export type AuthFilesPrefixProxyEditorModalProps = { + disableControls: boolean; + editor: PrefixProxyEditorState | null; + updatedText: string; + dirty: boolean; + onClose: () => void; + onSave: () => void; + onChange: (field: PrefixProxyEditorField, value: string) => void; +}; + +export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) { + const { t } = useTranslation(); + const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props; + + return ( + + + + + } + > + {editor && ( +
+ {editor.loading ? ( +
+ + {t('auth_files.prefix_proxy_loading')} +
+ ) : ( + <> + {editor.error &&
{editor.error}
} +
+ +