mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 11:20:50 +08:00
758 lines
26 KiB
TypeScript
758 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|