Files
Cli-Proxy-API-Management-Ce…/src/pages/AuthFilesPage.tsx

758 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { bg: string; text: string }> = {
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<AuthFileItem[]>([]);
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<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
// 详情弹窗相关
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
// OAuth 排除模型相关
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
const [savingExcluded, setSavingExcluded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(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<string>(['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<HTMLInputElement>) => {
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 = () => (
<div className={styles.filterTags}>
{existingTypes.map((type) => {
const isActive = filter === type;
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
return (
<button
key={type}
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
style={{
backgroundColor: isActive ? color.text : color.bg,
color: isActive ? '#fff' : color.text,
borderColor: color.text
}}
onClick={() => {
setFilter(type);
setPage(1);
}}
>
{getTypeLabel(type)}
</button>
);
})}
</div>
);
// 渲染单个认证文件卡片
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 (
<div key={item.name} className={styles.fileCard}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{ backgroundColor: typeColor.bg, color: typeColor.text }}
>
{getTypeLabel(item.type || 'unknown')}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.cardMeta}>
<span>{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}</span>
<span>{t('auth_files.file_modified')}: {formatModified(item)}</span>
</div>
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
</div>
<div className={styles.cardActions}>
{isRuntimeOnly ? (
<span className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</span>
) : (
<>
<Button
variant="secondary"
size="sm"
onClick={() => showDetails(item)}
disabled={disableControls}
>
<i className={styles.actionIcon}></i>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleDownload(item.name)}
disabled={disableControls}
>
<i className={styles.actionIcon}></i>
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(item.name)}
loading={deleting === item.name}
disabled={disableControls}
>
<i className={styles.actionIcon}>🗑</i>
</Button>
</>
)}
</div>
</div>
);
};
return (
<div className={styles.container}>
<Card
title={t('auth_files.title')}
extra={
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}>
{t('common.refresh')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleDeleteAll}
disabled={disableControls || loading || deletingAll}
loading={deletingAll}
>
{filter === 'all' ? t('auth_files.delete_all_button') : `${t('common.delete')} ${getTypeLabel(filter)}`}
</Button>
<Button size="sm" onClick={handleUploadClick} disabled={disableControls || uploading} loading={uploading}>
{t('auth_files.upload_button')}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
}
>
{error && <div className={styles.errorBox}>{error}</div>}
{/* 筛选区域 */}
<div className={styles.filterSection}>
{renderFilterTags()}
<div className={styles.filterControls}>
<div className={styles.filterItem}>
<label>{t('auth_files.search_label')}</label>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value) || 9);
setPage(1);
}}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={styles.filterItem}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
</div>
</div>
</div>
</div>
{/* 卡片网格 */}
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
) : (
<div className={styles.fileGrid}>
{pageItems.map(renderFileCard)}
</div>
)}
{/* 分页 */}
{!loading && filtered.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filtered.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</Card>
{/* OAuth 排除列表卡片 */}
<Card
title={t('oauth_excluded.title')}
extra={
<Button size="sm" onClick={() => openExcludedModal()} disabled={disableControls}>
{t('oauth_excluded.add')}
</Button>
}
>
{Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
{/* 详情弹窗 */}
<Modal
open={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
title={selectedFile?.name || t('auth_files.title_section')}
footer={
<>
<Button variant="secondary" onClick={() => setDetailModalOpen(false)}>
{t('common.close')}
</Button>
<Button
onClick={() => {
if (selectedFile) {
const text = JSON.stringify(selectedFile, null, 2);
navigator.clipboard.writeText(text).then(() => {
showNotification(t('notification.link_copied'), 'success');
});
}
}}
>
{t('common.copy')}
</Button>
</>
}
>
{selectedFile && (
<div className={styles.detailContent}>
<pre className={styles.jsonContent}>{JSON.stringify(selectedFile, null, 2)}</pre>
</div>
)}
</Modal>
{/* OAuth 排除弹窗 */}
<Modal
open={excludedModalOpen}
onClose={() => setExcludedModalOpen(false)}
title={t('oauth_excluded.add_title')}
footer={
<>
<Button variant="secondary" onClick={() => setExcludedModalOpen(false)} disabled={savingExcluded}>
{t('common.cancel')}
</Button>
<Button onClick={saveExcludedModels} loading={savingExcluded}>
{t('oauth_excluded.save')}
</Button>
</>
}
>
<Input
label={t('oauth_excluded.provider_label')}
placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
/>
<div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label>
<textarea
className={styles.textarea}
rows={4}
placeholder={t('oauth_excluded.models_placeholder')}
value={excludedForm.modelsText}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
/>
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
</div>
</Modal>
</div>
);
}