mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40: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 {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.headerActions {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-md;
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
flex-wrap: wrap;
|
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 {
|
.fileGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-md;
|
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 {
|
@include mobile {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
margin-top: $spacing-lg;
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -6,16 +6,84 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||||
import { authFilesApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem } from '@/types';
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
||||||
import { formatFileSize } from '@/utils/format';
|
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 {
|
interface ExcludedFormState {
|
||||||
provider: string;
|
provider: string;
|
||||||
modelsText: 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() {
|
export function AuthFilesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
@@ -27,10 +95,17 @@ export function AuthFilesPage() {
|
|||||||
const [filter, setFilter] = useState<'all' | string>('all');
|
const [filter, setFilter] = useState<'all' | string>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(9);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
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 [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
||||||
@@ -40,44 +115,71 @@ export function AuthFilesPage() {
|
|||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
// 格式化修改时间
|
||||||
const formatModified = (item: AuthFileItem): string => {
|
const formatModified = (item: AuthFileItem): string => {
|
||||||
const raw = (item as any).modtime ?? item.modified;
|
const raw = item['modtime'] ?? item.modified;
|
||||||
if (!raw) return t('auth_files.file_modified');
|
if (!raw) return '-';
|
||||||
const asNumber = Number(raw);
|
const asNumber = Number(raw);
|
||||||
const date =
|
const date =
|
||||||
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||||
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||||||
: new Date(String(raw));
|
: 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);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await authFilesApi.list();
|
const data = await authFilesApi.list();
|
||||||
setFiles(data?.files || []);
|
setFiles(data?.files || []);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err?.message || t('notification.refresh_failed'));
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
|
setError(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 {
|
try {
|
||||||
const res = await authFilesApi.getOauthExcludedModels();
|
const res = await authFilesApi.getOauthExcludedModels();
|
||||||
setExcluded(res || {});
|
setExcluded(res || {});
|
||||||
} catch (err) {
|
} catch {
|
||||||
// ignore silently
|
// 静默失败
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFiles();
|
loadFiles();
|
||||||
|
loadKeyStats();
|
||||||
loadExcluded();
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
return files.filter((item) => {
|
return files.filter((item) => {
|
||||||
const matchType = filter === 'all' || item.type === filter;
|
const matchType = filter === 'all' || item.type === filter;
|
||||||
@@ -91,58 +193,159 @@ export function AuthFilesPage() {
|
|||||||
});
|
});
|
||||||
}, [files, filter, search]);
|
}, [files, filter, search]);
|
||||||
|
|
||||||
|
// 分页计算
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const start = (currentPage - 1) * pageSize;
|
const start = (currentPage - 1) * pageSize;
|
||||||
const pageItems = filtered.slice(start, start + pageSize);
|
const pageItems = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
|
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
|
||||||
|
|
||||||
|
// 点击上传
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理文件上传(支持多选)
|
||||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const fileList = event.target.files;
|
||||||
if (!file) return;
|
if (!fileList || fileList.length === 0) return;
|
||||||
setUploading(true);
|
|
||||||
try {
|
const filesToUpload = Array.from(fileList);
|
||||||
await authFilesApi.upload(file);
|
const validFiles: File[] = [];
|
||||||
showNotification(t('auth_files.upload_success'), 'success');
|
const invalidFiles: string[] = [];
|
||||||
await loadFiles();
|
|
||||||
} catch (err: any) {
|
filesToUpload.forEach((file) => {
|
||||||
showNotification(`${t('notification.upload_failed')}: ${err?.message || ''}`, 'error');
|
if (file.name.endsWith('.json')) {
|
||||||
} finally {
|
validFiles.push(file);
|
||||||
setUploading(false);
|
} else {
|
||||||
event.target.value = '';
|
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) => {
|
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);
|
setDeleting(name);
|
||||||
try {
|
try {
|
||||||
await authFilesApi.deleteFile(name);
|
await authFilesApi.deleteFile(name);
|
||||||
showNotification(t('auth_files.delete_success'), 'success');
|
showNotification(t('auth_files.delete_success'), 'success');
|
||||||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 删除全部(根据筛选类型)
|
||||||
const handleDeleteAll = async () => {
|
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 {
|
try {
|
||||||
await authFilesApi.deleteAll();
|
if (!isFiltered) {
|
||||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
// 删除全部
|
||||||
setFiles([]);
|
await authFilesApi.deleteAll();
|
||||||
} catch (err: any) {
|
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
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) => {
|
const handleDownload = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
|
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
|
||||||
@@ -156,11 +359,33 @@ export function AuthFilesPage() {
|
|||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
showNotification(t('auth_files.download_success'), 'success');
|
showNotification(t('auth_files.download_success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
|
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 openExcludedModal = (provider?: string) => {
|
||||||
const models = provider ? excluded[provider] : [];
|
const models = provider ? excluded[provider] : [];
|
||||||
setExcludedForm({
|
setExcludedForm({
|
||||||
@@ -190,8 +415,9 @@ export function AuthFilesPage() {
|
|||||||
await loadExcluded();
|
await loadExcluded();
|
||||||
showNotification(t('oauth_excluded.save_success'), 'success');
|
showNotification(t('oauth_excluded.save_success'), 'success');
|
||||||
setExcludedModalOpen(false);
|
setExcludedModalOpen(false);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
showNotification(`${t('oauth_excluded.save_failed')}: ${err?.message || ''}`, 'error');
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingExcluded(false);
|
setSavingExcluded(false);
|
||||||
}
|
}
|
||||||
@@ -203,156 +429,229 @@ export function AuthFilesPage() {
|
|||||||
await authFilesApi.deleteOauthExcludedEntry(provider);
|
await authFilesApi.deleteOauthExcludedEntry(provider);
|
||||||
await loadExcluded();
|
await loadExcluded();
|
||||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${err?.message || ''}`, 'error');
|
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') },
|
const renderFilterTags = () => (
|
||||||
{ value: 'qwen', label: t('auth_files.filter_qwen') },
|
<div className={styles.filterTags}>
|
||||||
{ value: 'gemini', label: t('auth_files.filter_gemini') },
|
{existingTypes.map((type) => {
|
||||||
{ value: 'gemini-cli', label: t('auth_files.filter_gemini-cli') },
|
const isActive = filter === type;
|
||||||
{ value: 'aistudio', label: t('auth_files.filter_aistudio') },
|
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
||||||
{ value: 'claude', label: t('auth_files.filter_claude') },
|
return (
|
||||||
{ value: 'codex', label: t('auth_files.filter_codex') },
|
<button
|
||||||
{ value: 'antigravity', label: t('auth_files.filter_antigravity') },
|
key={type}
|
||||||
{ value: 'iflow', label: t('auth_files.filter_iflow') },
|
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
|
||||||
{ value: 'vertex', label: t('auth_files.filter_vertex') },
|
style={{
|
||||||
{ value: 'empty', label: t('auth_files.filter_empty') },
|
backgroundColor: isActive ? color.text : color.bg,
|
||||||
{ value: 'unknown', label: t('auth_files.filter_unknown') }
|
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 (
|
return (
|
||||||
<div className="stack">
|
<div className={styles.container}>
|
||||||
<Card
|
<Card
|
||||||
title={t('auth_files.title')}
|
title={t('auth_files.title')}
|
||||||
extra={
|
extra={
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div className={styles.headerActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
|
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={handleDeleteAll} disabled={disableControls || loading}>
|
<Button
|
||||||
{t('auth_files.delete_all_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>
|
||||||
<Button size="sm" onClick={handleUploadClick} disabled={disableControls || uploading}>
|
<Button size="sm" onClick={handleUploadClick} disabled={disableControls || uploading} loading={uploading}>
|
||||||
{t('auth_files.upload_button')}
|
{t('auth_files.upload_button')}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,application/json"
|
accept=".json,application/json"
|
||||||
|
multiple
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
<div className="filters">
|
{/* 筛选区域 */}
|
||||||
<div className="filter-item">
|
<div className={styles.filterSection}>
|
||||||
<label>{t('auth_files.search_label')}</label>
|
{renderFilterTags()}
|
||||||
<Input
|
|
||||||
value={search}
|
<div className={styles.filterControls}>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<div className={styles.filterItem}>
|
||||||
placeholder={t('auth_files.search_placeholder')}
|
<label>{t('auth_files.search_label')}</label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
value={search}
|
||||||
<div className="filter-item">
|
onChange={(e) => {
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
setSearch(e.target.value);
|
||||||
<input
|
setPage(1);
|
||||||
className="input"
|
}}
|
||||||
type="number"
|
placeholder={t('auth_files.search_placeholder')}
|
||||||
min={1}
|
/>
|
||||||
value={pageSize}
|
</div>
|
||||||
onChange={(e) => setPageSize(Number(e.target.value) || 10)}
|
<div className={styles.filterItem}>
|
||||||
/>
|
<label>{t('auth_files.page_size_label')}</label>
|
||||||
</div>
|
<select
|
||||||
<div className="filter-item">
|
className={styles.pageSizeSelect}
|
||||||
<label>{t('common.info')}</label>
|
value={pageSize}
|
||||||
<div className="pill">
|
onChange={(e) => {
|
||||||
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
|
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>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 卡片网格 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="hint">{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : pageItems.length === 0 ? (
|
) : pageItems.length === 0 ? (
|
||||||
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
|
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
|
||||||
) : (
|
) : (
|
||||||
<div className="table">
|
<div className={styles.fileGrid}>
|
||||||
<div className="table-header">
|
{pageItems.map(renderFileCard)}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pagination">
|
{/* 分页 */}
|
||||||
<Button variant="secondary" size="sm" onClick={() => setPage(Math.max(1, currentPage - 1))}>
|
{!loading && filtered.length > pageSize && (
|
||||||
{t('auth_files.pagination_prev')}
|
<div className={styles.pagination}>
|
||||||
</Button>
|
<Button
|
||||||
<div className="pill">
|
variant="secondary"
|
||||||
{t('auth_files.pagination_info', {
|
size="sm"
|
||||||
current: currentPage,
|
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
||||||
total: totalPages,
|
disabled={currentPage <= 1}
|
||||||
count: filtered.length
|
>
|
||||||
})}
|
{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>
|
</div>
|
||||||
<Button
|
)}
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
|
|
||||||
>
|
|
||||||
{t('auth_files.pagination_next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* OAuth 排除列表卡片 */}
|
||||||
<Card
|
<Card
|
||||||
title={t('oauth_excluded.title')}
|
title={t('oauth_excluded.title')}
|
||||||
extra={
|
extra={
|
||||||
@@ -364,18 +663,18 @@ export function AuthFilesPage() {
|
|||||||
{Object.keys(excluded).length === 0 ? (
|
{Object.keys(excluded).length === 0 ? (
|
||||||
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
||||||
) : (
|
) : (
|
||||||
<div className="item-list">
|
<div className={styles.excludedList}>
|
||||||
{Object.entries(excluded).map(([provider, models]) => (
|
{Object.entries(excluded).map(([provider, models]) => (
|
||||||
<div key={provider} className="item-row">
|
<div key={provider} className={styles.excludedItem}>
|
||||||
<div className="item-meta">
|
<div className={styles.excludedInfo}>
|
||||||
<div className="item-title">{provider}</div>
|
<div className={styles.excludedProvider}>{provider}</div>
|
||||||
<div className="item-subtitle">
|
<div className={styles.excludedModels}>
|
||||||
{models?.length
|
{models?.length
|
||||||
? t('oauth_excluded.model_count', { count: models.length })
|
? t('oauth_excluded.model_count', { count: models.length })
|
||||||
: t('oauth_excluded.no_models')}
|
: t('oauth_excluded.no_models')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
<div className={styles.excludedActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
|
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -389,6 +688,39 @@ export function AuthFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</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
|
<Modal
|
||||||
open={excludedModalOpen}
|
open={excludedModalOpen}
|
||||||
onClose={() => setExcludedModalOpen(false)}
|
onClose={() => setExcludedModalOpen(false)}
|
||||||
@@ -410,16 +742,16 @@ export function AuthFilesPage() {
|
|||||||
value={excludedForm.provider}
|
value={excludedForm.provider}
|
||||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className={styles.formGroup}>
|
||||||
<label>{t('oauth_excluded.models_label')}</label>
|
<label>{t('oauth_excluded.models_label')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="input"
|
className={styles.textarea}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder={t('oauth_excluded.models_placeholder')}
|
placeholder={t('oauth_excluded.models_placeholder')}
|
||||||
value={excludedForm.modelsText}
|
value={excludedForm.modelsText}
|
||||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
|
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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user