From 7e56d33bf0a03edde0bce92b627fa8520e2e3987 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 24 Jan 2026 01:24:05 +0800 Subject: [PATCH] feat(auth-files): add prefix/proxy_url modal editor --- src/i18n/locales/en.json | 11 +- src/i18n/locales/zh-CN.json | 11 +- src/pages/AuthFilesPage.module.scss | 95 +++++- src/pages/AuthFilesPage.tsx | 465 +++++++++++++++++++++++----- 4 files changed, 492 insertions(+), 90 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5b8c59f..5525bd4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -398,7 +398,16 @@ "models_excluded_hint": "This model is excluded by OAuth", "status_toggle_label": "Enabled", "status_enabled_success": "\"{{name}}\" enabled", - "status_disabled_success": "\"{{name}}\" disabled" + "status_disabled_success": "\"{{name}}\" disabled", + "prefix_proxy_button": "Edit prefix/proxy_url", + "prefix_proxy_loading": "Loading credential...", + "prefix_proxy_source_label": "Credential JSON", + "prefix_label": "prefix", + "proxy_url_label": "proxy_url", + "prefix_placeholder": "", + "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", + "prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", + "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully" }, "antigravity_quota": { "title": "Antigravity Quota", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3163079..f5d88bc 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -398,7 +398,16 @@ "models_excluded_hint": "此模型已被 OAuth 排除", "status_toggle_label": "启用", "status_enabled_success": "已启用 \"{{name}}\"", - "status_disabled_success": "已停用 \"{{name}}\"" + "status_disabled_success": "已停用 \"{{name}}\"", + "prefix_proxy_button": "配置 prefix/proxy_url", + "prefix_proxy_loading": "正在加载凭证文件...", + "prefix_proxy_source_label": "凭证 JSON", + "prefix_label": "prefix", + "proxy_url_label": "proxy_url", + "prefix_placeholder": "", + "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", + "prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。", + "prefix_proxy_saved_success": "已更新 \"{{name}}\"" }, "antigravity_quota": { "title": "Antigravity 额度", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 4117b51..26ccbc5 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -277,27 +277,15 @@ } .antigravityCard { - background-image: linear-gradient( - 180deg, - rgba(224, 247, 250, 0.12), - rgba(224, 247, 250, 0) - ); + background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0)); } .codexCard { - background-image: linear-gradient( - 180deg, - rgba(255, 243, 224, 0.18), - rgba(255, 243, 224, 0) - ); + background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0)); } .geminiCliCard { - background-image: linear-gradient( - 180deg, - rgba(231, 239, 255, 0.2), - rgba(231, 239, 255, 0) - ); + background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0)); } .quotaSection { @@ -446,7 +434,10 @@ display: flex; flex-direction: column; gap: $spacing-sm; - transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; + transition: + transform $transition-fast, + box-shadow $transition-fast, + border-color $transition-fast; &:hover { transform: translateY(-2px); @@ -546,7 +537,9 @@ height: 8px; border-radius: 2px; min-width: 6px; - transition: transform 0.15s ease, opacity 0.15s ease; + transition: + transform 0.15s ease, + opacity 0.15s ease; &:hover { transform: scaleY(1.5); @@ -597,6 +590,74 @@ background: var(--failure-badge-bg, #fee2e2); } +.prefixProxyEditor { + display: flex; + flex-direction: column; + gap: $spacing-md; + max-height: 60vh; + overflow: auto; +} + +.prefixProxyLoading { + display: flex; + align-items: center; + justify-content: center; + gap: $spacing-sm; + font-size: 12px; + color: var(--text-secondary); + padding: $spacing-sm 0; +} + +.prefixProxyError { + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + border: 1px solid var(--danger-color); + background-color: rgba(239, 68, 68, 0.1); + color: var(--danger-color); + font-size: 12px; +} + +.prefixProxyJsonWrapper { + display: flex; + flex-direction: column; + gap: 6px; +} + +.prefixProxyLabel { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; +} + +.prefixProxyTextarea { + width: 100%; + padding: $spacing-sm $spacing-md; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background-color: var(--bg-secondary); + color: var(--text-primary); + font-size: 12px; + font-family: monospace; + resize: vertical; + min-height: 120px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--primary-color); + } +} + +.prefixProxyFields { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-sm; + + :global(.form-group) { + margin: 0; + } +} + .cardActions { display: flex; gap: $spacing-xs; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index f4c38af..5b018db 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -9,7 +9,14 @@ 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 { IconBot, IconDownload, IconInfo, IconTrash2, IconX } from '@/components/ui/icons'; +import { + IconBot, + IconCode, + IconDownload, + IconInfo, + IconTrash2, + IconX, +} from '@/components/ui/icons'; import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; @@ -34,44 +41,44 @@ type ResolvedTheme = 'light' | 'dark'; const TYPE_COLORS: Record = { qwen: { light: { bg: '#e8f5e9', text: '#2e7d32' }, - dark: { bg: '#1b5e20', text: '#81c784' } + dark: { bg: '#1b5e20', text: '#81c784' }, }, gemini: { light: { bg: '#e3f2fd', text: '#1565c0' }, - dark: { bg: '#0d47a1', text: '#64b5f6' } + dark: { bg: '#0d47a1', text: '#64b5f6' }, }, 'gemini-cli': { light: { bg: '#e7efff', text: '#1e4fa3' }, - dark: { bg: '#1c3f73', text: '#a8c7ff' } + dark: { bg: '#1c3f73', text: '#a8c7ff' }, }, aistudio: { light: { bg: '#f0f2f5', text: '#2f343c' }, - dark: { bg: '#373c42', text: '#cfd3db' } + dark: { bg: '#373c42', text: '#cfd3db' }, }, claude: { light: { bg: '#fce4ec', text: '#c2185b' }, - dark: { bg: '#880e4f', text: '#f48fb1' } + dark: { bg: '#880e4f', text: '#f48fb1' }, }, codex: { light: { bg: '#fff3e0', text: '#ef6c00' }, - dark: { bg: '#e65100', text: '#ffb74d' } + dark: { bg: '#e65100', text: '#ffb74d' }, }, antigravity: { light: { bg: '#e0f7fa', text: '#006064' }, - dark: { bg: '#004d40', text: '#80deea' } + dark: { bg: '#004d40', text: '#80deea' }, }, iflow: { light: { bg: '#f3e5f5', text: '#7b1fa2' }, - dark: { bg: '#4a148c', text: '#ce93d8' } + dark: { bg: '#4a148c', text: '#ce93d8' }, }, empty: { light: { bg: '#f5f5f5', text: '#616161' }, - dark: { bg: '#424242', text: '#bdbdbd' } + dark: { bg: '#424242', text: '#bdbdbd' }, }, unknown: { light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' }, - dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' } - } + dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }, + }, }; const OAUTH_PROVIDER_PRESETS = [ @@ -82,7 +89,7 @@ const OAUTH_PROVIDER_PRESETS = [ 'claude', 'codex', 'qwen', - 'iflow' + 'iflow', ]; const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']); @@ -105,11 +112,23 @@ interface ModelMappingsFormState { mappings: OAuthModelMappingFormEntry[]; } +interface PrefixProxyEditorState { + fileName: string; + loading: boolean; + saving: boolean; + error: string | null; + originalText: string; + rawText: string; + json: Record | null; + prefix: string; + proxyUrl: string; +} + const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({ id: generateId(), name: '', alias: '', - fork: false + fork: false, }); // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) function normalizeAuthIndexValue(value: unknown): string | null { @@ -131,10 +150,7 @@ function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { } // 解析认证文件的统计数据 -function resolveAuthFileStats( - file: AuthFileItem, - stats: KeyStats -): KeyStatBucket { +function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket { const defaultStats: KeyStatBucket = { success: 0, failure: 0 }; const rawFileName = file?.name || ''; @@ -162,7 +178,10 @@ function resolveAuthFileStats( if (nameWithoutExt && nameWithoutExt !== rawFileName) { const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt); const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined; - if (fromNameWithoutExt && (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)) { + if ( + fromNameWithoutExt && + (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0) + ) { return fromNameWithoutExt; } } @@ -198,7 +217,9 @@ export function AuthFilesPage() { // 模型列表弹窗相关 const [modelsModalOpen, setModelsModalOpen] = useState(false); const [modelsLoading, setModelsLoading] = useState(false); - const [modelsList, setModelsList] = useState<{ id: string; display_name?: string; type?: string }[]>([]); + const [modelsList, setModelsList] = useState< + { id: string; display_name?: string; type?: string }[] + >([]); const [modelsFileName, setModelsFileName] = useState(''); const [modelsFileType, setModelsFileType] = useState(''); const [modelsError, setModelsError] = useState<'unsupported' | null>(null); @@ -207,7 +228,10 @@ export function AuthFilesPage() { const [excluded, setExcluded] = useState>({}); const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); const [excludedModalOpen, setExcludedModalOpen] = useState(false); - const [excludedForm, setExcludedForm] = useState({ provider: '', modelsText: '' }); + const [excludedForm, setExcludedForm] = useState({ + provider: '', + modelsText: '', + }); const [savingExcluded, setSavingExcluded] = useState(false); // OAuth 模型映射相关 @@ -216,10 +240,12 @@ export function AuthFilesPage() { const [mappingModalOpen, setMappingModalOpen] = useState(false); const [mappingForm, setMappingForm] = useState({ provider: '', - mappings: [buildEmptyMappingEntry()] + mappings: [buildEmptyMappingEntry()], }); const [savingMappings, setSavingMappings] = useState(false); + const [prefixProxyEditor, setPrefixProxyEditor] = useState(null); + const fileInputRef = useRef(null); const loadingKeyStatsRef = useRef(false); const excludedUnsupportedRef = useRef(false); @@ -227,6 +253,29 @@ export function AuthFilesPage() { const disableControls = connectionStatus !== 'connected'; + const prefixProxyUpdatedText = useMemo(() => { + if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? ''; + const next: Record = { ...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 normalizeProviderKey = (value: string) => value.trim().toLowerCase(); const handlePageSizeChange = (event: React.ChangeEvent) => { @@ -362,7 +411,6 @@ export function AuthFilesPage() { return Array.from(types); }, [files]); - const excludedProviderLookup = useMemo(() => { const lookup = new Map(); Object.keys(excluded).forEach((provider) => { @@ -493,7 +541,10 @@ export function AuthFilesPage() { if (successCount > 0) { const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : ''; - showNotification(`${t('auth_files.upload_success')}${suffix}`, failed.length ? 'warning' : 'success'); + showNotification( + `${t('auth_files.upload_success')}${suffix}`, + failed.length ? 'warning' : 'success' + ); await loadFiles(); await loadKeyStats(); } @@ -542,9 +593,7 @@ export function AuthFilesPage() { setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file))); } else { // 删除筛选类型的文件 - const filesToDelete = files.filter( - (f) => f.type === filter && !isRuntimeOnlyAuthFile(f) - ); + const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f)); if (filesToDelete.length === 0) { showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info'); @@ -592,9 +641,12 @@ export function AuthFilesPage() { // 下载文件 const handleDownload = async (name: string) => { try { - const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, { - responseType: 'blob' - }); + 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'); @@ -609,6 +661,133 @@ export function AuthFilesPage() { } }; + 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; + 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; @@ -620,9 +799,7 @@ export function AuthFilesPage() { try { const res = await authFilesApi.setStatus(name, nextDisabled); - setFiles((prev) => - prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f)) - ); + setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))); showNotification( enabled ? t('auth_files.status_enabled_success', { name }) @@ -665,7 +842,11 @@ export function AuthFilesPage() { } catch (err) { // 检测是否是 API 不支持的错误 (404 或特定错误消息) const errorMessage = err instanceof Error ? err.message : ''; - if (errorMessage.includes('404') || errorMessage.includes('not found') || errorMessage.includes('Not Found')) { + if ( + errorMessage.includes('404') || + errorMessage.includes('not found') || + errorMessage.includes('Not Found') + ) { setModelsError('unsupported'); } else { showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error'); @@ -679,7 +860,7 @@ export function AuthFilesPage() { const isModelExcluded = (modelId: string, providerType: string): boolean => { const providerKey = normalizeProviderKey(providerType); const excludedModels = excluded[providerKey] || excluded[providerType] || []; - return excludedModels.some(pattern => { + return excludedModels.some((pattern) => { if (pattern.includes('*')) { // 支持通配符匹配 const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i'); @@ -713,7 +894,7 @@ export function AuthFilesPage() { const models = lookupKey ? excluded[lookupKey] : []; setExcludedForm({ provider: lookupKey || fallbackProvider, - modelsText: Array.isArray(models) ? models.join('\n') : '' + modelsText: Array.isArray(models) ? models.join('\n') : '', }); setExcludedModalOpen(true); }; @@ -770,14 +951,21 @@ export function AuthFilesPage() { await loadExcluded(); showNotification(t('oauth_excluded.delete_success'), 'success'); } catch (fallbackErr: unknown) { - const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : err instanceof Error ? err.message : ''; + const errorMessage = + fallbackErr instanceof Error + ? fallbackErr.message + : err instanceof Error + ? err.message + : ''; showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error'); } } }; // OAuth 模型映射相关方法 - const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]): OAuthModelMappingFormEntry[] => { + const normalizeMappingEntries = ( + entries?: OAuthModelMappingEntry[] + ): OAuthModelMappingFormEntry[] => { if (!Array.isArray(entries) || entries.length === 0) { return [buildEmptyMappingEntry()]; } @@ -803,7 +991,11 @@ export function AuthFilesPage() { setMappingModalOpen(true); }; - const updateMappingEntry = (index: number, field: keyof OAuthModelMappingEntry, value: string | boolean) => { + const updateMappingEntry = ( + index: number, + field: keyof OAuthModelMappingEntry, + value: string | boolean + ) => { setMappingForm((prev) => ({ ...prev, mappings: prev.mappings.map((entry, idx) => @@ -884,7 +1076,10 @@ export function AuthFilesPage() {
{existingTypes.map((type) => { const isActive = filter === type; - const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type); + const color = + type === 'all' + ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } + : getTypeColor(type); const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff'; return (
- {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} - {t('auth_files.file_modified')}: {formatModified(item)} + + {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} + + + {t('auth_files.file_modified')}: {formatModified(item)} +
@@ -1042,6 +1242,16 @@ export function AuthFilesPage() { > +
@@ -1094,12 +1306,7 @@ export function AuthFilesPage() { title={titleNode} extra={
- - {t('common.loading')}
) : pageItems.length === 0 ? ( - + ) : ( -
- {pageItems.map(renderFileCard)} -
+
{pageItems.map(renderFileCard)}
)} {/* 分页 */} @@ -1184,7 +1399,7 @@ export function AuthFilesPage() { {t('auth_files.pagination_info', { current: currentPage, total: totalPages, - count: filtered.length + count: filtered.length, })} + + + } + > + {prefixProxyEditor && ( +
+ {prefixProxyEditor.loading ? ( +
+ + {t('auth_files.prefix_proxy_loading')} +
+ ) : ( + <> + {prefixProxyEditor.error && ( +
{prefixProxyEditor.error}
+ )} +
+ +