import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { useAuthStore, useNotificationStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem } from '@/types'; import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import { formatFileSize } from '@/utils/format'; import styles from './AuthFilesPage.module.scss'; // 标签类型颜色配置 const TYPE_COLORS: Record = { qwen: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6' }, gemini: { bg: 'rgba(34, 197, 94, 0.15)', text: '#22c55e' }, 'gemini-cli': { bg: 'rgba(6, 182, 212, 0.15)', text: '#06b6d4' }, aistudio: { bg: 'rgba(139, 92, 246, 0.15)', text: '#8b5cf6' }, claude: { bg: 'rgba(249, 115, 22, 0.15)', text: '#f97316' }, codex: { bg: 'rgba(236, 72, 153, 0.15)', text: '#ec4899' }, antigravity: { bg: 'rgba(245, 158, 11, 0.15)', text: '#f59e0b' }, iflow: { bg: 'rgba(132, 204, 22, 0.15)', text: '#84cc16' }, vertex: { bg: 'rgba(239, 68, 68, 0.15)', text: '#ef4444' }, empty: { bg: 'rgba(107, 114, 128, 0.15)', text: '#6b7280' }, unknown: { bg: 'rgba(156, 163, 175, 0.15)', text: '#9ca3af' } }; interface ExcludedFormState { provider: string; modelsText: string; } // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); } if (typeof value === 'string') { const trimmed = value.trim(); return trimmed ? trimmed : null; } return null; } // 解析认证文件的统计数据 function resolveAuthFileStats( file: AuthFileItem, stats: KeyStats ): KeyStatBucket { const defaultStats: KeyStatBucket = { success: 0, failure: 0 }; const rawFileName = file?.name || ''; // 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index) const rawAuthIndex = file['auth_index'] ?? file.authIndex; const authIndexKey = normalizeAuthIndexValue(rawAuthIndex); // 尝试根据 authIndex 匹配 if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) { return stats.byAuthIndex[authIndexKey]; } // 尝试根据 source (文件名) 匹配 if (rawFileName && stats.bySource?.[rawFileName]) { const fromName = stats.bySource[rawFileName]; if (fromName.success > 0 || fromName.failure > 0) { return fromName; } } // 尝试去掉扩展名后匹配 if (rawFileName) { const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, ''); if (nameWithoutExt && nameWithoutExt !== rawFileName) { const fromNameWithoutExt = stats.bySource?.[nameWithoutExt]; if (fromNameWithoutExt && (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)) { return fromNameWithoutExt; } } } return defaultStats; } export function AuthFilesPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [filter, setFilter] = useState<'all' | string>('all'); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(9); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(null); const [deletingAll, setDeletingAll] = useState(false); const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); // 详情弹窗相关 const [detailModalOpen, setDetailModalOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); // OAuth 排除模型相关 const [excluded, setExcluded] = useState>({}); const [excludedModalOpen, setExcludedModalOpen] = useState(false); const [excludedForm, setExcludedForm] = useState({ provider: '', modelsText: '' }); const [savingExcluded, setSavingExcluded] = useState(false); const fileInputRef = useRef(null); const disableControls = connectionStatus !== 'connected'; // 格式化修改时间 const formatModified = (item: AuthFileItem): string => { const raw = item['modtime'] ?? item.modified; if (!raw) return '-'; const asNumber = Number(raw); const date = Number.isFinite(asNumber) && !Number.isNaN(asNumber) ? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber) : new Date(String(raw)); return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString(); }; // 加载文件列表 const loadFiles = useCallback(async () => { setLoading(true); setError(''); try { const data = await authFilesApi.list(); setFiles(data?.files || []); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed'); setError(errorMessage); } finally { setLoading(false); } }, [t]); // 加载 key 统计 const loadKeyStats = useCallback(async () => { try { const stats = await usageApi.getKeyStats(); setKeyStats(stats); } catch { // 静默失败 } }, []); // 加载 OAuth 排除列表 const loadExcluded = useCallback(async () => { try { const res = await authFilesApi.getOauthExcludedModels(); setExcluded(res || {}); } catch { // 静默失败 } }, []); useEffect(() => { loadFiles(); loadKeyStats(); loadExcluded(); }, [loadFiles, loadKeyStats, loadExcluded]); // 提取所有存在的类型 const existingTypes = useMemo(() => { const types = new Set(['all']); files.forEach((file) => { if (file.type) { types.add(file.type); } }); return Array.from(types); }, [files]); // 过滤和搜索 const filtered = useMemo(() => { return files.filter((item) => { const matchType = filter === 'all' || item.type === filter; const term = search.trim().toLowerCase(); const matchSearch = !term || item.name.toLowerCase().includes(term) || (item.type || '').toString().toLowerCase().includes(term) || (item.provider || '').toString().toLowerCase().includes(term); return matchType && matchSearch; }); }, [files, filter, search]); // 分页计算 const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const currentPage = Math.min(page, totalPages); const start = (currentPage - 1) * pageSize; const pageItems = filtered.slice(start, start + pageSize); // 统计信息 const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]); // 点击上传 const handleUploadClick = () => { fileInputRef.current?.click(); }; // 处理文件上传(支持多选) const handleFileChange = async (event: React.ChangeEvent) => { const fileList = event.target.files; if (!fileList || fileList.length === 0) return; const filesToUpload = Array.from(fileList); const validFiles: File[] = []; const invalidFiles: string[] = []; filesToUpload.forEach((file) => { if (file.name.endsWith('.json')) { validFiles.push(file); } else { invalidFiles.push(file.name); } }); if (invalidFiles.length > 0) { showNotification(t('auth_files.upload_error_json'), 'error'); } if (validFiles.length === 0) { event.target.value = ''; return; } setUploading(true); let successCount = 0; const failed: { name: string; message: string }[] = []; for (const file of validFiles) { try { await authFilesApi.upload(file); successCount++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; failed.push({ name: file.name, message: errorMessage }); } } if (successCount > 0) { const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : ''; showNotification(`${t('auth_files.upload_success')}${suffix}`, failed.length ? 'warning' : 'success'); await loadFiles(); await loadKeyStats(); } if (failed.length > 0) { const details = failed.map((item) => `${item.name}: ${item.message}`).join('; '); showNotification(`${t('notification.upload_failed')}: ${details}`, 'error'); } setUploading(false); event.target.value = ''; }; // 删除单个文件 const handleDelete = async (name: string) => { if (!window.confirm(`${t('auth_files.delete_confirm')} "${name}" ?`)) return; setDeleting(name); try { await authFilesApi.deleteFile(name); showNotification(t('auth_files.delete_success'), 'success'); setFiles((prev) => prev.filter((item) => item.name !== name)); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); } finally { setDeleting(null); } }; // 删除全部(根据筛选类型) const handleDeleteAll = async () => { const isFiltered = filter !== 'all'; const typeLabel = isFiltered ? getTypeLabel(filter) : t('auth_files.filter_all'); const confirmMessage = isFiltered ? t('auth_files.delete_filtered_confirm', { type: typeLabel }) : t('auth_files.delete_all_confirm'); if (!window.confirm(confirmMessage)) return; setDeletingAll(true); try { if (!isFiltered) { // 删除全部 await authFilesApi.deleteAll(); showNotification(t('auth_files.delete_all_success'), 'success'); setFiles([]); } else { // 删除筛选类型的文件 const filesToDelete = files.filter( (f) => f.type === filter && !f['runtime_only'] ); if (filesToDelete.length === 0) { showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info'); setDeletingAll(false); return; } let success = 0; let failed = 0; const deletedNames: string[] = []; for (const file of filesToDelete) { try { await authFilesApi.deleteFile(file.name); success++; deletedNames.push(file.name); } catch { failed++; } } setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name))); if (failed === 0) { showNotification( t('auth_files.delete_filtered_success', { count: success, type: typeLabel }), 'success' ); } else { showNotification( t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }), 'warning' ); } setFilter('all'); } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); } finally { setDeletingAll(false); } }; // 下载文件 const handleDownload = async (name: string) => { try { const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, { responseType: 'blob' }); const blob = new Blob([response.data]); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); window.URL.revokeObjectURL(url); showNotification(t('auth_files.download_success'), 'success'); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error'); } }; // 显示详情弹窗 const showDetails = (file: AuthFileItem) => { setSelectedFile(file); setDetailModalOpen(true); }; // 获取类型标签显示文本 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 getTypeColor = (type: string) => { return TYPE_COLORS[type] || TYPE_COLORS.unknown; }; // OAuth 排除相关方法 const openExcludedModal = (provider?: string) => { const models = provider ? excluded[provider] : []; setExcludedForm({ provider: provider || '', modelsText: Array.isArray(models) ? models.join('\n') : '' }); setExcludedModalOpen(true); }; const saveExcludedModels = async () => { const provider = excludedForm.provider.trim(); if (!provider) { showNotification(t('oauth_excluded.provider_required'), 'error'); return; } const models = excludedForm.modelsText .split(/[\n,]+/) .map((item) => item.trim()) .filter(Boolean); setSavingExcluded(true); try { if (models.length) { await authFilesApi.saveOauthExcludedModels(provider, models); } else { await authFilesApi.deleteOauthExcludedEntry(provider); } await loadExcluded(); showNotification(t('oauth_excluded.save_success'), 'success'); setExcludedModalOpen(false); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error'); } finally { setSavingExcluded(false); } }; const deleteExcluded = async (provider: string) => { if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return; try { await authFilesApi.deleteOauthExcludedEntry(provider); await loadExcluded(); showNotification(t('oauth_excluded.delete_success'), 'success'); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error'); } }; // 渲染标签筛选器 const renderFilterTags = () => (
{existingTypes.map((type) => { const isActive = filter === type; const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type); return ( ); })}
); // 渲染单个认证文件卡片 const renderFileCard = (item: AuthFileItem) => { const fileStats = resolveAuthFileStats(item, keyStats); const runtimeOnlyValue = item['runtime_only']; const isRuntimeOnly = runtimeOnlyValue === true || runtimeOnlyValue === 'true'; const typeColor = getTypeColor(item.type || 'unknown'); 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}
{isRuntimeOnly ? ( {t('auth_files.type_virtual') || '虚拟认证文件'} ) : ( <> )}
); }; return (
} > {error &&
{error}
} {/* 筛选区域 */}
{renderFilterTags()}
{ setSearch(e.target.value); setPage(1); }} placeholder={t('auth_files.search_placeholder')} />
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
{/* 卡片网格 */} {loading ? (
{t('common.loading')}
) : pageItems.length === 0 ? ( ) : (
{pageItems.map(renderFileCard)}
)} {/* 分页 */} {!loading && filtered.length > pageSize && (
{t('auth_files.pagination_info', { current: currentPage, total: totalPages, count: filtered.length })}
)} {/* OAuth 排除列表卡片 */} openExcludedModal()} disabled={disableControls}> {t('oauth_excluded.add')} } > {Object.keys(excluded).length === 0 ? ( ) : (
{Object.entries(excluded).map(([provider, models]) => (
{provider}
{models?.length ? t('oauth_excluded.model_count', { count: models.length }) : t('oauth_excluded.no_models')}
))}
)}
{/* 详情弹窗 */} setDetailModalOpen(false)} title={selectedFile?.name || t('auth_files.title_section')} footer={ <> } > {selectedFile && (
{JSON.stringify(selectedFile, null, 2)}
)}
{/* OAuth 排除弹窗 */} setExcludedModalOpen(false)} title={t('oauth_excluded.add_title')} footer={ <> } > setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))} />