mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
2230 lines
77 KiB
TypeScript
2230 lines
77 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, useCallback, type ReactNode } from 'react';
|
||
import { Trans, useTranslation } from 'react-i18next';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useInterval } from '@/hooks/useInterval';
|
||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||
import { Card } from '@/components/ui/Card';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||
import { Input } from '@/components/ui/Input';
|
||
import { Modal } from '@/components/ui/Modal';
|
||
import { EmptyState } from '@/components/ui/EmptyState';
|
||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
|
||
import {
|
||
IconBot,
|
||
IconCode,
|
||
IconChevronUp,
|
||
IconDownload,
|
||
IconInfo,
|
||
IconRefreshCw,
|
||
IconTrash2,
|
||
} from '@/components/ui/icons';
|
||
import type { TFunction } from 'i18next';
|
||
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||
import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
|
||
import { authFilesApi, usageApi } from '@/services/api';
|
||
import { apiClient } from '@/services/api/client';
|
||
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||
import { getStatusFromError, resolveAuthProvider } from '@/utils/quota';
|
||
import {
|
||
calculateStatusBarData,
|
||
collectUsageDetails,
|
||
normalizeUsageSourceId,
|
||
type KeyStatBucket,
|
||
type KeyStats,
|
||
type UsageDetail,
|
||
} from '@/utils/usage';
|
||
import { formatFileSize } from '@/utils/format';
|
||
import styles from './AuthFilesPage.module.scss';
|
||
|
||
type ThemeColors = { bg: string; text: string; border?: string };
|
||
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||
type ResolvedTheme = 'light' | 'dark';
|
||
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||
|
||
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||
qwen: {
|
||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||
},
|
||
gemini: {
|
||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||
},
|
||
'gemini-cli': {
|
||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||
},
|
||
aistudio: {
|
||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||
},
|
||
claude: {
|
||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||
},
|
||
codex: {
|
||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||
},
|
||
antigravity: {
|
||
light: { bg: '#e0f7fa', text: '#006064' },
|
||
dark: { bg: '#004d40', text: '#80deea' },
|
||
},
|
||
iflow: {
|
||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||
},
|
||
empty: {
|
||
light: { bg: '#f5f5f5', text: '#616161' },
|
||
dark: { bg: '#424242', text: '#bdbdbd' },
|
||
},
|
||
unknown: {
|
||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
|
||
},
|
||
};
|
||
|
||
const MIN_CARD_PAGE_SIZE = 3;
|
||
const MAX_CARD_PAGE_SIZE = 30;
|
||
const MAX_AUTH_FILE_SIZE = 50 * 1024;
|
||
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||
|
||
const clampCardPageSize = (value: number) =>
|
||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||
|
||
type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
|
||
|
||
const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||
|
||
const resolveQuotaErrorMessage = (
|
||
t: TFunction,
|
||
status: number | undefined,
|
||
fallback: string
|
||
): string => {
|
||
if (status === 404) return t('common.quota_update_required');
|
||
if (status === 403) return t('common.quota_check_credential');
|
||
return fallback;
|
||
};
|
||
|
||
type QuotaProgressBarProps = {
|
||
percent: number | null;
|
||
highThreshold: number;
|
||
mediumThreshold: number;
|
||
};
|
||
|
||
function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
|
||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||
const fillClass =
|
||
normalized === null
|
||
? styles.quotaBarFillMedium
|
||
: normalized >= highThreshold
|
||
? styles.quotaBarFillHigh
|
||
: normalized >= mediumThreshold
|
||
? styles.quotaBarFillMedium
|
||
: styles.quotaBarFillLow;
|
||
const widthPercent = Math.round(normalized ?? 0);
|
||
|
||
return (
|
||
<div className={styles.quotaBar}>
|
||
<div
|
||
className={`${styles.quotaBarFill} ${fillClass}`}
|
||
style={{ width: `${widthPercent}%` }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type AuthFilesUiState = {
|
||
filter?: string;
|
||
search?: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
};
|
||
|
||
const readAuthFilesUiState = (): AuthFilesUiState | null => {
|
||
if (typeof window === 'undefined') return null;
|
||
try {
|
||
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
|
||
if (!raw) return null;
|
||
const parsed = JSON.parse(raw) as AuthFilesUiState;
|
||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||
if (typeof window === 'undefined') return;
|
||
try {
|
||
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
interface PrefixProxyEditorState {
|
||
fileName: string;
|
||
loading: boolean;
|
||
saving: boolean;
|
||
error: string | null;
|
||
originalText: string;
|
||
rawText: string;
|
||
json: Record<string, unknown> | null;
|
||
prefix: string;
|
||
proxyUrl: 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 isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||
if (typeof raw === 'boolean') return raw;
|
||
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
||
return false;
|
||
}
|
||
|
||
// 解析认证文件的统计数据
|
||
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 (文件名) 匹配
|
||
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
|
||
if (fileNameId && stats.bySource?.[fileNameId]) {
|
||
const fromName = stats.bySource[fileNameId];
|
||
if (fromName.success > 0 || fromName.failure > 0) {
|
||
return fromName;
|
||
}
|
||
}
|
||
|
||
// 尝试去掉扩展名后匹配
|
||
if (rawFileName) {
|
||
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
||
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
|
||
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
|
||
if (
|
||
fromNameWithoutExt &&
|
||
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
|
||
) {
|
||
return fromNameWithoutExt;
|
||
}
|
||
}
|
||
}
|
||
|
||
return defaultStats;
|
||
}
|
||
|
||
export function AuthFilesPage() {
|
||
const { t } = useTranslation();
|
||
const { showNotification, showConfirmation } = useNotificationStore();
|
||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
|
||
const codexQuota = useQuotaStore((state) => state.codexQuota);
|
||
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
|
||
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
||
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
||
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
||
const navigate = useNavigate();
|
||
|
||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [filter, setFilter] = useState<'all' | string>('all');
|
||
const [search, setSearch] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(9);
|
||
const [pageSizeInput, setPageSizeInput] = useState('9');
|
||
const [uploading, setUploading] = useState(false);
|
||
const [deleting, setDeleting] = useState<string | null>(null);
|
||
const [deletingAll, setDeletingAll] = useState(false);
|
||
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||
|
||
// 详情弹窗相关
|
||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
|
||
|
||
// 模型列表弹窗相关
|
||
const [modelsModalOpen, setModelsModalOpen] = useState(false);
|
||
const [modelsLoading, setModelsLoading] = useState(false);
|
||
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||
const [modelsFileName, setModelsFileName] = useState('');
|
||
const [modelsFileType, setModelsFileType] = useState('');
|
||
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
|
||
|
||
// OAuth 排除模型相关
|
||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||
|
||
// OAuth 模型映射相关
|
||
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||
const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null);
|
||
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
|
||
{}
|
||
);
|
||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||
|
||
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const loadingKeyStatsRef = useRef(false);
|
||
const excludedUnsupportedRef = useRef(false);
|
||
const mappingsUnsupportedRef = useRef(false);
|
||
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
|
||
|
||
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||
|
||
const disableControls = connectionStatus !== 'connected';
|
||
const normalizedFilter = normalizeProviderKey(String(filter));
|
||
const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has(
|
||
normalizedFilter as QuotaProviderType
|
||
)
|
||
? (normalizedFilter as QuotaProviderType)
|
||
: null;
|
||
|
||
const providerList = useMemo(() => {
|
||
const providers = new Set<string>();
|
||
|
||
Object.keys(modelAlias).forEach((provider) => {
|
||
const key = provider.trim().toLowerCase();
|
||
if (key) providers.add(key);
|
||
});
|
||
|
||
files.forEach((file) => {
|
||
if (typeof file.type === 'string') {
|
||
const key = file.type.trim().toLowerCase();
|
||
if (key) providers.add(key);
|
||
}
|
||
if (typeof file.provider === 'string') {
|
||
const key = file.provider.trim().toLowerCase();
|
||
if (key) providers.add(key);
|
||
}
|
||
});
|
||
return Array.from(providers);
|
||
}, [files, modelAlias]);
|
||
|
||
useEffect(() => {
|
||
if (viewMode !== 'diagram') return;
|
||
|
||
let cancelled = false;
|
||
|
||
const loadAllModels = async () => {
|
||
if (providerList.length === 0) {
|
||
if (!cancelled) setAllProviderModels({});
|
||
return;
|
||
}
|
||
|
||
const results = await Promise.all(
|
||
providerList.map(async (provider) => {
|
||
try {
|
||
const models = await authFilesApi.getModelDefinitions(provider);
|
||
return { provider, models };
|
||
} catch {
|
||
return { provider, models: [] };
|
||
}
|
||
})
|
||
);
|
||
|
||
if (cancelled) return;
|
||
|
||
const nextModels: Record<string, AuthFileModelItem[]> = {};
|
||
results.forEach(({ provider, models }) => {
|
||
if (models.length > 0) {
|
||
nextModels[provider] = models;
|
||
}
|
||
});
|
||
|
||
setAllProviderModels(nextModels);
|
||
};
|
||
|
||
void loadAllModels();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [providerList, viewMode]);
|
||
|
||
|
||
|
||
useEffect(() => {
|
||
const persisted = readAuthFilesUiState();
|
||
if (!persisted) return;
|
||
|
||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||
setFilter(persisted.filter);
|
||
}
|
||
if (typeof persisted.search === 'string') {
|
||
setSearch(persisted.search);
|
||
}
|
||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||
setPage(Math.max(1, Math.round(persisted.page)));
|
||
}
|
||
if (typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)) {
|
||
setPageSize(clampCardPageSize(persisted.pageSize));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
writeAuthFilesUiState({ filter, search, page, pageSize });
|
||
}, [filter, search, page, pageSize]);
|
||
|
||
useEffect(() => {
|
||
setPageSizeInput(String(pageSize));
|
||
}, [pageSize]);
|
||
|
||
const prefixProxyUpdatedText = useMemo(() => {
|
||
if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? '';
|
||
const next: Record<string, unknown> = { ...prefixProxyEditor.json };
|
||
if ('prefix' in next || prefixProxyEditor.prefix.trim()) {
|
||
next.prefix = prefixProxyEditor.prefix;
|
||
}
|
||
if ('proxy_url' in next || prefixProxyEditor.proxyUrl.trim()) {
|
||
next.proxy_url = prefixProxyEditor.proxyUrl;
|
||
}
|
||
return JSON.stringify(next);
|
||
}, [
|
||
prefixProxyEditor?.json,
|
||
prefixProxyEditor?.prefix,
|
||
prefixProxyEditor?.proxyUrl,
|
||
prefixProxyEditor?.rawText,
|
||
]);
|
||
|
||
const prefixProxyDirty = useMemo(() => {
|
||
if (!prefixProxyEditor?.json) return false;
|
||
if (!prefixProxyEditor.originalText) return false;
|
||
return prefixProxyUpdatedText !== prefixProxyEditor.originalText;
|
||
}, [prefixProxyEditor?.json, prefixProxyEditor?.originalText, prefixProxyUpdatedText]);
|
||
|
||
const commitPageSizeInput = (rawValue: string) => {
|
||
const trimmed = rawValue.trim();
|
||
if (!trimmed) {
|
||
setPageSizeInput(String(pageSize));
|
||
return;
|
||
}
|
||
|
||
const value = Number(trimmed);
|
||
if (!Number.isFinite(value)) {
|
||
setPageSizeInput(String(pageSize));
|
||
return;
|
||
}
|
||
|
||
const next = clampCardPageSize(value);
|
||
setPageSize(next);
|
||
setPageSizeInput(String(next));
|
||
setPage(1);
|
||
};
|
||
|
||
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const rawValue = event.currentTarget.value;
|
||
setPageSizeInput(rawValue);
|
||
|
||
const trimmed = rawValue.trim();
|
||
if (!trimmed) return;
|
||
|
||
const parsed = Number(trimmed);
|
||
if (!Number.isFinite(parsed)) return;
|
||
|
||
const rounded = Math.round(parsed);
|
||
if (rounded < MIN_CARD_PAGE_SIZE || rounded > MAX_CARD_PAGE_SIZE) return;
|
||
|
||
setPageSize(rounded);
|
||
setPage(1);
|
||
};
|
||
|
||
// 格式化修改时间
|
||
const formatModified = (item: AuthFileItem): string => {
|
||
const raw = item['modtime'] ?? item.modified;
|
||
if (!raw) return '-';
|
||
const asNumber = Number(raw);
|
||
const date =
|
||
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||
: new Date(String(raw));
|
||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||
};
|
||
|
||
// 加载文件列表
|
||
const loadFiles = useCallback(async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const data = await authFilesApi.list();
|
||
setFiles(data?.files || []);
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||
setError(errorMessage);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [t]);
|
||
|
||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||
const loadKeyStats = useCallback(async () => {
|
||
// 防止重复请求
|
||
if (loadingKeyStatsRef.current) return;
|
||
loadingKeyStatsRef.current = true;
|
||
try {
|
||
const usageResponse = await usageApi.getUsage();
|
||
const usageData = usageResponse?.usage ?? usageResponse;
|
||
const stats = await usageApi.getKeyStats(usageData);
|
||
setKeyStats(stats);
|
||
// 收集 usage 明细用于状态栏
|
||
const details = collectUsageDetails(usageData);
|
||
setUsageDetails(details);
|
||
} catch {
|
||
// 静默失败
|
||
} finally {
|
||
loadingKeyStatsRef.current = false;
|
||
}
|
||
}, []);
|
||
|
||
// 加载 OAuth 排除列表
|
||
const loadExcluded = useCallback(async () => {
|
||
try {
|
||
const res = await authFilesApi.getOauthExcludedModels();
|
||
excludedUnsupportedRef.current = false;
|
||
setExcluded(res || {});
|
||
setExcludedError(null);
|
||
} catch (err: unknown) {
|
||
const status =
|
||
typeof err === 'object' && err !== null && 'status' in err
|
||
? (err as { status?: unknown }).status
|
||
: undefined;
|
||
|
||
if (status === 404) {
|
||
setExcluded({});
|
||
setExcludedError('unsupported');
|
||
if (!excludedUnsupportedRef.current) {
|
||
excludedUnsupportedRef.current = true;
|
||
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
|
||
}
|
||
return;
|
||
}
|
||
// 静默失败
|
||
}
|
||
}, [showNotification, t]);
|
||
|
||
// 加载 OAuth 模型映射
|
||
const loadModelAlias = useCallback(async () => {
|
||
try {
|
||
const res = await authFilesApi.getOauthModelAlias();
|
||
mappingsUnsupportedRef.current = false;
|
||
setModelAlias(res || {});
|
||
setModelAliasError(null);
|
||
} catch (err: unknown) {
|
||
const status =
|
||
typeof err === 'object' && err !== null && 'status' in err
|
||
? (err as { status?: unknown }).status
|
||
: undefined;
|
||
|
||
if (status === 404) {
|
||
setModelAlias({});
|
||
setModelAliasError('unsupported');
|
||
if (!mappingsUnsupportedRef.current) {
|
||
mappingsUnsupportedRef.current = true;
|
||
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
|
||
}
|
||
return;
|
||
}
|
||
// 静默失败
|
||
}
|
||
}, [showNotification, t]);
|
||
|
||
const handleHeaderRefresh = useCallback(async () => {
|
||
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelAlias()]);
|
||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||
|
||
useHeaderRefresh(handleHeaderRefresh);
|
||
|
||
useEffect(() => {
|
||
loadFiles();
|
||
loadKeyStats();
|
||
loadExcluded();
|
||
loadModelAlias();
|
||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||
|
||
// 定时刷新状态数据(每240秒)
|
||
useInterval(loadKeyStats, 240_000);
|
||
|
||
// 提取所有存在的类型
|
||
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 typeCounts = useMemo(() => {
|
||
const counts: Record<string, number> = { all: files.length };
|
||
files.forEach((file) => {
|
||
if (!file.type) return;
|
||
counts[file.type] = (counts[file.type] || 0) + 1;
|
||
});
|
||
return counts;
|
||
}, [files]);
|
||
|
||
// 过滤和搜索
|
||
const filtered = useMemo(() => {
|
||
return files.filter((item) => {
|
||
const matchType = filter === 'all' || item.type === filter;
|
||
const term = search.trim().toLowerCase();
|
||
const matchSearch =
|
||
!term ||
|
||
item.name.toLowerCase().includes(term) ||
|
||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||
(item.provider || '').toString().toLowerCase().includes(term);
|
||
return matchType && matchSearch;
|
||
});
|
||
}, [files, filter, search]);
|
||
|
||
// 分页计算
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||
const currentPage = Math.min(page, totalPages);
|
||
const start = (currentPage - 1) * pageSize;
|
||
const pageItems = filtered.slice(start, start + pageSize);
|
||
|
||
// 点击上传
|
||
const handleUploadClick = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
// 处理文件上传(支持多选)
|
||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const fileList = event.target.files;
|
||
if (!fileList || fileList.length === 0) return;
|
||
|
||
const filesToUpload = Array.from(fileList);
|
||
const validFiles: File[] = [];
|
||
const invalidFiles: string[] = [];
|
||
const oversizedFiles: string[] = [];
|
||
|
||
filesToUpload.forEach((file) => {
|
||
if (!file.name.endsWith('.json')) {
|
||
invalidFiles.push(file.name);
|
||
return;
|
||
}
|
||
if (file.size > MAX_AUTH_FILE_SIZE) {
|
||
oversizedFiles.push(file.name);
|
||
return;
|
||
}
|
||
validFiles.push(file);
|
||
});
|
||
|
||
if (invalidFiles.length > 0) {
|
||
showNotification(t('auth_files.upload_error_json'), 'error');
|
||
}
|
||
if (oversizedFiles.length > 0) {
|
||
showNotification(
|
||
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||
'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) => {
|
||
showConfirmation({
|
||
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
|
||
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
|
||
variant: 'danger',
|
||
confirmText: t('common.confirm'),
|
||
onConfirm: async () => {
|
||
setDeleting(name);
|
||
try {
|
||
await authFilesApi.deleteFile(name);
|
||
showNotification(t('auth_files.delete_success'), 'success');
|
||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||
} finally {
|
||
setDeleting(null);
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
// 删除全部(根据筛选类型)
|
||
const handleDeleteAll = async () => {
|
||
const isFiltered = filter !== 'all';
|
||
const typeLabel = isFiltered ? getTypeLabel(filter) : t('auth_files.filter_all');
|
||
const confirmMessage = isFiltered
|
||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||
: t('auth_files.delete_all_confirm');
|
||
|
||
showConfirmation({
|
||
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||
message: confirmMessage,
|
||
variant: 'danger',
|
||
confirmText: t('common.confirm'),
|
||
onConfirm: async () => {
|
||
setDeletingAll(true);
|
||
try {
|
||
if (!isFiltered) {
|
||
// 删除全部
|
||
await authFilesApi.deleteAll();
|
||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||
} else {
|
||
// 删除筛选类型的文件
|
||
const filesToDelete = files.filter(
|
||
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||
);
|
||
|
||
if (filesToDelete.length === 0) {
|
||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||
setDeletingAll(false);
|
||
return;
|
||
}
|
||
|
||
let success = 0;
|
||
let failed = 0;
|
||
const deletedNames: string[] = [];
|
||
|
||
for (const file of filesToDelete) {
|
||
try {
|
||
await authFilesApi.deleteFile(file.name);
|
||
success++;
|
||
deletedNames.push(file.name);
|
||
} catch {
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||
|
||
if (failed === 0) {
|
||
showNotification(
|
||
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||
'success'
|
||
);
|
||
} else {
|
||
showNotification(
|
||
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
||
'warning'
|
||
);
|
||
}
|
||
setFilter('all');
|
||
}
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||
} finally {
|
||
setDeletingAll(false);
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
// 下载文件
|
||
const handleDownload = async (name: string) => {
|
||
try {
|
||
const response = await apiClient.getRaw(
|
||
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||
{
|
||
responseType: 'blob',
|
||
}
|
||
);
|
||
const blob = new Blob([response.data]);
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = name;
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
showNotification(t('auth_files.download_success'), 'success');
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
};
|
||
|
||
const openPrefixProxyEditor = async (name: string) => {
|
||
if (disableControls) return;
|
||
if (prefixProxyEditor?.fileName === name) {
|
||
setPrefixProxyEditor(null);
|
||
return;
|
||
}
|
||
|
||
setPrefixProxyEditor({
|
||
fileName: name,
|
||
loading: true,
|
||
saving: false,
|
||
error: null,
|
||
originalText: '',
|
||
rawText: '',
|
||
json: null,
|
||
prefix: '',
|
||
proxyUrl: '',
|
||
});
|
||
|
||
try {
|
||
const rawText = await authFilesApi.downloadText(name);
|
||
const trimmed = rawText.trim();
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(trimmed) as unknown;
|
||
} catch {
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev || prev.fileName !== name) return prev;
|
||
return {
|
||
...prev,
|
||
loading: false,
|
||
error: t('auth_files.prefix_proxy_invalid_json'),
|
||
rawText: trimmed,
|
||
originalText: trimmed,
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev || prev.fileName !== name) return prev;
|
||
return {
|
||
...prev,
|
||
loading: false,
|
||
error: t('auth_files.prefix_proxy_invalid_json'),
|
||
rawText: trimmed,
|
||
originalText: trimmed,
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
const json = parsed as Record<string, unknown>;
|
||
const originalText = JSON.stringify(json);
|
||
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
|
||
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev || prev.fileName !== name) return prev;
|
||
return {
|
||
...prev,
|
||
loading: false,
|
||
originalText,
|
||
rawText: originalText,
|
||
json,
|
||
prefix,
|
||
proxyUrl,
|
||
error: null,
|
||
};
|
||
});
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev || prev.fileName !== name) return prev;
|
||
return { ...prev, loading: false, error: errorMessage, rawText: '' };
|
||
});
|
||
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
};
|
||
|
||
const handlePrefixProxyChange = (field: 'prefix' | 'proxyUrl', value: string) => {
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev) return prev;
|
||
if (field === 'prefix') return { ...prev, prefix: value };
|
||
return { ...prev, proxyUrl: value };
|
||
});
|
||
};
|
||
|
||
const handlePrefixProxySave = async () => {
|
||
if (!prefixProxyEditor?.json) return;
|
||
if (!prefixProxyDirty) return;
|
||
|
||
const name = prefixProxyEditor.fileName;
|
||
const payload = prefixProxyUpdatedText;
|
||
const fileSize = new Blob([payload]).size;
|
||
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||
showNotification(
|
||
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||
'error'
|
||
);
|
||
return;
|
||
}
|
||
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev || prev.fileName !== name) return prev;
|
||
return { ...prev, saving: true };
|
||
});
|
||
|
||
try {
|
||
const file = new File([payload], name, { type: 'application/json' });
|
||
await authFilesApi.upload(file);
|
||
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||
await loadFiles();
|
||
await loadKeyStats();
|
||
setPrefixProxyEditor(null);
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||
setPrefixProxyEditor((prev) => {
|
||
if (!prev || prev.fileName !== name) return prev;
|
||
return { ...prev, saving: false };
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleStatusToggle = async (item: AuthFileItem, enabled: boolean) => {
|
||
const name = item.name;
|
||
const nextDisabled = !enabled;
|
||
const previousDisabled = item.disabled === true;
|
||
|
||
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
|
||
// Optimistic update for snappy UI.
|
||
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
||
|
||
try {
|
||
const res = await authFilesApi.setStatus(name, nextDisabled);
|
||
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f)));
|
||
showNotification(
|
||
enabled
|
||
? t('auth_files.status_enabled_success', { name })
|
||
: t('auth_files.status_disabled_success', { name }),
|
||
'success'
|
||
);
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
setFiles((prev) =>
|
||
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
||
);
|
||
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
|
||
} finally {
|
||
setStatusUpdating((prev) => {
|
||
if (!prev[name]) return prev;
|
||
const next = { ...prev };
|
||
delete next[name];
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
// 显示详情弹窗
|
||
const showDetails = (file: AuthFileItem) => {
|
||
setSelectedFile(file);
|
||
setDetailModalOpen(true);
|
||
};
|
||
|
||
// 显示模型列表
|
||
const showModels = async (item: AuthFileItem) => {
|
||
setModelsFileName(item.name);
|
||
setModelsFileType(item.type || '');
|
||
setModelsList([]);
|
||
setModelsError(null);
|
||
setModelsModalOpen(true);
|
||
|
||
const cached = modelsCacheRef.current.get(item.name);
|
||
if (cached) {
|
||
setModelsList(cached);
|
||
setModelsLoading(false);
|
||
return;
|
||
}
|
||
|
||
setModelsLoading(true);
|
||
try {
|
||
const models = await authFilesApi.getModelsForAuthFile(item.name);
|
||
modelsCacheRef.current.set(item.name, models);
|
||
setModelsList(models);
|
||
} catch (err) {
|
||
// 检测是否是 API 不支持的错误 (404 或特定错误消息)
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
if (
|
||
errorMessage.includes('404') ||
|
||
errorMessage.includes('not found') ||
|
||
errorMessage.includes('Not Found')
|
||
) {
|
||
setModelsError('unsupported');
|
||
} else {
|
||
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
} finally {
|
||
setModelsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 检查模型是否被 OAuth 排除
|
||
const isModelExcluded = (modelId: string, providerType: string): boolean => {
|
||
const providerKey = normalizeProviderKey(providerType);
|
||
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||
return excludedModels.some((pattern) => {
|
||
if (pattern.includes('*')) {
|
||
// 支持通配符匹配
|
||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
|
||
return regex.test(modelId);
|
||
}
|
||
return pattern.toLowerCase() === modelId.toLowerCase();
|
||
});
|
||
};
|
||
|
||
// 获取类型标签显示文本
|
||
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): ThemeColors => {
|
||
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||
};
|
||
|
||
const openExcludedEditor = (provider?: string) => {
|
||
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
|
||
const params = new URLSearchParams();
|
||
if (providerValue) {
|
||
params.set('provider', providerValue);
|
||
}
|
||
const search = params.toString();
|
||
navigate(`/auth-files/oauth-excluded${search ? `?${search}` : ''}`, {
|
||
state: { fromAuthFiles: true },
|
||
});
|
||
};
|
||
|
||
const deleteExcluded = async (provider: string) => {
|
||
const providerLabel = provider.trim() || provider;
|
||
showConfirmation({
|
||
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
|
||
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
|
||
variant: 'danger',
|
||
confirmText: t('common.confirm'),
|
||
onConfirm: async () => {
|
||
const providerKey = normalizeProviderKey(provider);
|
||
if (!providerKey) {
|
||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||
return;
|
||
}
|
||
try {
|
||
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||
await loadExcluded();
|
||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||
} catch (err: unknown) {
|
||
try {
|
||
const current = await authFilesApi.getOauthExcludedModels();
|
||
const next: Record<string, string[]> = {};
|
||
Object.entries(current).forEach(([key, models]) => {
|
||
if (normalizeProviderKey(key) === providerKey) return;
|
||
next[key] = models;
|
||
});
|
||
await authFilesApi.replaceOauthExcludedModels(next);
|
||
await loadExcluded();
|
||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||
} catch (fallbackErr: unknown) {
|
||
const errorMessage =
|
||
fallbackErr instanceof Error
|
||
? fallbackErr.message
|
||
: err instanceof Error
|
||
? err.message
|
||
: '';
|
||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const openModelAliasEditor = (provider?: string) => {
|
||
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
|
||
const params = new URLSearchParams();
|
||
if (providerValue) {
|
||
params.set('provider', providerValue);
|
||
}
|
||
const search = params.toString();
|
||
navigate(`/auth-files/oauth-model-alias${search ? `?${search}` : ''}`, {
|
||
state: { fromAuthFiles: true },
|
||
});
|
||
};
|
||
|
||
const deleteModelAlias = async (provider: string) => {
|
||
showConfirmation({
|
||
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
|
||
message: t('oauth_model_alias.delete_confirm', { provider }),
|
||
variant: 'danger',
|
||
confirmText: t('common.confirm'),
|
||
onConfirm: async () => {
|
||
try {
|
||
await authFilesApi.deleteOauthModelAlias(provider);
|
||
await loadModelAlias();
|
||
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleMappingUpdate = async (provider: string, sourceModel: string, newAlias: string) => {
|
||
if (!provider || !sourceModel || !newAlias) return;
|
||
const normalizedProvider = normalizeProviderKey(provider);
|
||
if (!normalizedProvider) return;
|
||
|
||
const providerKey = Object.keys(modelAlias).find(
|
||
(key) => normalizeProviderKey(key) === normalizedProvider
|
||
);
|
||
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||
|
||
const nameTrim = sourceModel.trim();
|
||
const aliasTrim = newAlias.trim();
|
||
const nameKey = nameTrim.toLowerCase();
|
||
const aliasKey = aliasTrim.toLowerCase();
|
||
|
||
if (
|
||
currentMappings.some(
|
||
(m) =>
|
||
(m.name ?? '').trim().toLowerCase() === nameKey &&
|
||
(m.alias ?? '').trim().toLowerCase() === aliasKey
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const nextMappings: OAuthModelAliasEntry[] = [
|
||
...currentMappings,
|
||
{ name: nameTrim, alias: aliasTrim, fork: true },
|
||
];
|
||
|
||
try {
|
||
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||
await loadModelAlias();
|
||
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
};
|
||
|
||
const handleDeleteLink = (provider: string, sourceModel: string, alias: string) => {
|
||
const nameTrim = sourceModel.trim();
|
||
const aliasTrim = alias.trim();
|
||
if (!provider || !nameTrim || !aliasTrim) return;
|
||
|
||
showConfirmation({
|
||
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
|
||
message: (
|
||
<Trans
|
||
i18nKey="oauth_model_alias.delete_link_confirm"
|
||
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
|
||
components={{ code: <code /> }}
|
||
/>
|
||
),
|
||
variant: 'danger',
|
||
confirmText: t('common.confirm'),
|
||
onConfirm: async () => {
|
||
const normalizedProvider = normalizeProviderKey(provider);
|
||
const providerKey = Object.keys(modelAlias).find(
|
||
(key) => normalizeProviderKey(key) === normalizedProvider
|
||
);
|
||
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||
const nameKey = nameTrim.toLowerCase();
|
||
const aliasKey = aliasTrim.toLowerCase();
|
||
const nextMappings = currentMappings.filter(
|
||
(m) =>
|
||
(m.name ?? '').trim().toLowerCase() !== nameKey ||
|
||
(m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||
);
|
||
if (nextMappings.length === currentMappings.length) return;
|
||
|
||
try {
|
||
if (nextMappings.length === 0) {
|
||
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
|
||
} else {
|
||
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||
}
|
||
await loadModelAlias();
|
||
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleToggleFork = async (
|
||
provider: string,
|
||
sourceModel: string,
|
||
alias: string,
|
||
fork: boolean
|
||
) => {
|
||
const normalizedProvider = normalizeProviderKey(provider);
|
||
if (!normalizedProvider) return;
|
||
|
||
const providerKey = Object.keys(modelAlias).find(
|
||
(key) => normalizeProviderKey(key) === normalizedProvider
|
||
);
|
||
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||
const nameKey = sourceModel.trim().toLowerCase();
|
||
const aliasKey = alias.trim().toLowerCase();
|
||
let changed = false;
|
||
|
||
const nextMappings = currentMappings.map((m) => {
|
||
const mName = (m.name ?? '').trim().toLowerCase();
|
||
const mAlias = (m.alias ?? '').trim().toLowerCase();
|
||
if (mName === nameKey && mAlias === aliasKey) {
|
||
changed = true;
|
||
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
|
||
}
|
||
return m;
|
||
});
|
||
|
||
if (!changed) return;
|
||
|
||
try {
|
||
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||
await loadModelAlias();
|
||
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : '';
|
||
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||
}
|
||
};
|
||
|
||
const handleRenameAlias = async (oldAlias: string, newAlias: string) => {
|
||
const oldTrim = oldAlias.trim();
|
||
const newTrim = newAlias.trim();
|
||
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
|
||
|
||
const oldKey = oldTrim.toLowerCase();
|
||
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
|
||
);
|
||
|
||
if (providersToUpdate.length === 0) return;
|
||
|
||
let hadFailure = false;
|
||
let failureMessage = '';
|
||
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
providersToUpdate.map(([provider, mappings]) => {
|
||
const nextMappings = mappings.map((m) =>
|
||
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
|
||
);
|
||
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||
})
|
||
);
|
||
|
||
const failures = results.filter(
|
||
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||
);
|
||
|
||
if (failures.length > 0) {
|
||
hadFailure = true;
|
||
const reason = failures[0].reason;
|
||
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||
}
|
||
} finally {
|
||
await loadModelAlias();
|
||
}
|
||
|
||
if (hadFailure) {
|
||
showNotification(
|
||
failureMessage
|
||
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
|
||
: t('oauth_model_alias.save_failed'),
|
||
'error'
|
||
);
|
||
} else {
|
||
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||
}
|
||
};
|
||
|
||
const handleDeleteAlias = (aliasName: string) => {
|
||
const aliasTrim = aliasName.trim();
|
||
if (!aliasTrim) return;
|
||
const aliasKey = aliasTrim.toLowerCase();
|
||
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
|
||
);
|
||
|
||
if (providersToUpdate.length === 0) return;
|
||
|
||
showConfirmation({
|
||
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
|
||
message: (
|
||
<Trans
|
||
i18nKey="oauth_model_alias.delete_alias_confirm"
|
||
values={{ alias: aliasTrim }}
|
||
components={{ code: <code /> }}
|
||
/>
|
||
),
|
||
variant: 'danger',
|
||
confirmText: t('common.confirm'),
|
||
onConfirm: async () => {
|
||
let hadFailure = false;
|
||
let failureMessage = '';
|
||
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
providersToUpdate.map(([provider, mappings]) => {
|
||
const nextMappings = mappings.filter(
|
||
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||
);
|
||
if (nextMappings.length === 0) {
|
||
return authFilesApi.deleteOauthModelAlias(provider);
|
||
}
|
||
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||
})
|
||
);
|
||
|
||
const failures = results.filter(
|
||
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||
);
|
||
|
||
if (failures.length > 0) {
|
||
hadFailure = true;
|
||
const reason = failures[0].reason;
|
||
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||
}
|
||
} finally {
|
||
await loadModelAlias();
|
||
}
|
||
|
||
if (hadFailure) {
|
||
showNotification(
|
||
failureMessage
|
||
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
|
||
: t('oauth_model_alias.delete_failed'),
|
||
'error'
|
||
);
|
||
} else {
|
||
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
// 渲染标签筛选器
|
||
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);
|
||
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
||
return (
|
||
<button
|
||
key={type}
|
||
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
|
||
style={{
|
||
backgroundColor: isActive ? color.text : color.bg,
|
||
color: isActive ? activeTextColor : color.text,
|
||
borderColor: color.text,
|
||
}}
|
||
onClick={() => {
|
||
setFilter(type);
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<span className={styles.filterTagLabel}>{getTypeLabel(type)}</span>
|
||
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
|
||
// 预计算所有认证文件的状态栏数据(避免每次渲染重复计算)
|
||
const statusBarCache = useMemo(() => {
|
||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||
|
||
files.forEach((file) => {
|
||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||
|
||
if (authIndexKey) {
|
||
// 过滤出属于该认证文件的 usage 明细
|
||
const filteredDetails = usageDetails.filter((detail) => {
|
||
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||
});
|
||
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||
}
|
||
});
|
||
|
||
return cache;
|
||
}, [usageDetails, files]);
|
||
|
||
// 渲染状态监测栏
|
||
const renderStatusBar = (item: AuthFileItem) => {
|
||
// 认证文件使用 authIndex 来匹配 usage 数据
|
||
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
|
||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||
|
||
const statusData =
|
||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||
const rateClass = !hasData
|
||
? ''
|
||
: statusData.successRate >= 90
|
||
? styles.statusRateHigh
|
||
: statusData.successRate >= 50
|
||
? styles.statusRateMedium
|
||
: styles.statusRateLow;
|
||
|
||
return (
|
||
<div className={styles.statusBar}>
|
||
<div className={styles.statusBlocks}>
|
||
{statusData.blocks.map((state, idx) => {
|
||
const blockClass =
|
||
state === 'success'
|
||
? styles.statusBlockSuccess
|
||
: state === 'failure'
|
||
? styles.statusBlockFailure
|
||
: state === 'mixed'
|
||
? styles.statusBlockMixed
|
||
: styles.statusBlockIdle;
|
||
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||
})}
|
||
</div>
|
||
<span className={`${styles.statusRate} ${rateClass}`}>
|
||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||
</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||
const provider = resolveAuthProvider(file);
|
||
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||
return provider as QuotaProviderType;
|
||
};
|
||
|
||
const getQuotaConfig = (type: QuotaProviderType) => {
|
||
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||
if (type === 'codex') return CODEX_CONFIG;
|
||
return GEMINI_CLI_CONFIG;
|
||
};
|
||
|
||
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
|
||
if (type === 'antigravity') return antigravityQuota[fileName];
|
||
if (type === 'codex') return codexQuota[fileName];
|
||
return geminiCliQuota[fileName];
|
||
};
|
||
|
||
const updateQuotaState = useCallback(
|
||
(
|
||
type: QuotaProviderType,
|
||
updater: (prev: Record<string, unknown>) => Record<string, unknown>
|
||
) => {
|
||
if (type === 'antigravity') {
|
||
setAntigravityQuota(updater as never);
|
||
return;
|
||
}
|
||
if (type === 'codex') {
|
||
setCodexQuota(updater as never);
|
||
return;
|
||
}
|
||
setGeminiCliQuota(updater as never);
|
||
},
|
||
[setAntigravityQuota, setCodexQuota, setGeminiCliQuota]
|
||
);
|
||
|
||
const refreshQuotaForFile = useCallback(
|
||
async (file: AuthFileItem, quotaType: QuotaProviderType) => {
|
||
if (disableControls) return;
|
||
if (isRuntimeOnlyAuthFile(file)) return;
|
||
if (file.disabled) return;
|
||
|
||
const currentState = getQuotaState(quotaType, file.name);
|
||
if (currentState?.status === 'loading') return;
|
||
|
||
const config = getQuotaConfig(quotaType) as unknown as {
|
||
i18nPrefix: string;
|
||
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||
buildLoadingState: () => unknown;
|
||
buildSuccessState: (data: unknown) => unknown;
|
||
buildErrorState: (message: string, status?: number) => unknown;
|
||
};
|
||
|
||
updateQuotaState(quotaType, (prev) => ({
|
||
...prev,
|
||
[file.name]: config.buildLoadingState()
|
||
}));
|
||
|
||
try {
|
||
const data = await config.fetchQuota(file, t);
|
||
updateQuotaState(quotaType, (prev) => ({
|
||
...prev,
|
||
[file.name]: config.buildSuccessState(data)
|
||
}));
|
||
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||
const status = getStatusFromError(err);
|
||
updateQuotaState(quotaType, (prev) => ({
|
||
...prev,
|
||
[file.name]: config.buildErrorState(message, status)
|
||
}));
|
||
showNotification(
|
||
t('auth_files.quota_refresh_failed', { name: file.name, message }),
|
||
'error'
|
||
);
|
||
}
|
||
},
|
||
[disableControls, getQuotaState, showNotification, t, updateQuotaState]
|
||
);
|
||
|
||
const renderQuotaSection = (item: AuthFileItem, quotaType: QuotaProviderType) => {
|
||
const config = getQuotaConfig(quotaType) as unknown as {
|
||
i18nPrefix: string;
|
||
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||
};
|
||
|
||
const quota = getQuotaState(quotaType, item.name) as
|
||
| { status?: string; error?: string; errorStatus?: number }
|
||
| undefined;
|
||
const quotaStatus = quota?.status ?? 'idle';
|
||
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||
t,
|
||
quota?.errorStatus,
|
||
quota?.error || t('common.unknown_error')
|
||
);
|
||
|
||
return (
|
||
<div className={styles.quotaSection}>
|
||
{quotaStatus === 'loading' ? (
|
||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||
) : quotaStatus === 'idle' ? (
|
||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||
) : quotaStatus === 'error' ? (
|
||
<div className={styles.quotaError}>
|
||
{t(`${config.i18nPrefix}.load_failed`, {
|
||
message: quotaErrorMessage
|
||
})}
|
||
</div>
|
||
) : quota ? (
|
||
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
|
||
) : (
|
||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 渲染单个认证文件卡片
|
||
const renderFileCard = (item: AuthFileItem) => {
|
||
const fileStats = resolveAuthFileStats(item, keyStats);
|
||
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
||
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
|
||
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||
const typeColor = getTypeColor(item.type || 'unknown');
|
||
|
||
const quotaType =
|
||
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
|
||
|
||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
|
||
const quotaRefreshing = quotaState?.status === 'loading';
|
||
|
||
const providerCardClass =
|
||
quotaType === 'antigravity'
|
||
? styles.antigravityCard
|
||
: quotaType === 'codex'
|
||
? styles.codexCard
|
||
: quotaType === 'gemini-cli'
|
||
? styles.geminiCliCard
|
||
: '';
|
||
|
||
return (
|
||
<div
|
||
key={item.name}
|
||
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
||
>
|
||
<div
|
||
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
|
||
>
|
||
<div className={styles.fileCardMain}>
|
||
<div className={styles.cardHeader}>
|
||
<span
|
||
className={styles.typeBadge}
|
||
style={{
|
||
backgroundColor: typeColor.bg,
|
||
color: typeColor.text,
|
||
...(typeColor.border ? { border: typeColor.border } : {}),
|
||
}}
|
||
>
|
||
{getTypeLabel(item.type || 'unknown')}
|
||
</span>
|
||
<span className={styles.fileName}>{item.name}</span>
|
||
</div>
|
||
|
||
<div className={styles.cardMeta}>
|
||
<span>
|
||
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
|
||
</span>
|
||
<span>
|
||
{t('auth_files.file_modified')}: {formatModified(item)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className={styles.cardStats}>
|
||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||
{t('stats.success')}: {fileStats.success}
|
||
</span>
|
||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||
{t('stats.failure')}: {fileStats.failure}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 状态监测栏 */}
|
||
{renderStatusBar(item)}
|
||
|
||
{showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)}
|
||
|
||
<div className={styles.cardActions}>
|
||
{showModelsButton && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => showModels(item)}
|
||
className={styles.iconButton}
|
||
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||
disabled={disableControls}
|
||
>
|
||
<IconBot className={styles.actionIcon} size={16} />
|
||
</Button>
|
||
)}
|
||
{!isRuntimeOnly && (
|
||
<>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => showDetails(item)}
|
||
className={styles.iconButton}
|
||
title={t('common.info', { defaultValue: '关于' })}
|
||
disabled={disableControls}
|
||
>
|
||
<IconInfo className={styles.actionIcon} size={16} />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => handleDownload(item.name)}
|
||
className={styles.iconButton}
|
||
title={t('auth_files.download_button')}
|
||
disabled={disableControls}
|
||
>
|
||
<IconDownload className={styles.actionIcon} size={16} />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => void openPrefixProxyEditor(item.name)}
|
||
className={styles.iconButton}
|
||
title={t('auth_files.prefix_proxy_button')}
|
||
disabled={disableControls}
|
||
>
|
||
<IconCode className={styles.actionIcon} size={16} />
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => handleDelete(item.name)}
|
||
className={styles.iconButton}
|
||
title={t('auth_files.delete_button')}
|
||
disabled={disableControls || deleting === item.name}
|
||
>
|
||
{deleting === item.name ? (
|
||
<LoadingSpinner size={14} />
|
||
) : (
|
||
<IconTrash2 className={styles.actionIcon} size={16} />
|
||
)}
|
||
</Button>
|
||
</>
|
||
)}
|
||
{!isRuntimeOnly && (
|
||
<div className={styles.statusToggle}>
|
||
<ToggleSwitch
|
||
ariaLabel={t('auth_files.status_toggle_label')}
|
||
checked={!item.disabled}
|
||
disabled={disableControls || statusUpdating[item.name] === true}
|
||
onChange={(value) => void handleStatusToggle(item, value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{isRuntimeOnly && (
|
||
<div className={styles.virtualBadge}>
|
||
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{showQuotaLayout && quotaType && (
|
||
<div className={styles.fileCardSidebar}>
|
||
<div className={styles.fileCardSidebarHeader}>
|
||
<span className={styles.fileCardSidebarTitle}>
|
||
{t('auth_files.card_tools_title')}
|
||
</span>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
className={styles.iconButton}
|
||
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
||
disabled={disableControls || item.disabled}
|
||
loading={quotaRefreshing}
|
||
title={t('auth_files.quota_refresh_single')}
|
||
aria-label={t('auth_files.quota_refresh_single')}
|
||
>
|
||
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
|
||
</Button>
|
||
</div>
|
||
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const titleNode = (
|
||
<div className={styles.titleWrapper}>
|
||
<span>{t('auth_files.title_section')}</span>
|
||
{files.length > 0 && <span className={styles.countBadge}>{files.length}</span>}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<div className={styles.pageHeader}>
|
||
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
|
||
<p className={styles.description}>{t('auth_files.description')}</p>
|
||
</div>
|
||
|
||
<Card
|
||
title={titleNode}
|
||
extra={
|
||
<div className={styles.headerActions}>
|
||
<Button variant="secondary" size="sm" onClick={handleHeaderRefresh} disabled={loading}>
|
||
{t('common.refresh')}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleUploadClick}
|
||
disabled={disableControls || uploading}
|
||
loading={uploading}
|
||
>
|
||
{t('auth_files.upload_button')}
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={handleDeleteAll}
|
||
disabled={disableControls || loading || deletingAll}
|
||
loading={deletingAll}
|
||
>
|
||
{filter === 'all'
|
||
? t('auth_files.delete_all_button')
|
||
: `${t('common.delete')} ${getTypeLabel(filter)}`}
|
||
</Button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".json,application/json"
|
||
multiple
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileChange}
|
||
/>
|
||
</div>
|
||
}
|
||
>
|
||
{error && <div className={styles.errorBox}>{error}</div>}
|
||
|
||
{/* 筛选区域 */}
|
||
<div className={styles.filterSection}>
|
||
{renderFilterTags()}
|
||
|
||
<div className={styles.filterControls}>
|
||
<div className={styles.filterItem}>
|
||
<label>{t('auth_files.search_label')}</label>
|
||
<Input
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
placeholder={t('auth_files.search_placeholder')}
|
||
/>
|
||
</div>
|
||
<div className={styles.filterItem}>
|
||
<label>{t('auth_files.page_size_label')}</label>
|
||
<input
|
||
className={styles.pageSizeSelect}
|
||
type="number"
|
||
min={MIN_CARD_PAGE_SIZE}
|
||
max={MAX_CARD_PAGE_SIZE}
|
||
step={1}
|
||
value={pageSizeInput}
|
||
onChange={handlePageSizeChange}
|
||
onBlur={(e) => commitPageSizeInput(e.currentTarget.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 卡片网格 */}
|
||
{loading ? (
|
||
<div className={styles.hint}>{t('common.loading')}</div>
|
||
) : pageItems.length === 0 ? (
|
||
<EmptyState
|
||
title={t('auth_files.search_empty_title')}
|
||
description={t('auth_files.search_empty_desc')}
|
||
/>
|
||
) : (
|
||
<div
|
||
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
|
||
>
|
||
{pageItems.map(renderFileCard)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 分页 */}
|
||
{!loading && filtered.length > pageSize && (
|
||
<div className={styles.pagination}>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
||
disabled={currentPage <= 1}
|
||
>
|
||
{t('auth_files.pagination_prev')}
|
||
</Button>
|
||
<div className={styles.pageInfo}>
|
||
{t('auth_files.pagination_info', {
|
||
current: currentPage,
|
||
total: totalPages,
|
||
count: filtered.length,
|
||
})}
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
|
||
disabled={currentPage >= totalPages}
|
||
>
|
||
{t('auth_files.pagination_next')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* OAuth 排除列表卡片 */}
|
||
<Card
|
||
title={t('oauth_excluded.title')}
|
||
extra={
|
||
<Button
|
||
size="sm"
|
||
onClick={() => openExcludedEditor()}
|
||
disabled={disableControls || excludedError === 'unsupported'}
|
||
>
|
||
{t('oauth_excluded.add')}
|
||
</Button>
|
||
}
|
||
>
|
||
{excludedError === 'unsupported' ? (
|
||
<EmptyState
|
||
title={t('oauth_excluded.upgrade_required_title')}
|
||
description={t('oauth_excluded.upgrade_required_desc')}
|
||
/>
|
||
) : Object.keys(excluded).length === 0 ? (
|
||
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
||
) : (
|
||
<div className={styles.excludedList}>
|
||
{Object.entries(excluded).map(([provider, models]) => (
|
||
<div key={provider} className={styles.excludedItem}>
|
||
<div className={styles.excludedInfo}>
|
||
<div className={styles.excludedProvider}>{provider}</div>
|
||
<div className={styles.excludedModels}>
|
||
{models?.length
|
||
? t('oauth_excluded.model_count', { count: models.length })
|
||
: t('oauth_excluded.no_models')}
|
||
</div>
|
||
</div>
|
||
<div className={styles.excludedActions}>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => openExcludedEditor(provider)}
|
||
>
|
||
{t('common.edit')}
|
||
</Button>
|
||
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
|
||
{t('oauth_excluded.delete')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* OAuth 模型映射卡片 */}
|
||
<Card
|
||
title={t('oauth_model_alias.title')}
|
||
extra={
|
||
<div className={styles.cardExtraButtons}>
|
||
<div className={styles.viewModeSwitch}>
|
||
<Button
|
||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||
size="sm"
|
||
onClick={() => setViewMode('list')}
|
||
disabled={disableControls || modelAliasError === 'unsupported'}
|
||
>
|
||
{t('oauth_model_alias.view_mode_list')}
|
||
</Button>
|
||
<Button
|
||
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
|
||
size="sm"
|
||
onClick={() => setViewMode('diagram')}
|
||
disabled={disableControls || modelAliasError === 'unsupported'}
|
||
>
|
||
{t('oauth_model_alias.view_mode_diagram')}
|
||
</Button>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => openModelAliasEditor()}
|
||
disabled={disableControls || modelAliasError === 'unsupported'}
|
||
>
|
||
{t('oauth_model_alias.add')}
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
{modelAliasError === 'unsupported' ? (
|
||
<EmptyState
|
||
title={t('oauth_model_alias.upgrade_required_title')}
|
||
description={t('oauth_model_alias.upgrade_required_desc')}
|
||
/>
|
||
) : viewMode === 'diagram' ? (
|
||
Object.keys(modelAlias).length === 0 ? (
|
||
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||
) : (
|
||
<div className={styles.aliasChartSection}>
|
||
<div className={styles.aliasChartHeader}>
|
||
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => diagramRef.current?.collapseAll()}
|
||
disabled={disableControls || modelAliasError === 'unsupported'}
|
||
title={t('oauth_model_alias.diagram_collapse')}
|
||
aria-label={t('oauth_model_alias.diagram_collapse')}
|
||
>
|
||
<IconChevronUp size={16} />
|
||
</Button>
|
||
</div>
|
||
<ModelMappingDiagram
|
||
ref={diagramRef}
|
||
modelAlias={modelAlias}
|
||
allProviderModels={allProviderModels}
|
||
onUpdate={handleMappingUpdate}
|
||
onDeleteLink={handleDeleteLink}
|
||
onToggleFork={handleToggleFork}
|
||
onRenameAlias={handleRenameAlias}
|
||
onDeleteAlias={handleDeleteAlias}
|
||
onEditProvider={openModelAliasEditor}
|
||
onDeleteProvider={deleteModelAlias}
|
||
className={styles.aliasChart}
|
||
/>
|
||
</div>
|
||
)
|
||
) : Object.keys(modelAlias).length === 0 ? (
|
||
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||
) : (
|
||
<div className={styles.excludedList}>
|
||
{Object.entries(modelAlias).map(([provider, mappings]) => (
|
||
<div key={provider} className={styles.excludedItem}>
|
||
<div className={styles.excludedInfo}>
|
||
<div className={styles.excludedProvider}>{provider}</div>
|
||
<div className={styles.excludedModels}>
|
||
{mappings?.length
|
||
? t('oauth_model_alias.model_count', { count: mappings.length })
|
||
: t('oauth_model_alias.no_models')}
|
||
</div>
|
||
</div>
|
||
<div className={styles.excludedActions}>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => openModelAliasEditor(provider)}
|
||
>
|
||
{t('common.edit')}
|
||
</Button>
|
||
<Button variant="danger" size="sm" onClick={() => deleteModelAlias(provider)}>
|
||
{t('oauth_model_alias.delete')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* 详情弹窗 */}
|
||
<Modal
|
||
open={detailModalOpen}
|
||
onClose={() => setDetailModalOpen(false)}
|
||
title={selectedFile?.name || t('auth_files.title_section')}
|
||
footer={
|
||
<>
|
||
<Button variant="secondary" onClick={() => setDetailModalOpen(false)}>
|
||
{t('common.close')}
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
if (selectedFile) {
|
||
const text = JSON.stringify(selectedFile, null, 2);
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
showNotification(t('notification.link_copied'), 'success');
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
{t('common.copy')}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
{selectedFile && (
|
||
<div className={styles.detailContent}>
|
||
<pre className={styles.jsonContent}>{JSON.stringify(selectedFile, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* 模型列表弹窗 */}
|
||
<Modal
|
||
open={modelsModalOpen}
|
||
onClose={() => setModelsModalOpen(false)}
|
||
title={
|
||
t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${modelsFileName}`
|
||
}
|
||
footer={
|
||
<Button variant="secondary" onClick={() => setModelsModalOpen(false)}>
|
||
{t('common.close')}
|
||
</Button>
|
||
}
|
||
>
|
||
{modelsLoading ? (
|
||
<div className={styles.hint}>
|
||
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
|
||
</div>
|
||
) : modelsError === 'unsupported' ? (
|
||
<EmptyState
|
||
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
||
description={t('auth_files.models_unsupported_desc', {
|
||
defaultValue: '请更新 CLI Proxy API 到最新版本后重试',
|
||
})}
|
||
/>
|
||
) : modelsList.length === 0 ? (
|
||
<EmptyState
|
||
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
||
description={t('auth_files.models_empty_desc', {
|
||
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型',
|
||
})}
|
||
/>
|
||
) : (
|
||
<div className={styles.modelsList}>
|
||
{modelsList.map((model) => {
|
||
const isExcluded = isModelExcluded(model.id, modelsFileType);
|
||
return (
|
||
<div
|
||
key={model.id}
|
||
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(model.id);
|
||
showNotification(
|
||
t('notification.link_copied', { defaultValue: '已复制到剪贴板' }),
|
||
'success'
|
||
);
|
||
}}
|
||
title={
|
||
isExcluded
|
||
? t('auth_files.models_excluded_hint', {
|
||
defaultValue: '此模型已被 OAuth 排除',
|
||
})
|
||
: t('common.copy', { defaultValue: '点击复制' })
|
||
}
|
||
>
|
||
<span className={styles.modelId}>{model.id}</span>
|
||
{model.display_name && model.display_name !== model.id && (
|
||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||
)}
|
||
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||
{isExcluded && (
|
||
<span className={styles.modelExcludedBadge}>
|
||
{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* prefix/proxy_url 编辑弹窗 */}
|
||
<Modal
|
||
open={Boolean(prefixProxyEditor)}
|
||
onClose={() => setPrefixProxyEditor(null)}
|
||
closeDisabled={prefixProxyEditor?.saving === true}
|
||
width={720}
|
||
title={
|
||
prefixProxyEditor?.fileName
|
||
? `${t('auth_files.prefix_proxy_button')} - ${prefixProxyEditor.fileName}`
|
||
: t('auth_files.prefix_proxy_button')
|
||
}
|
||
footer={
|
||
<>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setPrefixProxyEditor(null)}
|
||
disabled={prefixProxyEditor?.saving === true}
|
||
>
|
||
{t('common.cancel')}
|
||
</Button>
|
||
<Button
|
||
onClick={() => void handlePrefixProxySave()}
|
||
loading={prefixProxyEditor?.saving === true}
|
||
disabled={
|
||
disableControls ||
|
||
prefixProxyEditor?.saving === true ||
|
||
!prefixProxyDirty ||
|
||
!prefixProxyEditor?.json
|
||
}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
{prefixProxyEditor && (
|
||
<div className={styles.prefixProxyEditor}>
|
||
{prefixProxyEditor.loading ? (
|
||
<div className={styles.prefixProxyLoading}>
|
||
<LoadingSpinner size={14} />
|
||
<span>{t('auth_files.prefix_proxy_loading')}</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{prefixProxyEditor.error && (
|
||
<div className={styles.prefixProxyError}>{prefixProxyEditor.error}</div>
|
||
)}
|
||
<div className={styles.prefixProxyJsonWrapper}>
|
||
<label className={styles.prefixProxyLabel}>
|
||
{t('auth_files.prefix_proxy_source_label')}
|
||
</label>
|
||
<textarea
|
||
className={styles.prefixProxyTextarea}
|
||
rows={10}
|
||
readOnly
|
||
value={prefixProxyUpdatedText}
|
||
/>
|
||
</div>
|
||
<div className={styles.prefixProxyFields}>
|
||
<Input
|
||
label={t('auth_files.prefix_label')}
|
||
value={prefixProxyEditor.prefix}
|
||
disabled={
|
||
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||
}
|
||
onChange={(e) => handlePrefixProxyChange('prefix', e.target.value)}
|
||
/>
|
||
<Input
|
||
label={t('auth_files.proxy_url_label')}
|
||
value={prefixProxyEditor.proxyUrl}
|
||
placeholder={t('auth_files.proxy_url_placeholder')}
|
||
disabled={
|
||
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||
}
|
||
onChange={(e) => handlePrefixProxyChange('proxyUrl', e.target.value)}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|