feat(aiProviders): 添加配置搜索功能及相关UI优化

- 在英文和中文语言文件中新增搜索相关文案
- AiProvidersPage新增搜索框组件,支持根据关键字过滤各类AI提供商配置
- 根据搜索结果动态展示配置列表或空状态提示
- 优化页面样式,新增搜索框和空状态样式
- 通过useMemo缓存过滤结果,提升性能
- 支持搜索字段包括密钥、前缀、URL、模型名称、别名、请求头等多维度信息
This commit is contained in:
kongkongyo
2026-01-14 21:36:21 +08:00
parent 3c34872352
commit 91c6c7dd4f
4 changed files with 336 additions and 102 deletions

View File

@@ -325,7 +325,10 @@
"openai_test_success": "Test succeeded. The model responded.", "openai_test_success": "Test succeeded. The model responded.",
"openai_test_failed": "Test failed", "openai_test_failed": "Test failed",
"openai_test_select_placeholder": "Choose from current models", "openai_test_select_placeholder": "Choose from current models",
"openai_test_select_empty": "No models configured. Add models first" "openai_test_select_empty": "No models configured. Add models first",
"search_placeholder": "Search configs (keys, URLs, models...)",
"search_empty_title": "No matching configs",
"search_empty_desc": "Try a different keyword or clear the search box"
}, },
"auth_files": { "auth_files": {
"title": "Auth Files Management", "title": "Auth Files Management",

View File

@@ -325,7 +325,10 @@
"openai_test_success": "测试成功,模型可用。", "openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败", "openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择", "openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,请先添加模型" "openai_test_select_empty": "当前未配置模型,请先添加模型",
"search_placeholder": "搜索配置(密钥、地址、模型等)",
"search_empty_title": "没有匹配的配置",
"search_empty_desc": "请尝试更换关键字或清空搜索框"
}, },
"auth_files": { "auth_files": {
"title": "认证文件管理", "title": "认证文件管理",

View File

@@ -20,7 +20,53 @@
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 $spacing-xl 0; margin: 0;
}
.pageHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin-bottom: $spacing-xl;
}
.searchBox {
flex: 0 1 320px;
min-width: 200px;
@include mobile {
flex: 1 1 100%;
}
:global(.form-group) {
margin-bottom: 0;
}
}
.searchEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl * 2;
text-align: center;
background: var(--bg-secondary);
border: 1px dashed var(--border-primary);
border-radius: 12px;
}
.searchEmptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: $spacing-sm;
}
.searchEmptyDesc {
font-size: 14px;
color: var(--text-tertiary);
} }
.content { .content {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { entriesToModels } from '@/components/ui/ModelInputList'; import { entriesToModels } from '@/components/ui/ModelInputList';
import { import {
@@ -20,6 +20,7 @@ import {
withDisableAllModelsRule, withDisableAllModelsRule,
withoutDisableAllModelsRule, withoutDisableAllModelsRule,
} from '@/components/providers/utils'; } from '@/components/providers/utils';
import { Input } from '@/components/ui/Input';
import { ampcodeApi, providersApi } from '@/services/api'; import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
@@ -50,10 +51,132 @@ export function AiProvidersPage() {
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null); const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
const [modal, setModal] = useState<ProviderModal | null>(null); const [modal, setModal] = useState<ProviderModal | null>(null);
const [ampcodeBusy, setAmpcodeBusy] = useState(false); const [ampcodeBusy, setAmpcodeBusy] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey); const isSwitching = Boolean(configSwitchingKey);
const normalizedQuery = searchQuery.trim().toLowerCase();
const filteredGeminiKeys = useMemo(() => {
if (!normalizedQuery) return geminiKeys.map((item, index) => ({ item, originalIndex: index }));
return geminiKeys
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
...(item.excludedModels || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [geminiKeys, normalizedQuery]);
const filteredCodexConfigs = useMemo(() => {
if (!normalizedQuery) return codexConfigs.map((item, index) => ({ item, originalIndex: index }));
return codexConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.excludedModels || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [codexConfigs, normalizedQuery]);
const filteredClaudeConfigs = useMemo(() => {
if (!normalizedQuery) return claudeConfigs.map((item, index) => ({ item, originalIndex: index }));
return claudeConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.excludedModels || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [claudeConfigs, normalizedQuery]);
const filteredVertexConfigs = useMemo(() => {
if (!normalizedQuery) return vertexConfigs.map((item, index) => ({ item, originalIndex: index }));
return vertexConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [vertexConfigs, normalizedQuery]);
const filteredOpenaiProviders = useMemo(() => {
if (!normalizedQuery)
return openaiProviders.map((item, index) => ({ item, originalIndex: index }));
return openaiProviders
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.name,
item.prefix,
item.baseUrl,
item.testModel,
...(item.apiKeyEntries?.map((e) => e.apiKey) || []),
...(item.apiKeyEntries?.map((e) => e.proxyUrl) || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [openaiProviders, normalizedQuery]);
const showAmpcode = useMemo(() => {
if (!normalizedQuery) return true;
const ampcode = config?.ampcode;
if (!ampcode) return false;
const searchFields = [
ampcode.upstreamUrl,
ampcode.upstreamApiKey,
...(ampcode.modelMappings?.map((m) => m.from) || []),
...(ampcode.modelMappings?.map((m) => m.to) || []),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
}, [config?.ampcode, normalizedQuery]);
const hasSearchResults =
filteredGeminiKeys.length > 0 ||
filteredCodexConfigs.length > 0 ||
filteredClaudeConfigs.length > 0 ||
filteredVertexConfigs.length > 0 ||
filteredOpenaiProviders.length > 0 ||
showAmpcode;
const { keyStats, usageDetails, loadKeyStats } = useProviderStats(); const { keyStats, usageDetails, loadKeyStats } = useProviderStats();
const getErrorMessage = (err: unknown) => { const getErrorMessage = (err: unknown) => {
@@ -507,12 +630,30 @@ export function AiProvidersPage() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1> <h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.searchBox}>
<Input
type="text"
placeholder={t('ai_providers.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className={styles.content}> <div className={styles.content}>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
{normalizedQuery && !hasSearchResults && (
<div className={styles.searchEmpty}>
<div className={styles.searchEmptyTitle}>{t('ai_providers.search_empty_title')}</div>
<div className={styles.searchEmptyDesc}>{t('ai_providers.search_empty_desc')}</div>
</div>
)}
{filteredGeminiKeys.length > 0 && (
<GeminiSection <GeminiSection
configs={geminiKeys} configs={filteredGeminiKeys.map(({ item }) => item)}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
@@ -520,17 +661,25 @@ export function AiProvidersPage() {
isSaving={saving} isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'} isModalOpen={modal?.type === 'gemini'}
modalIndex={geminiModalIndex} modalIndex={
geminiModalIndex !== null
? filteredGeminiKeys.findIndex(({ originalIndex }) => originalIndex === geminiModalIndex)
: null
}
onAdd={() => openGeminiModal(null)} onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(index)} onEdit={(index) => openGeminiModal(filteredGeminiKeys[index]?.originalIndex ?? index)}
onDelete={deleteGemini} onDelete={(index) => deleteGemini(filteredGeminiKeys[index]?.originalIndex ?? index)}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)} onToggle={(index, enabled) =>
void setConfigEnabled('gemini', filteredGeminiKeys[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal} onCloseModal={closeModal}
onSave={saveGemini} onSave={saveGemini}
/> />
)}
{filteredCodexConfigs.length > 0 && (
<CodexSection <CodexSection
configs={codexConfigs} configs={filteredCodexConfigs.map(({ item }) => item)}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
@@ -539,17 +688,27 @@ export function AiProvidersPage() {
isSwitching={isSwitching} isSwitching={isSwitching}
resolvedTheme={resolvedTheme} resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'} isModalOpen={modal?.type === 'codex'}
modalIndex={codexModalIndex} modalIndex={
codexModalIndex !== null
? filteredCodexConfigs.findIndex(({ originalIndex }) => originalIndex === codexModalIndex)
: null
}
onAdd={() => openProviderModal('codex', null)} onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', index)} onEdit={(index) => openProviderModal('codex', filteredCodexConfigs[index]?.originalIndex ?? index)}
onDelete={(index) => void deleteProviderEntry('codex', index)} onDelete={(index) =>
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)} void deleteProviderEntry('codex', filteredCodexConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('codex', filteredCodexConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal} onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)} onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/> />
)}
{filteredClaudeConfigs.length > 0 && (
<ClaudeSection <ClaudeSection
configs={claudeConfigs} configs={filteredClaudeConfigs.map(({ item }) => item)}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
@@ -557,17 +716,27 @@ export function AiProvidersPage() {
isSaving={saving} isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'} isModalOpen={modal?.type === 'claude'}
modalIndex={claudeModalIndex} modalIndex={
claudeModalIndex !== null
? filteredClaudeConfigs.findIndex(({ originalIndex }) => originalIndex === claudeModalIndex)
: null
}
onAdd={() => openProviderModal('claude', null)} onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', index)} onEdit={(index) => openProviderModal('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)}
onDelete={(index) => void deleteProviderEntry('claude', index)} onDelete={(index) =>
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)} void deleteProviderEntry('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('claude', filteredClaudeConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal} onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)} onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/> />
)}
{filteredVertexConfigs.length > 0 && (
<VertexSection <VertexSection
configs={vertexConfigs} configs={filteredVertexConfigs.map(({ item }) => item)}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
@@ -575,14 +744,20 @@ export function AiProvidersPage() {
isSaving={saving} isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'} isModalOpen={modal?.type === 'vertex'}
modalIndex={vertexModalIndex} modalIndex={
vertexModalIndex !== null
? filteredVertexConfigs.findIndex(({ originalIndex }) => originalIndex === vertexModalIndex)
: null
}
onAdd={() => openVertexModal(null)} onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(index)} onEdit={(index) => openVertexModal(filteredVertexConfigs[index]?.originalIndex ?? index)}
onDelete={deleteVertex} onDelete={(index) => deleteVertex(filteredVertexConfigs[index]?.originalIndex ?? index)}
onCloseModal={closeModal} onCloseModal={closeModal}
onSave={saveVertex} onSave={saveVertex}
/> />
)}
{showAmpcode && (
<AmpcodeSection <AmpcodeSection
config={config?.ampcode} config={config?.ampcode}
loading={loading} loading={loading}
@@ -595,9 +770,11 @@ export function AiProvidersPage() {
onCloseModal={closeModal} onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy} onBusyChange={setAmpcodeBusy}
/> />
)}
{filteredOpenaiProviders.length > 0 && (
<OpenAISection <OpenAISection
configs={openaiProviders} configs={filteredOpenaiProviders.map(({ item }) => item)}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
@@ -606,13 +783,18 @@ export function AiProvidersPage() {
isSwitching={isSwitching} isSwitching={isSwitching}
resolvedTheme={resolvedTheme} resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'} isModalOpen={modal?.type === 'openai'}
modalIndex={openaiModalIndex} modalIndex={
openaiModalIndex !== null
? filteredOpenaiProviders.findIndex(({ originalIndex }) => originalIndex === openaiModalIndex)
: null
}
onAdd={() => openOpenaiModal(null)} onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(index)} onEdit={(index) => openOpenaiModal(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onDelete={deleteOpenai} onDelete={(index) => deleteOpenai(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onCloseModal={closeModal} onCloseModal={closeModal}
onSave={saveOpenai} onSave={saveOpenai}
/> />
)}
</div> </div>
</div> </div>
); );