diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 3b5d9fa..9db58d5 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -4,15 +4,20 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; import { EmptyState } from '@/components/ui/EmptyState'; -import { IconCheck, IconX } from '@/components/ui/icons'; +import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox'; +import { Select } from '@/components/ui/Select'; +import { + IconCheck, + IconChevronDown, + IconChevronUp, + IconSlidersHorizontal, + IconX, +} from '@/components/ui/icons'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import type { OpenAIProviderConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderStatusBar } from '../ProviderStatusBar'; @@ -117,7 +122,8 @@ export function OpenAISection({ const fixedTop = Number.parseFloat(rootStyles.getPropertyValue('--header-height')) || 64; const toolbarHeight = anchorRect.height; const isMobile = window.innerWidth <= 768; - const shouldShow = !isMobile && anchorRect.top <= fixedTop && sectionRect.bottom > fixedTop + toolbarHeight; + const shouldShow = + !isMobile && anchorRect.top <= fixedTop && sectionRect.bottom > fixedTop + toolbarHeight; setFloatingToolbarStyle((prev) => { const next = { @@ -148,7 +154,14 @@ export function OpenAISection({ window.removeEventListener('resize', updateFloatingToolbar); window.removeEventListener('scroll', updateFloatingToolbar, true); }; - }, [configs.length, isDropdownOpen, isTransitionAnimating, selectedModels, sortDirection, sortOption]); + }, [ + configs.length, + isDropdownOpen, + isTransitionAnimating, + selectedModels, + sortDirection, + sortOption, + ]); useEffect(() => { if (!isDropdownOpen) { @@ -187,7 +200,10 @@ export function OpenAISection({ const dropdownGap = 4; const preferredMaxHeight = 300; const minimumMaxHeight = 120; - const availableBelow = Math.max(0, window.innerHeight - rect.bottom - viewportPadding - dropdownGap); + const availableBelow = Math.max( + 0, + window.innerHeight - rect.bottom - viewportPadding - dropdownGap + ); const availableAbove = Math.max(0, rect.top - viewportPadding - dropdownGap); const openAbove = availableBelow < preferredMaxHeight && availableAbove > availableBelow; const availableSpace = openAbove ? availableAbove : availableBelow; @@ -223,6 +239,14 @@ export function OpenAISection({ }); return Array.from(modelSet).sort(); }, [configs]); + const selectedModelNames = useMemo(() => Array.from(selectedModels).sort(), [selectedModels]); + const modelFilterActive = selectedModelNames.length > 0; + const modelFilterLabel = modelFilterActive + ? t('ai_providers.model_discovery_selected_count', { count: selectedModelNames.length }) + : t('ai_providers.model_search_placeholder'); + const modelFilterTitle = modelFilterActive + ? selectedModelNames.join(', ') + : t('ai_providers.model_search_placeholder'); const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -232,11 +256,7 @@ export function OpenAISection({ cache.set( providerKey, calculateStatusBarData( - collectOpenAIProviderUsageDetails( - provider, - usageDetailsBySource, - usageDetailsByAuthIndex - ) + collectOpenAIProviderUsageDetails(provider, usageDetailsBySource, usageDetailsByAuthIndex) ) ); }); @@ -244,6 +264,15 @@ export function OpenAISection({ return cache; }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + const sortOptions = useMemo( + () => [ + { value: 'priority', label: t('ai_providers.sort_by_priority') }, + { value: 'name', label: t('ai_providers.sort_by_name') }, + { value: 'recent-success', label: t('ai_providers.sort_by_recent_success') }, + ], + [t] + ); + const sortedConfigs = useMemo(() => { const indexed = configs.map((config, originalIndex) => ({ config, originalIndex })); const filtered = indexed.filter(({ config }) => { @@ -255,12 +284,7 @@ export function OpenAISection({ const direction = sortDirection === 'desc' ? -1 : 1; const providerStats = sortOption === 'recent-success' - ? new Map( - sorted.map(({ config }) => [ - config, - getOpenAIProviderStats(config, keyStats), - ]) - ) + ? new Map(sorted.map(({ config }) => [config, getOpenAIProviderStats(config, keyStats)])) : null; switch (sortOption) { @@ -283,7 +307,8 @@ export function OpenAISection({ case 'recent-success': sorted.sort((a, b) => { const successDiff = - (providerStats?.get(a.config)?.success ?? 0) - (providerStats?.get(b.config)?.success ?? 0); + (providerStats?.get(a.config)?.success ?? 0) - + (providerStats?.get(b.config)?.success ?? 0); if (successDiff !== 0) { return direction * successDiff; @@ -327,32 +352,49 @@ export function OpenAISection({ const renderSortControls = () => (
- - + + {sortDirection === 'asc' ? : } + + + {sortDirection === 'asc' + ? t('ai_providers.sort_asc_short') + : t('ai_providers.sort_desc_short')} + +
); const renderToolbar = (isFloating = false) => { const isActiveToolbar = isFloating === shouldRenderFloatingToolbar; - const dropdownClassName = - dropdownLayout.openAbove ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` : styles.modelDropdownList; + const dropdownClassName = dropdownLayout.openAbove + ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` + : styles.modelDropdownList; return (
@@ -360,97 +402,110 @@ export function OpenAISection({ className={styles.modelMultiSelectWrapper} ref={isFloating ? floatingDropdownRef : topDropdownRef} > -
- {selectedModels.size === 0 ? ( +
+ + {modelFilterActive && ( - ) : ( - <> - {Array.from(selectedModels).map((name) => ( - - {name} - - - ))} - - )}
{isActiveToolbar && isDropdownOpen && ( -
+
- - {selectedModels.size > 0 && ( - + + )} +
+
+ {allModelNames.length === 0 ? ( +
+ {t('ai_providers.model_filter_empty')} +
+ ) : ( + allModelNames.map((name) => ( + toggleModelSelection(name)} + disabled={actionsDisabled} + className={styles.modelDropdownItem} + labelClassName={styles.modelDropdownItemLabel} + label={{name}} + /> + )) )}
- {allModelNames.map((name) => ( - - ))}
)}
- {selectedModels.size > 0 && ( - - )} {renderSortControls()} -
@@ -472,7 +527,8 @@ export function OpenAISection({ const stats = getOpenAIProviderStats(provider, keyStats); const headerEntries = Object.entries(provider.headers || {}); const apiKeyEntries = provider.apiKeyEntries || []; - const statusData = statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR; + const statusData = + statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR; return (
{entryIndex + 1} {maskApiKey(entry.apiKey)} - {entry.proxyUrl && {entry.proxyUrl}} + {entry.proxyUrl && ( + {entry.proxyUrl} + )}
- + {entryStats.success} - + {entryStats.failure}
@@ -573,10 +635,20 @@ export function OpenAISection({
- -
@@ -604,11 +676,16 @@ export function OpenAISection({ + action={ + - )} + } /> ) : sortedConfigs.length === 0 ? ( {typeof document !== 'undefined' && shouldRenderFloatingToolbar ? createPortal( -
-
-
{renderStaticTitle()}
- {renderToolbar(true)} -
-
, - document.body - ) +
+
+
{renderStaticTitle()}
+ {renderToolbar(true)} +
+
, + document.body + ) : null} ); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a8acb93..c324257 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -406,9 +406,11 @@ "openai_filtered_empty_desc": "No providers match the current model filter. Clear the filter and try again.", "sort_by_name": "Sort by Name", "sort_ascending": "Sort ascending", + "sort_asc_short": "Asc", "sort_by_priority": "Sort by Priority", "sort_by_recent_success": "Sort by Recent Success", "sort_descending": "Sort descending", + "sort_desc_short": "Desc", "openai_test_model": "Test Model", "openai_add_modal_title": "Add OpenAI Compatible Provider", "openai_add_modal_name_label": "Provider Name:", @@ -466,7 +468,8 @@ "openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed", "model_search_placeholder": "Filter by models...", "model_search_clear": "Clear", - "model_select_all": "Select All" + "model_select_all": "Select All", + "model_filter_empty": "No models to filter" }, "auth_files": { "title": "Auth Files Management", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e8a7dea..186d669 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -406,9 +406,11 @@ "openai_filtered_empty_desc": "Ни один провайдер не соответствует текущему фильтру моделей. Очистите фильтр и попробуйте снова.", "sort_by_name": "Сортировать по имени", "sort_ascending": "Сортировать по возрастанию", + "sort_asc_short": "Возр.", "sort_by_priority": "Сортировать по приоритету", "sort_by_recent_success": "Сортировать по недавним успехам", "sort_descending": "Сортировать по убыванию", + "sort_desc_short": "Убыв.", "openai_test_model": "Тестовая модель", "openai_add_modal_title": "Добавление совместимого с OpenAI провайдера", "openai_add_modal_name_label": "Имя провайдера:", @@ -466,7 +468,8 @@ "openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло", "model_search_placeholder": "Фильтр по моделям...", "model_search_clear": "Очистить", - "model_select_all": "Выбрать все" + "model_select_all": "Выбрать все", + "model_filter_empty": "Нет моделей для фильтра" }, "auth_files": { "title": "Управление файлами авторизации", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b1ac355..15f28ed 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -406,9 +406,11 @@ "openai_filtered_empty_desc": "当前模型筛选下没有匹配的提供商,请清除筛选后重试。", "sort_by_name": "按名称排序", "sort_ascending": "升序排序", + "sort_asc_short": "升序", "sort_by_priority": "按优先级排序", "sort_by_recent_success": "按最近成功数排序", "sort_descending": "降序排序", + "sort_desc_short": "降序", "openai_test_model": "测试模型", "openai_add_modal_title": "添加OpenAI兼容提供商", "openai_add_modal_name_label": "提供商名称:", @@ -466,7 +468,8 @@ "openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败", "model_search_placeholder": "按模型筛选...", "model_search_clear": "清除", - "model_select_all": "全选" + "model_select_all": "全选", + "model_filter_empty": "暂无可筛选模型" }, "auth_files": { "title": "认证文件管理", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 62335bf..7f9aeba 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -406,9 +406,11 @@ "openai_filtered_empty_desc": "目前模型篩選下沒有匹配的供應商,請清除篩選後再試一次。", "sort_by_name": "依名稱排序", "sort_ascending": "升冪排序", + "sort_asc_short": "升冪", "sort_by_priority": "依優先順序排序", "sort_by_recent_success": "依最近成功排序", "sort_descending": "降冪排序", + "sort_desc_short": "降冪", "openai_test_model": "測試模型", "openai_add_modal_title": "新增 OpenAI 相容供應商", "openai_add_modal_name_label": "供應商名稱:", @@ -466,7 +468,8 @@ "openai_test_all_partial": "測試完成:{{success}} 個通過,{{failed}} 個失敗", "model_search_placeholder": "依模型篩選...", "model_search_clear": "清除", - "model_select_all": "全選" + "model_select_all": "全選", + "model_filter_empty": "暫無可篩選模型" }, "auth_files": { "title": "驗證檔案管理", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index 2ccac8c..3e48eec 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -1,6 +1,8 @@ @use '../styles/variables' as *; @use '../styles/mixins' as *; +$openai-toolbar-control-height: 36px; + .container { width: 100%; } @@ -103,69 +105,60 @@ display: flex; align-items: center; gap: $spacing-xs; + height: $openai-toolbar-control-height; } .sortSelect { - padding: 6px 10px; - font-size: 13px; - font-weight: 500; - border: 1px solid var(--border-primary); - border-radius: 6px; - background: var(--bg-secondary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.15s ease; + flex: 0 0 148px; + width: 148px; - &:hover { - border-color: var(--primary-color); - } - - &:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 2px var(--primary-color-alpha, rgba(59, 130, 246, 0.1)); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; + > button { + height: $openai-toolbar-control-height; + padding: 0 12px; + border-color: var(--border-primary); + border-radius: 6px; + background: var(--bg-secondary); + box-shadow: none; + font-size: 13px; } } // 排序方向按钮 -.sortDirectionButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - font-size: 16px; - font-weight: 600; - border: 1px solid var(--border-primary); +.sortDirectionButton:global(.btn.btn-secondary) { + flex: 0 0 74px; + min-width: 74px; + height: $openai-toolbar-control-height; + padding: 0 10px; + border-color: var(--border-primary); border-radius: 6px; background: var(--bg-secondary); color: var(--text-primary); - cursor: pointer; - transition: all 0.15s ease; + gap: 6px; &:hover { border-color: var(--primary-color); background: var(--bg-tertiary); } - &:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 2px var(--primary-color-alpha, rgba(59, 130, 246, 0.1)); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; + > span { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; } } +.sortDirectionIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 5px; + background: color-mix(in srgb, var(--primary-color) 12%, transparent); + color: var(--primary-color); + flex-shrink: 0; +} // 卡片头部操作区 .cardHeaderActions { @@ -173,6 +166,11 @@ align-items: center; flex-wrap: wrap; gap: $spacing-sm; + min-height: $openai-toolbar-control-height; + + :global(.btn) { + white-space: nowrap; + } } .openaiToolbarAnchorHidden { @@ -195,23 +193,24 @@ border-radius: 0; } - // 模型多选下拉框 .modelMultiSelectWrapper { position: relative; } -.modelSelectedTags { +.modelFilterControl { display: flex; align-items: center; - flex-wrap: wrap; - gap: 4px; - padding: 4px 8px; + width: 164px; + height: $openai-toolbar-control-height; border: 1px solid var(--border-primary); border-radius: 6px; background: var(--bg-secondary); - transition: all 0.15s ease; - min-height: 32px; + overflow: hidden; + transition: + background-color $transition-fast, + border-color $transition-fast, + box-shadow $transition-fast; &:hover { border-color: var(--primary-color); @@ -219,91 +218,98 @@ } } -.modelSelectButton { - display: flex; - align-items: center; - gap: 4px; - flex: 1 1 auto; - min-width: 0; - padding: 0; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - - &:disabled { - cursor: not-allowed; - opacity: 0.6; - } +.modelFilterControlActive { + border-color: color-mix(in srgb, var(--primary-color) 46%, var(--border-primary)); + background: color-mix(in srgb, var(--primary-color) 7%, var(--bg-secondary)); } -.modelSelectPlaceholder { - font-size: 13px; - color: var(--text-tertiary); - flex: 1; -} - -.modelFilterTag { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 3px; - padding: 4px 8px 4px 8px; - background: var(--primary-color-alpha, rgba(59, 130, 246, 0.15)); - color: var(--primary-color); - border-radius: 4px; - font-size: 11px; - font-weight: 500; - white-space: nowrap; - max-width: 150px; - position: relative; -} - -.modelTagName { - overflow: hidden; - text-overflow: ellipsis; -} - -.modelTagRemove { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - padding: 0; - border: none; - background: var(--bg-secondary); - color: var(--text-primary); - font-size: 18px; - line-height: 1; - cursor: pointer; - border-radius: 50%; - transition: all 0.15s ease; - flex-shrink: 0; +.modelFilterControlDisabled { + opacity: 0.6; + cursor: not-allowed; &:hover { - background: var(--bg-tertiary); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.6; + border-color: var(--border-primary); + background: var(--bg-secondary); } } -.modelSelectArrowButton { +.modelFilterTrigger { + display: flex; + align-items: center; + gap: 7px; + flex: 1 1 auto; + min-width: 0; + height: 100%; + padding: 0 9px; + border: 0; + background: transparent; + color: var(--text-primary); + cursor: pointer; + text-align: left; + + &:disabled { + cursor: not-allowed; + } +} + +.modelFilterIcon { display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.modelFilterText { + font-size: 13px; + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelFilterCount { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: $radius-full; + background: var(--primary-color); + color: var(--primary-contrast, #fff); + font-size: 12px; + font-weight: 700; + line-height: 1; + flex-shrink: 0; +} + +.modelFilterChevron { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + flex-shrink: 0; +} + +.modelFilterInlineClear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 100%; padding: 0; border: 0; - border-radius: 4px; + border-left: 1px solid var(--border-primary); background: transparent; - color: var(--text-tertiary); + color: var(--text-secondary); cursor: pointer; flex-shrink: 0; + transition: + background-color $transition-fast, + color $transition-fast; &:hover { background: var(--bg-tertiary); @@ -312,31 +318,24 @@ &:disabled { cursor: not-allowed; - opacity: 0.6; + color: var(--text-tertiary); } - - .modelSelectArrow { - margin-left: 0; - } -} - -.modelSelectArrow { - font-size: 10px; - color: var(--text-tertiary); - margin-left: 4px; } .modelDropdownList { position: absolute; top: calc(100% + 4px); left: 0; - min-width: 280px; + width: 320px; + max-width: min(320px, calc(100vw - 32px)); max-height: 300px; - overflow-y: auto; + overflow: hidden; background: var(--bg-primary); border: 1px solid var(--border-primary); border-radius: $radius-md; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; z-index: 1000; } @@ -350,58 +349,55 @@ align-items: center; justify-content: space-between; gap: $spacing-xs; - padding: $spacing-xs $spacing-sm; + padding: 6px; border-bottom: 1px solid var(--border-primary); background: var(--bg-tertiary); } -.modelDropdownSelectAll, -.modelDropdownClear { - padding: 4px 8px; - font-size: 12px; - background: transparent; - border: none; +.modelDropdownSelectAll:global(.btn.btn-ghost), +.modelDropdownClear:global(.btn.btn-ghost) { + height: 28px; + padding: 0 8px; color: var(--primary-color); - cursor: pointer; - border-radius: 4px; - transition: all 0.15s ease; + font-size: 12px; &:hover { background: var(--bg-secondary); } } -.modelFilterClearButton:global(.btn.btn-secondary) { - background: var(--primary-color-alpha, rgba(59, 130, 246, 0.15)); - border-color: rgba(59, 130, 246, 0.24); - color: var(--primary-color); +.openaiAddButton:global(.btn.btn-primary) { + height: $openai-toolbar-control-height; + padding: 0 12px; +} - &:hover { - background: rgba(59, 130, 246, 0.22); - border-color: rgba(59, 130, 246, 0.32); - color: var(--primary-color); - } +.modelDropdownItems { + overflow-y: auto; + padding: 4px; + min-height: 0; } .modelDropdownItem { display: flex; align-items: center; gap: $spacing-sm; - padding: $spacing-xs $spacing-sm; + width: 100%; + padding: 8px; + border-radius: 6px; cursor: pointer; transition: background 0.15s ease; &:hover { background: var(--bg-secondary); } +} - input[type="checkbox"] { - margin: 0; - cursor: pointer; - } +.modelDropdownItemLabel { + min-width: 0; + flex: 1; span { - flex: 1; + display: block; font-size: 13px; color: var(--text-primary); white-space: nowrap; @@ -410,6 +406,12 @@ } } +.modelDropdownEmpty { + padding: 18px 12px; + text-align: center; + color: var(--text-tertiary); + font-size: 13px; +} // 成功失败次数统计样式 .cardStats {