diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index e2bdf7e..a28b02b 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -14,53 +14,53 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem, OAuthModelMappingEntry } from '@/types'; -import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; -import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage'; -import { formatFileSize } from '@/utils/format'; -import styles from './AuthFilesPage.module.scss'; - -type ThemeColors = { bg: string; text: string; border?: string }; -type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; -type ResolvedTheme = 'light' | 'dark'; - -// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色) +import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; +import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage'; +import { formatFileSize } from '@/utils/format'; +import styles from './AuthFilesPage.module.scss'; + +type ThemeColors = { bg: string; text: string; border?: string }; +type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; +type ResolvedTheme = 'light' | 'dark'; + +// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色) const TYPE_COLORS: Record = { - qwen: { - light: { bg: '#e8f5e9', text: '#2e7d32' }, - dark: { bg: '#1b5e20', text: '#81c784' } - }, - gemini: { - light: { bg: '#e3f2fd', text: '#1565c0' }, - dark: { bg: '#0d47a1', text: '#64b5f6' } - }, - 'gemini-cli': { - light: { bg: '#e7efff', text: '#1e4fa3' }, - dark: { bg: '#1c3f73', text: '#a8c7ff' } - }, - aistudio: { - light: { bg: '#f0f2f5', text: '#2f343c' }, - dark: { bg: '#373c42', text: '#cfd3db' } - }, - claude: { - light: { bg: '#fce4ec', text: '#c2185b' }, - dark: { bg: '#880e4f', text: '#f48fb1' } - }, - codex: { - light: { bg: '#fff3e0', text: '#ef6c00' }, - dark: { bg: '#e65100', text: '#ffb74d' } - }, - antigravity: { - light: { bg: '#e0f7fa', text: '#006064' }, - dark: { bg: '#004d40', text: '#80deea' } - }, - iflow: { - light: { bg: '#f3e5f5', text: '#7b1fa2' }, - dark: { bg: '#4a148c', text: '#ce93d8' } - }, - empty: { - light: { bg: '#f5f5f5', text: '#616161' }, - dark: { bg: '#424242', text: '#bdbdbd' } - }, + qwen: { + light: { bg: '#e8f5e9', text: '#2e7d32' }, + dark: { bg: '#1b5e20', text: '#81c784' } + }, + gemini: { + light: { bg: '#e3f2fd', text: '#1565c0' }, + dark: { bg: '#0d47a1', text: '#64b5f6' } + }, + 'gemini-cli': { + light: { bg: '#e7efff', text: '#1e4fa3' }, + dark: { bg: '#1c3f73', text: '#a8c7ff' } + }, + aistudio: { + light: { bg: '#f0f2f5', text: '#2f343c' }, + dark: { bg: '#373c42', text: '#cfd3db' } + }, + claude: { + light: { bg: '#fce4ec', text: '#c2185b' }, + dark: { bg: '#880e4f', text: '#f48fb1' } + }, + codex: { + light: { bg: '#fff3e0', text: '#ef6c00' }, + dark: { bg: '#e65100', text: '#ffb74d' } + }, + antigravity: { + light: { bg: '#e0f7fa', text: '#006064' }, + dark: { bg: '#004d40', text: '#80deea' } + }, + iflow: { + light: { bg: '#f3e5f5', text: '#7b1fa2' }, + dark: { bg: '#4a148c', text: '#ce93d8' } + }, + empty: { + light: { bg: '#f5f5f5', text: '#616161' }, + dark: { bg: '#424242', text: '#bdbdbd' } + }, unknown: { light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' }, dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' } @@ -116,58 +116,58 @@ function normalizeAuthIndexValue(value: unknown): string | null { function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw; - if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true'; - return false; -} - -// 解析认证文件的统计数据 -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; -} - + if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true'; + return false; +} + +// 解析认证文件的统计数据 +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 resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); - - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); + + 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); @@ -177,19 +177,19 @@ export function AuthFilesPage() { const [deletingAll, setDeletingAll] = useState(false); const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); const [usageDetails, setUsageDetails] = useState([]); - - // 详情弹窗相关 - const [detailModalOpen, setDetailModalOpen] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); - - // 模型列表弹窗相关 - const [modelsModalOpen, setModelsModalOpen] = useState(false); - const [modelsLoading, setModelsLoading] = useState(false); - const [modelsList, setModelsList] = useState<{ id: string; display_name?: string; type?: string }[]>([]); - const [modelsFileName, setModelsFileName] = useState(''); - const [modelsFileType, setModelsFileType] = useState(''); - const [modelsError, setModelsError] = useState<'unsupported' | null>(null); - + + // 详情弹窗相关 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + // 模型列表弹窗相关 + const [modelsModalOpen, setModelsModalOpen] = useState(false); + const [modelsLoading, setModelsLoading] = useState(false); + const [modelsList, setModelsList] = useState<{ id: string; display_name?: string; type?: string }[]>([]); + const [modelsFileName, setModelsFileName] = useState(''); + const [modelsFileType, setModelsFileType] = useState(''); + const [modelsError, setModelsError] = useState<'unsupported' | null>(null); + // OAuth 排除模型相关 const [excluded, setExcluded] = useState>({}); const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); @@ -222,76 +222,76 @@ export function AuthFilesPage() { setPageSize(clampCardPageSize(value)); setPage(1); }; - - // 格式化修改时间 - 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 统计和 usage 明细(API 层已有60秒超时) - const loadKeyStats = useCallback(async () => { - // 防止重复请求 - if (loadingKeyStatsRef.current) return; - loadingKeyStatsRef.current = true; - try { - const usageResponse = await usageApi.getUsage(); - const usageData = usageResponse?.usage ?? usageResponse; - const stats = await usageApi.getKeyStats(usageData); - setKeyStats(stats); - // 收集 usage 明细用于状态栏 - const details = collectUsageDetails(usageData); - setUsageDetails(details); - } catch { - // 静默失败 - } finally { - loadingKeyStatsRef.current = false; - } - }, []); - - // 加载 OAuth 排除列表 + + // 格式化修改时间 + 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 统计和 usage 明细(API 层已有60秒超时) + const loadKeyStats = useCallback(async () => { + // 防止重复请求 + if (loadingKeyStatsRef.current) return; + loadingKeyStatsRef.current = true; + try { + const usageResponse = await usageApi.getUsage(); + const usageData = usageResponse?.usage ?? usageResponse; + const stats = await usageApi.getKeyStats(usageData); + setKeyStats(stats); + // 收集 usage 明细用于状态栏 + const details = collectUsageDetails(usageData); + setUsageDetails(details); + } catch { + // 静默失败 + } finally { + loadingKeyStatsRef.current = false; + } + }, []); + + // 加载 OAuth 排除列表 const loadExcluded = useCallback(async () => { try { const res = await authFilesApi.getOauthExcludedModels(); excludedUnsupportedRef.current = false; setExcluded(res || {}); setExcludedError(null); - } catch (err: unknown) { - const status = - typeof err === 'object' && err !== null && 'status' in err - ? (err as { status?: unknown }).status - : undefined; - - if (status === 404) { - setExcluded({}); - setExcludedError('unsupported'); - if (!excludedUnsupportedRef.current) { - excludedUnsupportedRef.current = true; - showNotification(t('oauth_excluded.upgrade_required'), 'warning'); - } - return; - } + } catch (err: unknown) { + const status = + typeof err === 'object' && err !== null && 'status' in err + ? (err as { status?: unknown }).status + : undefined; + + if (status === 404) { + setExcluded({}); + setExcludedError('unsupported'); + if (!excludedUnsupportedRef.current) { + excludedUnsupportedRef.current = true; + showNotification(t('oauth_excluded.upgrade_required'), 'warning'); + } + return; + } // 静默失败 } }, [showNotification, t]); @@ -334,10 +334,10 @@ export function AuthFilesPage() { loadExcluded(); loadModelMappings(); }, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]); - + // 定时刷新状态数据(每240秒) useInterval(loadKeyStats, 240_000); - + // 提取所有存在的类型 const existingTypes = useMemo(() => { const types = new Set(['all']); @@ -405,33 +405,33 @@ export function AuthFilesPage() { // 过滤和搜索 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 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 handleUploadClick = () => { fileInputRef.current?.click(); }; - - // 处理文件上传(支持多选) - const handleFileChange = async (event: React.ChangeEvent) => { - const fileList = event.target.files; - if (!fileList || fileList.length === 0) return; - + + // 处理文件上传(支持多选) + 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[] = []; @@ -458,175 +458,175 @@ export function AuthFilesPage() { '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((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file))); - } else { - // 删除筛选类型的文件 - const filesToDelete = files.filter( - (f) => f.type === filter && !isRuntimeOnlyAuthFile(f) - ); - - 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 showModels = async (item: AuthFileItem) => { - setModelsFileName(item.name); - setModelsFileType(item.type || ''); - setModelsList([]); - setModelsError(null); - setModelsModalOpen(true); - setModelsLoading(true); - try { - const models = await authFilesApi.getModelsForAuthFile(item.name); - setModelsList(models); - } catch (err) { - // 检测是否是 API 不支持的错误 (404 或特定错误消息) - const errorMessage = err instanceof Error ? err.message : ''; - if (errorMessage.includes('404') || errorMessage.includes('not found') || errorMessage.includes('Not Found')) { - setModelsError('unsupported'); - } else { - showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error'); - } - } finally { - setModelsLoading(false); - } - }; - - // 检查模型是否被 OAuth 排除 + + 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((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file))); + } else { + // 删除筛选类型的文件 + const filesToDelete = files.filter( + (f) => f.type === filter && !isRuntimeOnlyAuthFile(f) + ); + + 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 showModels = async (item: AuthFileItem) => { + setModelsFileName(item.name); + setModelsFileType(item.type || ''); + setModelsList([]); + setModelsError(null); + setModelsModalOpen(true); + setModelsLoading(true); + try { + const models = await authFilesApi.getModelsForAuthFile(item.name); + setModelsList(models); + } catch (err) { + // 检测是否是 API 不支持的错误 (404 或特定错误消息) + const errorMessage = err instanceof Error ? err.message : ''; + if (errorMessage.includes('404') || errorMessage.includes('not found') || errorMessage.includes('Not Found')) { + setModelsError('unsupported'); + } else { + showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error'); + } + } finally { + setModelsLoading(false); + } + }; + + // 检查模型是否被 OAuth 排除 const isModelExcluded = (modelId: string, providerType: string): boolean => { const providerKey = normalizeProviderKey(providerType); const excludedModels = excluded[providerKey] || excluded[providerType] || []; @@ -635,26 +635,26 @@ export function AuthFilesPage() { // 支持通配符匹配 const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i'); return regex.test(modelId); - } - return pattern.toLowerCase() === modelId.toLowerCase(); - }); - }; - - // 获取类型标签显示文本 - 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 pattern.toLowerCase() === modelId.toLowerCase(); + }); + }; + + // 获取类型标签显示文本 + 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): ThemeColors => { const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; return resolvedTheme === 'dark' && set.dark ? set.dark : set.light; }; - + // OAuth 排除相关方法 const openExcludedModal = (provider?: string) => { const normalizedProvider = normalizeProviderKey(provider || ''); @@ -668,18 +668,18 @@ export function AuthFilesPage() { }); setExcludedModalOpen(true); }; - + const saveExcludedModels = async () => { const provider = normalizeProviderKey(excludedForm.provider); if (!provider) { showNotification(t('oauth_excluded.provider_required'), 'error'); return; } - const models = excludedForm.modelsText - .split(/[\n,]+/) - .map((item) => item.trim()) - .filter(Boolean); - setSavingExcluded(true); + const models = excludedForm.modelsText + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean); + setSavingExcluded(true); try { if (models.length) { await authFilesApi.saveOauthExcludedModels(provider, models); @@ -687,12 +687,12 @@ export function AuthFilesPage() { 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 { + 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); } }; @@ -828,95 +828,95 @@ export function AuthFilesPage() { showNotification(`${t('oauth_model_mappings.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); - const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff'; - return ( - - ); - })} -
- ); - - // 预计算所有认证文件的状态栏数据(避免每次渲染重复计算) - const statusBarCache = useMemo(() => { - const cache = new Map>(); - - files.forEach((file) => { - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndexKey = normalizeAuthIndexValue(rawAuthIndex); - - if (authIndexKey) { - // 过滤出属于该认证文件的 usage 明细 - const filteredDetails = usageDetails.filter((detail) => { - const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index); - return detailAuthIndex !== null && detailAuthIndex === authIndexKey; - }); - cache.set(authIndexKey, calculateStatusBarData(filteredDetails)); - } - }); - - return cache; - }, [usageDetails, files]); - - // 渲染状态监测栏 - const renderStatusBar = (item: AuthFileItem) => { - // 认证文件使用 authIndex 来匹配 usage 数据 - const rawAuthIndex = item['auth_index'] ?? item.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 ( -
-
- {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)}%` : '--'} - -
- ); - }; - - // 渲染单个认证文件卡片 + + // 渲染标签筛选器 + const renderFilterTags = () => ( +
+ {existingTypes.map((type) => { + const isActive = filter === type; + const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type); + const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff'; + return ( + + ); + })} +
+ ); + + // 预计算所有认证文件的状态栏数据(避免每次渲染重复计算) + const statusBarCache = useMemo(() => { + const cache = new Map>(); + + files.forEach((file) => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndexKey = normalizeAuthIndexValue(rawAuthIndex); + + if (authIndexKey) { + // 过滤出属于该认证文件的 usage 明细 + const filteredDetails = usageDetails.filter((detail) => { + const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index); + return detailAuthIndex !== null && detailAuthIndex === authIndexKey; + }); + cache.set(authIndexKey, calculateStatusBarData(filteredDetails)); + } + }); + + return cache; + }, [usageDetails, files]); + + // 渲染状态监测栏 + const renderStatusBar = (item: AuthFileItem) => { + // 认证文件使用 authIndex 来匹配 usage 数据 + const rawAuthIndex = item['auth_index'] ?? item.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 ( +
+
+ {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)}%` : '--'} + +
+ ); + }; + + // 渲染单个认证文件卡片 const renderFileCard = (item: AuthFileItem) => { const fileStats = resolveAuthFileStats(item, keyStats); const isRuntimeOnly = isRuntimeOnlyAuthFile(item); @@ -925,37 +925,37 @@ export function AuthFilesPage() { 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} - -
- - {/* 状态监测栏 */} - {renderStatusBar(item)} +
+
+ + {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 && ( @@ -979,29 +979,29 @@ export function AuthFilesPage() { className={styles.iconButton} title={t('common.info', { defaultValue: '关于' })} disabled={disableControls} - > - - - - + + - - - -
- } - > - {error &&
{error}
} - - {/* 筛选区域 */} -
- {renderFilterTags()} - -
-
- - { - setSearch(e.target.value); - setPage(1); - }} - placeholder={t('auth_files.search_placeholder')} - /> -
+ + + +
+ } + > + {error &&
{error}
} + + {/* 筛选区域 */} +
+ {renderFilterTags()} + +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + placeholder={t('auth_files.search_placeholder')} + /> +
- - {/* 卡片网格 */} - {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 - })} -
- -
- )} + + {/* 卡片网格 */} + {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 排除列表卡片 */} @@ -1152,36 +1152,36 @@ export function AuthFilesPage() { } > - {excludedError === 'unsupported' ? ( - - ) : 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')} -
-
-
- - -
-
- ))} -
+ {excludedError === 'unsupported' ? ( + + ) : 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')} +
+
+
+ + +
+
+ ))} +
)} @@ -1234,102 +1234,102 @@ export function AuthFilesPage() { {/* 详情弹窗 */} setDetailModalOpen(false)} - title={selectedFile?.name || t('auth_files.title_section')} - footer={ - <> - - - - } - > - {selectedFile && ( -
-
{JSON.stringify(selectedFile, null, 2)}
-
- )} -
- - {/* 模型列表弹窗 */} - setModelsModalOpen(false)} - title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${modelsFileName}`} - footer={ - - } - > - {modelsLoading ? ( -
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
- ) : modelsError === 'unsupported' ? ( - - ) : modelsList.length === 0 ? ( - - ) : ( -
- {modelsList.map((model) => { - const isExcluded = isModelExcluded(model.id, modelsFileType); - return ( -
{ - navigator.clipboard.writeText(model.id); - showNotification(t('notification.link_copied', { defaultValue: '已复制到剪贴板' }), 'success'); - }} - title={isExcluded ? 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} - )} - {isExcluded && ( - {t('auth_files.models_excluded_badge', { defaultValue: '已排除' })} - )} -
- ); - })} -
- )} -
- + onClose={() => setDetailModalOpen(false)} + title={selectedFile?.name || t('auth_files.title_section')} + footer={ + <> + + + + } + > + {selectedFile && ( +
+
{JSON.stringify(selectedFile, null, 2)}
+
+ )} + + + {/* 模型列表弹窗 */} + setModelsModalOpen(false)} + title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${modelsFileName}`} + footer={ + + } + > + {modelsLoading ? ( +
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
+ ) : modelsError === 'unsupported' ? ( + + ) : modelsList.length === 0 ? ( + + ) : ( +
+ {modelsList.map((model) => { + const isExcluded = isModelExcluded(model.id, modelsFileType); + return ( +
{ + navigator.clipboard.writeText(model.id); + showNotification(t('notification.link_copied', { defaultValue: '已复制到剪贴板' }), 'success'); + }} + title={isExcluded ? 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} + )} + {isExcluded && ( + {t('auth_files.models_excluded_badge', { defaultValue: '已排除' })} + )} +
+ ); + })} +
+ )} +
+ {/* OAuth 排除弹窗 */} setExcludedModalOpen(false)} title={t('oauth_excluded.add_title')} - footer={ - <> - - + footer={ + <> + + } > @@ -1372,11 +1372,11 @@ export function AuthFilesPage() {