feat: enhance AuthFilesPage with improved layout and styling, add filter and pagination features, and implement detailed file statistics and actions for better user interaction

This commit is contained in:
Supra4E8C
2025-12-10 12:31:40 +08:00
parent e8d918ba98
commit bf5f34be0d
2 changed files with 815 additions and 192 deletions

View File

@@ -1,58 +1,349 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-md 0;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.filters {
.headerActions {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
}
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
font-size: 14px;
margin-bottom: $spacing-md;
}
// 筛选区域
.filterSection {
display: flex;
flex-direction: column;
gap: $spacing-md;
margin-bottom: $spacing-lg;
}
.filterTags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.filterTag {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: all $transition-fast;
&:hover {
transform: translateY(-1px);
box-shadow: $shadow-sm;
}
}
.filterTagActive {
font-weight: 600;
}
.filterControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
}
.filterItem {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
}
.pageSizeSelect {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
// 卡片网格
.fileGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@include tablet {
grid-template-columns: repeat(2, 1fr);
}
@include mobile {
grid-template-columns: 1fr;
}
}
// 单个认证文件卡片
.fileCard {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
border-color: rgba(37, 99, 235, 0.2);
}
}
.cardHeader {
display: flex;
align-items: center;
gap: $spacing-sm;
min-height: 28px;
}
.typeBadge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.fileName {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
line-height: 1.4;
}
.cardMeta {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: var(--text-secondary);
padding: $spacing-xs 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.cardStats {
display: flex;
gap: $spacing-md;
padding: $spacing-sm 0;
}
.statSuccess {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--success-color, #22c55e);
font-weight: 500;
}
.statFailure {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--danger-color, #ef4444);
font-weight: 500;
}
.statIcon {
font-style: normal;
font-size: 12px;
}
.cardActions {
display: flex;
gap: $spacing-xs;
justify-content: flex-end;
margin-top: auto;
padding-top: $spacing-sm;
}
.actionIcon {
font-style: normal;
font-size: 14px;
}
.virtualBadge {
font-size: 12px;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
padding: 4px 10px;
border-radius: $radius-sm;
font-style: italic;
}
// 分页
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: $spacing-md;
margin-top: $spacing-lg;
padding-top: $spacing-md;
border-top: 1px solid var(--border-color);
}
.pageInfo {
font-size: 13px;
color: var(--text-secondary);
padding: $spacing-xs $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
}
// OAuth 排除列表
.excludedList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.excludedItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
border: 1px solid var(--border-color);
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: flex-start;
}
}
.excludedInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.excludedProvider {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.excludedModels {
font-size: 12px;
color: var(--text-secondary);
}
.excludedActions {
display: flex;
gap: $spacing-xs;
flex-shrink: 0;
}
// 详情弹窗
.detailContent {
max-height: 400px;
overflow: auto;
}
.jsonContent {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-primary);
margin: 0;
}
// 表单
.formGroup {
display: flex;
flex-direction: column;
gap: $spacing-xs;
margin-top: $spacing-md;
label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
}
.textarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-family: monospace;
resize: vertical;
&:focus {
outline: none;
border-color: var(--primary-color);
}
&::placeholder {
color: var(--text-tertiary);
}
}
.hint {
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
text-align: center;
padding: $spacing-lg;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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';
@@ -6,16 +6,84 @@ 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 } from '@/services/api';
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();
@@ -27,10 +95,17 @@ export function AuthFilesPage() {
const [filter, setFilter] = useState<'all' | string>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
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: '' });
@@ -40,44 +115,71 @@ export function AuthFilesPage() {
const disableControls = connectionStatus !== 'connected';
// 格式化修改时间
const formatModified = (item: AuthFileItem): string => {
const raw = (item as any).modtime ?? item.modified;
if (!raw) return t('auth_files.file_modified');
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()) ? t('auth_files.file_modified') : date.toLocaleString();
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
const loadFiles = async () => {
// 加载文件列表
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(errorMessage);
} finally {
setLoading(false);
}
};
}, [t]);
const loadExcluded = async () => {
// 加载 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 (err) {
// ignore silently
} 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;
@@ -91,58 +193,159 @@ export function AuthFilesPage() {
});
}, [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 file = event.target.files?.[0];
if (!file) return;
setUploading(true);
try {
await authFilesApi.upload(file);
showNotification(t('auth_files.upload_success'), 'success');
await loadFiles();
} catch (err: any) {
showNotification(`${t('notification.upload_failed')}: ${err?.message || ''}`, 'error');
} finally {
setUploading(false);
event.target.value = '';
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'))) return;
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: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeleting(null);
}
};
// 删除全部(根据筛选类型)
const handleDeleteAll = async () => {
if (!window.confirm(t('auth_files.delete_all_confirm'))) return;
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 {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles([]);
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
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)}`, {
@@ -156,11 +359,33 @@ export function AuthFilesPage() {
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('auth_files.download_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
} 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({
@@ -190,8 +415,9 @@ export function AuthFilesPage() {
await loadExcluded();
showNotification(t('oauth_excluded.save_success'), 'success');
setExcludedModalOpen(false);
} catch (err: any) {
showNotification(`${t('oauth_excluded.save_failed')}: ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSavingExcluded(false);
}
@@ -203,156 +429,229 @@ export function AuthFilesPage() {
await authFilesApi.deleteOauthExcludedEntry(provider);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (err: any) {
showNotification(`${t('oauth_excluded.delete_failed')}: ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
};
const typeOptions: { value: string; label: string }[] = [
{ value: 'all', label: t('auth_files.filter_all') },
{ value: 'qwen', label: t('auth_files.filter_qwen') },
{ value: 'gemini', label: t('auth_files.filter_gemini') },
{ value: 'gemini-cli', label: t('auth_files.filter_gemini-cli') },
{ value: 'aistudio', label: t('auth_files.filter_aistudio') },
{ value: 'claude', label: t('auth_files.filter_claude') },
{ value: 'codex', label: t('auth_files.filter_codex') },
{ value: 'antigravity', label: t('auth_files.filter_antigravity') },
{ value: 'iflow', label: t('auth_files.filter_iflow') },
{ value: 'vertex', label: t('auth_files.filter_vertex') },
{ value: 'empty', label: t('auth_files.filter_empty') },
{ value: 'unknown', label: t('auth_files.filter_unknown') }
];
// 渲染标签筛选器
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.statSuccess}>
<i className={styles.statIcon}></i>
{t('stats.success')}: {fileStats.success}
</span>
<span className={styles.statFailure}>
<i className={styles.statIcon}></i>
{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="stack">
<div className={styles.container}>
<Card
title={t('auth_files.title')}
extra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
<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}>
{t('auth_files.delete_all_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}>
<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="error-box">{error}</div>}
{error && <div className={styles.errorBox}>{error}</div>}
<div className="filters">
<div className="filter-item">
<label>{t('auth_files.search_label')}</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className="filter-item">
<label>{t('auth_files.page_size_label')}</label>
<input
className="input"
type="number"
min={1}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value) || 10)}
/>
</div>
<div className="filter-item">
<label>{t('common.info')}</label>
<div className="pill">
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
{/* 筛选区域 */}
<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 className="filter-item">
<label>{t('auth_files.filter_all')}</label>
<select className="input" value={filter} onChange={(e) => setFilter(e.target.value)}>
{typeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* 卡片网格 */}
{loading ? (
<div className="hint">{t('common.loading')}</div>
<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="table">
<div className="table-header">
<div>{t('auth_files.title_section')}</div>
<div>{t('auth_files.file_size')}</div>
<div>{t('auth_files.file_modified')}</div>
<div>Actions</div>
</div>
{pageItems.map((item) => (
<div key={item.name} className="table-row">
<div className="cell">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.type || t('auth_files.type_unknown')} {item.provider ? `· ${item.provider}` : ''}
</div>
</div>
<div className="cell">{item.size ? formatFileSize(item.size) : '-'}</div>
<div className="cell">
{formatModified(item)}
</div>
<div className="cell">
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => handleDownload(item.name)} disabled={disableControls}>
{t('auth_files.download_button')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(item.name)}
loading={deleting === item.name}
disabled={disableControls}
>
{t('auth_files.delete_button')}
</Button>
</div>
</div>
</div>
))}
<div className={styles.fileGrid}>
{pageItems.map(renderFileCard)}
</div>
)}
<div className="pagination">
<Button variant="secondary" size="sm" onClick={() => setPage(Math.max(1, currentPage - 1))}>
{t('auth_files.pagination_prev')}
</Button>
<div className="pill">
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filtered.length
})}
{/* 分页 */}
{!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>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</Card>
{/* OAuth 排除列表卡片 */}
<Card
title={t('oauth_excluded.title')}
extra={
@@ -364,18 +663,18 @@ export function AuthFilesPage() {
{Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className="item-list">
<div className={styles.excludedList}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className="item-row">
<div className="item-meta">
<div className="item-title">{provider}</div>
<div className="item-subtitle">
<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="item-actions">
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
{t('common.edit')}
</Button>
@@ -389,6 +688,39 @@ export function AuthFilesPage() {
)}
</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)}
@@ -410,16 +742,16 @@ export function AuthFilesPage() {
value={excludedForm.provider}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
/>
<div className="form-group">
<div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label>
<textarea
className="input"
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="hint">{t('oauth_excluded.models_hint')}</div>
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
</div>
</Modal>
</div>