From 2a57055f8175ad8b1bdef46a6c8f474d39a796cd Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 12 Dec 2025 17:58:23 +0800 Subject: [PATCH] feat: introduce ModelInputList component for managing model entries in AiProvidersPage, enhance MainLayout with header action icons, and improve styling for success and failure statistics across pages --- src/components/layout/MainLayout.tsx | 84 ++++++- src/components/ui/ModelInputList.tsx | 102 +++++++++ src/pages/AiProvidersPage.module.scss | 268 ++++++++++++++++++++-- src/pages/AiProvidersPage.tsx | 316 +++++++++++++++++++------- src/pages/AuthFilesPage.module.scss | 52 ++--- src/pages/AuthFilesPage.tsx | 8 +- src/styles/themes.scss | 16 ++ 7 files changed, 709 insertions(+), 137 deletions(-) create mode 100644 src/components/ui/ModelInputList.tsx diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 9759924..456325e 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -95,6 +95,77 @@ const sidebarIcons: Record = { ) }; +// Header action icons - smaller size for header buttons +const headerIconProps: SVGProps = { + width: 16, + height: 16, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': 'true', + focusable: 'false' +}; + +const headerIcons = { + refresh: ( + + + + + ), + update: ( + + + + + ), + menu: ( + + + + + + ), + chevronLeft: ( + + + + ), + chevronRight: ( + + + + ), + language: ( + + + + + + ), + sun: ( + + + + + + + + + + + + ), + moon: ( + + + + ) +}; + const parseVersionSegments = (version?: string | null) => { if (!version) return null; const cleaned = version.trim().replace(/^v/i, ''); @@ -136,7 +207,6 @@ export function MainLayout() { const theme = useThemeStore((state) => state.theme); const toggleTheme = useThemeStore((state) => state.toggleTheme); - const language = useLanguageStore((state) => state.language); const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -277,7 +347,7 @@ export function MainLayout() { onClick={() => setSidebarCollapsed((prev) => !prev)} title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })} > - {sidebarCollapsed ? '»' : '«'} + {sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft} CPAMC logo
{abbrBrandName}
@@ -309,16 +379,16 @@ export function MainLayout() {
diff --git a/src/components/ui/ModelInputList.tsx b/src/components/ui/ModelInputList.tsx new file mode 100644 index 0000000..560a0f1 --- /dev/null +++ b/src/components/ui/ModelInputList.tsx @@ -0,0 +1,102 @@ +import { Fragment } from 'react'; +import { Button } from './Button'; +import type { ModelAlias } from '@/types'; + +interface ModelEntry { + name: string; + alias: string; +} + +interface ModelInputListProps { + entries: ModelEntry[]; + onChange: (entries: ModelEntry[]) => void; + addLabel: string; + disabled?: boolean; + namePlaceholder?: string; + aliasPlaceholder?: string; +} + +export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => { + if (!Array.isArray(models) || models.length === 0) { + return [{ name: '', alias: '' }]; + } + return models.map((m) => ({ + name: m.name || '', + alias: m.alias || '' + })); +}; + +export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => { + return entries + .filter((entry) => entry.name.trim()) + .map((entry) => { + const model: ModelAlias = { name: entry.name.trim() }; + const alias = entry.alias.trim(); + if (alias && alias !== model.name) { + model.alias = alias; + } + return model; + }); +}; + +export function ModelInputList({ + entries, + onChange, + addLabel, + disabled = false, + namePlaceholder = 'model-name', + aliasPlaceholder = 'alias (optional)' +}: ModelInputListProps) { + const currentEntries = entries.length ? entries : [{ name: '', alias: '' }]; + + const updateEntry = (index: number, field: 'name' | 'alias', value: string) => { + const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry)); + onChange(next); + }; + + const addEntry = () => { + onChange([...currentEntries, { name: '', alias: '' }]); + }; + + const removeEntry = (index: number) => { + const next = currentEntries.filter((_, idx) => idx !== index); + onChange(next.length ? next : [{ name: '', alias: '' }]); + }; + + return ( +
+ {currentEntries.map((entry, index) => ( + +
+ updateEntry(index, 'name', e.target.value)} + disabled={disabled} + /> + + updateEntry(index, 'alias', e.target.value)} + disabled={disabled} + /> + +
+
+ ))} + +
+ ); +} diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index 484105b..bb3a78d 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -51,33 +51,269 @@ // 成功失败次数统计样式 .cardStats { display: flex; + flex-wrap: wrap; gap: 8px; - padding: 8px 0 0; margin-top: 8px; + padding-top: 4px; +} + +.statPill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + line-height: 1.1; + border: 1px solid transparent; + background-color: var(--bg-tertiary); + color: var(--text-primary); + white-space: nowrap; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } .statSuccess { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: #fff; - background-color: #22c55e; - padding: 4px 12px; - border-radius: 14px; - white-space: nowrap; + background-color: var(--success-badge-bg, #d1fae5); + color: var(--success-badge-text, #065f46); + border-color: var(--success-badge-border, #6ee7b7); } .statFailure { + background-color: var(--failure-badge-bg, #fee2e2); + color: var(--failure-badge-text, #991b1b); + border-color: var(--failure-badge-border, #fca5a5); +} + +// 字段行样式:标签 + 值 +.fieldRow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + margin-bottom: 4px; + font-size: 13px; + line-height: 1.4; +} + +.fieldLabel { + color: var(--text-tertiary); + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.fieldValue { + color: var(--text-primary); + font-weight: 600; + word-break: break-all; + font-family: 'Monaco', 'Menlo', 'Consolas', 'Ubuntu Mono', monospace; +} + +// 自定义请求头徽章 +.headerBadgeList { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.headerBadge { display: inline-flex; align-items: center; + gap: 4px; + background: var(--accent-tertiary, #f3f4f6); + border: 1px solid var(--border-primary); + border-radius: 12px; + padding: 4px 10px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-weight: 600; + color: var(--text-primary); + } +} + +// 模型标签容器 +.modelTagList { + display: flex; + flex-wrap: wrap; gap: 6px; + margin-top: 8px; +} + +// 单个模型标签 +.modelTag { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--bg-quinary, #f8f9fa); + color: var(--text-secondary); + border: 1px solid var(--border-secondary); + border-radius: 14px; + padding: 4px 10px; + font-size: 12px; + transition: all 0.15s ease; + + &:hover { + background: var(--bg-tertiary); + border-color: var(--primary-color); + } +} + +.modelName { + font-weight: 600; + color: var(--text-primary); +} + +.modelAlias { + color: var(--text-tertiary); + font-style: italic; + + &::before { + content: '→ '; + } +} + +// 排除模型标签(警告色) +.excludedModelTag { + background: var(--warning-bg, #fef3c7); + border-color: var(--warning-border, #fbbf24); + color: var(--warning-text, #92400e); + + .modelName { + color: var(--warning-text, #92400e); + } +} + +// 排除模型区块 +.excludedModelsSection { + margin-top: 8px; +} + +.excludedModelsLabel { + font-size: 12px; + font-weight: 500; + color: var(--warning-text, #92400e); + margin-bottom: 4px; +} + +// API密钥条目列表(二级卡片) +.apiKeyEntriesSection { + margin-top: 10px; +} + +.apiKeyEntriesLabel { font-size: 12px; font-weight: 600; - color: #fff; - background-color: #ef4444; - padding: 4px 12px; - border-radius: 14px; - white-space: nowrap; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.apiKeyEntryList { + display: flex; + flex-direction: column; + gap: 6px; +} + +.apiKeyEntryCard { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-secondary, #f9fafb); + border: 1px solid var(--border-secondary); + border-radius: 8px; + font-size: 12px; +} + +.apiKeyEntryIndex { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + color: white; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.apiKeyEntryKey { + font-family: 'Monaco', 'Menlo', 'Consolas', 'Ubuntu Mono', monospace; + font-weight: 600; + color: var(--text-primary); + word-break: break-all; +} + +.apiKeyEntryProxy { + color: var(--text-tertiary); + font-size: 11px; + + &::before { + content: '| Proxy: '; + color: var(--text-quaternary); + } +} + +.apiKeyEntryStats { + display: flex; + gap: 6px; + margin-left: auto; +} + +.apiKeyEntryStat { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; +} + +.apiKeyEntryStatSuccess { + background: var(--success-badge-bg, #d1fae5); + color: var(--success-badge-text, #065f46); +} + +.apiKeyEntryStatFailure { + background: var(--failure-badge-bg, #fee2e2); + color: var(--failure-badge-text, #991b1b); +} + +// 暗色主题适配 +:global([data-theme='dark']) { + .headerBadge { + background: rgba(59, 130, 246, 0.15); + border-color: rgba(59, 130, 246, 0.3); + color: var(--text-secondary); + + strong { + color: var(--text-secondary); + } + } + + .modelTag { + background: rgba(59, 130, 246, 0.1); + border-color: var(--border-secondary); + } + + .excludedModelTag { + background: rgba(251, 191, 36, 0.2); + border-color: rgba(251, 191, 36, 0.4); + } + + .apiKeyEntryCard { + background: var(--bg-tertiary); + border-color: var(--border-primary); + } + + .apiKeyEntryIndex { + background: var(--primary-color); + } } diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 02f4ba0..f13b520 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -6,14 +6,14 @@ import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; +import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { providersApi, usageApi } from '@/services/api'; import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig, - ApiKeyEntry, - ModelAlias + ApiKeyEntry } from '@/types'; import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers'; @@ -26,38 +26,20 @@ type ProviderModal = | { type: 'claude'; index: number | null } | { type: 'openai'; index: number | null }; +interface ModelEntry { + name: string; + alias: string; +} + interface OpenAIFormState { name: string; baseUrl: string; headers: HeaderEntry[]; testModel?: string; - modelsText: string; + modelEntries: ModelEntry[]; apiKeyEntries: ApiKeyEntry[]; } -const parseModelsText = (value: string): ModelAlias[] => { - return value - .split(/\n+/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const [namePart, aliasPart] = line.split(',').map((item) => item.trim()); - if (!namePart) return null; - const entry: ModelAlias = { name: namePart }; - if (aliasPart && aliasPart !== namePart) entry.alias = aliasPart; - return entry; - }) - .filter(Boolean) as ModelAlias[]; -}; - -const modelsToText = (models?: ModelAlias[]) => - Array.isArray(models) - ? models - .map((m) => (m.alias && m.alias !== m.name ? `${m.name}, ${m.alias}` : m.name)) - .filter(Boolean) - .join('\n') - : ''; - const parseExcludedModels = (text: string): string[] => text .split(/[\n,]+/) @@ -133,20 +115,20 @@ export function AiProvidersPage() { excludedModels: [], excludedText: '' }); - const [providerForm, setProviderForm] = useState({ + const [providerForm, setProviderForm] = useState({ apiKey: '', baseUrl: '', proxyUrl: '', headers: {}, models: [], - modelsText: '' + modelEntries: [{ name: '', alias: '' }] }); const [openaiForm, setOpenaiForm] = useState({ name: '', baseUrl: '', headers: [], apiKeyEntries: [buildApiKeyEntry()], - modelsText: '' + modelEntries: [{ name: '', alias: '' }] }); const [saving, setSaving] = useState(false); @@ -205,14 +187,14 @@ export function AiProvidersPage() { proxyUrl: '', headers: {}, models: [], - modelsText: '' + modelEntries: [{ name: '', alias: '' }] }); setOpenaiForm({ name: '', baseUrl: '', headers: [], apiKeyEntries: [buildApiKeyEntry()], - modelsText: '', + modelEntries: [{ name: '', alias: '' }], testModel: undefined }); }; @@ -234,7 +216,7 @@ export function AiProvidersPage() { const entry = source[index]; setProviderForm({ ...entry, - modelsText: modelsToText(entry?.models) + modelEntries: modelsToEntries(entry?.models) }); } setModal({ type, index }); @@ -248,7 +230,7 @@ export function AiProvidersPage() { baseUrl: entry.baseUrl, headers: headersToEntries(entry.headers), testModel: entry.testModel, - modelsText: modelsToText(entry.models), + modelEntries: modelsToEntries(entry.models), apiKeyEntries: entry.apiKeyEntries?.length ? entry.apiKeyEntries : [buildApiKeyEntry()] }); } @@ -312,7 +294,7 @@ export function AiProvidersPage() { baseUrl, proxyUrl: providerForm.proxyUrl?.trim() || undefined, headers: buildHeaderObject(headersToEntries(providerForm.headers as any)), - models: parseModelsText(providerForm.modelsText) + models: entriesToModels(providerForm.modelEntries) }; const source = type === 'codex' ? codexConfigs : claudeConfigs; @@ -384,7 +366,7 @@ export function AiProvidersPage() { })) }; if (openaiForm.testModel) payload.testModel = openaiForm.testModel.trim(); - const models = parseModelsText(openaiForm.modelsText); + const models = entriesToModels(openaiForm.modelEntries); if (models.length) payload.models = models; const nextList = @@ -535,24 +517,56 @@ export function AiProvidersPage() { (item) => item.apiKey, (item, index) => { const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); return (
{t('ai_providers.gemini_item_title')} #{index + 1}
-
{maskApiKey(item.apiKey)}
- {item.baseUrl &&
{item.baseUrl}
} + {/* API Key 行 */} +
+ {t('common.api_key')}: + {maskApiKey(item.apiKey)} +
+ {/* Base URL 行 */} + {item.baseUrl && ( +
+ {t('common.base_url')}: + {item.baseUrl} +
+ )} + {/* 自定义请求头徽章 */} + {headerEntries.length > 0 && ( +
+ {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {/* 排除模型徽章 */} {item.excludedModels?.length ? ( -
- {t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} +
+
+ {t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} +
+
+ {item.excludedModels.map((model) => ( + + {model} + + ))} +
) : null} + {/* 成功/失败统计 */}
- - {t('stats.success')}:{stats.success}次 + + {t('stats.success')}: {stats.success} - - {t('stats.failure')}:{stats.failure}次 + + {t('stats.failure')}: {stats.failure}
@@ -577,17 +591,46 @@ export function AiProvidersPage() { (item) => item.apiKey, (item, _index) => { const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); return ( -
{item.baseUrl || t('ai_providers.codex_item_title')}
-
{maskApiKey(item.apiKey)}
- {item.proxyUrl &&
{item.proxyUrl}
} +
{t('ai_providers.codex_item_title')}
+ {/* API Key 行 */} +
+ {t('common.api_key')}: + {maskApiKey(item.apiKey)} +
+ {/* Base URL 行 */} + {item.baseUrl && ( +
+ {t('common.base_url')}: + {item.baseUrl} +
+ )} + {/* Proxy URL 行 */} + {item.proxyUrl && ( +
+ {t('common.proxy_url')}: + {item.proxyUrl} +
+ )} + {/* 自定义请求头徽章 */} + {headerEntries.length > 0 && ( +
+ {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {/* 成功/失败统计 */}
- - {t('stats.success')}:{stats.success}次 + + {t('stats.success')}: {stats.success} - - {t('stats.failure')}:{stats.failure}次 + + {t('stats.failure')}: {stats.failure}
@@ -612,22 +655,64 @@ export function AiProvidersPage() { (item) => item.apiKey, (item, _index) => { const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); return ( -
{item.baseUrl || t('ai_providers.claude_item_title')}
-
{maskApiKey(item.apiKey)}
- {item.proxyUrl &&
{item.proxyUrl}
} +
{t('ai_providers.claude_item_title')}
+ {/* API Key 行 */} +
+ {t('common.api_key')}: + {maskApiKey(item.apiKey)} +
+ {/* Base URL 行 */} + {item.baseUrl && ( +
+ {t('common.base_url')}: + {item.baseUrl} +
+ )} + {/* Proxy URL 行 */} + {item.proxyUrl && ( +
+ {t('common.proxy_url')}: + {item.proxyUrl} +
+ )} + {/* 自定义请求头徽章 */} + {headerEntries.length > 0 && ( +
+ {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {/* 模型列表 */} {item.models?.length ? ( -
- {t('ai_providers.claude_models_count')}: {item.models.length} +
+
+ + {t('ai_providers.claude_models_count')}: {item.models.length} + +
+ {item.models.map((model) => ( + + {model.name} + {model.alias && model.alias !== model.name && ( + {model.alias} + )} + + ))}
) : null} + {/* 成功/失败统计 */}
- - {t('stats.success')}:{stats.success}次 + + {t('stats.success')}: {stats.success} - - {t('stats.failure')}:{stats.failure}次 + + {t('stats.failure')}: {stats.failure}
@@ -652,23 +737,88 @@ export function AiProvidersPage() { (item) => item.name, (item, _index) => { const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); + const apiKeyEntries = item.apiKeyEntries || []; return (
{item.name}
-
{item.baseUrl}
-
- {t('ai_providers.openai_keys_count')}: {item.apiKeyEntries?.length || 0} + {/* Base URL 行 */} +
+ {t('common.base_url')}: + {item.baseUrl}
-
- {t('ai_providers.openai_models_count')}: {item.models?.length || 0} + {/* 自定义请求头徽章 */} + {headerEntries.length > 0 && ( +
+ {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {/* API密钥条目二级卡片 */} + {apiKeyEntries.length > 0 && ( +
+
+ {t('ai_providers.openai_keys_count')}: {apiKeyEntries.length} +
+
+ {apiKeyEntries.map((entry, entryIndex) => { + const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey); + return ( +
+ {entryIndex + 1} + {maskApiKey(entry.apiKey)} + {entry.proxyUrl && ( + {entry.proxyUrl} + )} +
+ + ✓ {entryStats.success} + + + ✗ {entryStats.failure} + +
+
+ ); + })} +
+
+ )} + {/* 模型数量标签 */} +
+ {t('ai_providers.openai_models_count')}: + {item.models?.length || 0}
- {item.testModel &&
{item.testModel}
} + {/* 模型列表徽章 */} + {item.models?.length ? ( +
+ {item.models.map((model) => ( + + {model.name} + {model.alias && model.alias !== model.name && ( + {model.alias} + )} + + ))} +
+ ) : null} + {/* 测试模型 */} + {item.testModel && ( +
+ Test Model: + {item.testModel} +
+ )} + {/* 成功/失败统计(汇总) */}
- - {t('stats.success')}:{stats.success}次 + + {t('stats.success')}: {stats.success} - - {t('stats.failure')}:{stats.failure}次 + + {t('stats.failure')}: {stats.failure}
@@ -790,14 +940,14 @@ export function AiProvidersPage() { />
-