mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 11:20:50 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user