diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 452d56a..5561275 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -391,7 +391,10 @@ export function PageTransition({ } > {render(layer.location)} diff --git a/src/components/common/PageTransitionLayer.ts b/src/components/common/PageTransitionLayer.ts index 98f5898..0ab66c3 100644 --- a/src/components/common/PageTransitionLayer.ts +++ b/src/components/common/PageTransitionLayer.ts @@ -5,15 +5,16 @@ export type LayerStatus = 'current' | 'exiting' | 'stacked'; export type PageTransitionLayerContextValue = { status: LayerStatus; isCurrentLayer: boolean; + isAnimating: boolean; }; export const PageTransitionLayerContext = createContext(null); export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record = { - current: { status: 'current', isCurrentLayer: true }, - stacked: { status: 'stacked', isCurrentLayer: false }, - exiting: { status: 'exiting', isCurrentLayer: false }, + current: { status: 'current', isCurrentLayer: true, isAnimating: false }, + stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false }, + exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false }, }; export function usePageTransitionLayer() { diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index abb4a07..20aa817 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -91,8 +91,8 @@ export function ClaudeSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.claude_empty_title')} emptyDescription={t('ai_providers.claude_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index 1245357..7db1150 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -91,8 +91,8 @@ export function CodexSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.codex_empty_title')} emptyDescription={t('ai_providers.codex_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index a551311..48b141e 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -91,8 +91,8 @@ export function GeminiSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.gemini_empty_title')} emptyDescription={t('ai_providers.gemini_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 6b9315d..7b7c766 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -1,25 +1,41 @@ -import { Fragment, useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; 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 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 { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; +import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; import { collectOpenAIProviderUsageDetails, - getOpenAIEntryKey, getOpenAIProviderKey, getOpenAIProviderStats, getStatsForIdentity, } from '../utils'; +type SortOption = 'name' | 'priority' | 'recent-success'; +type SortDirection = 'asc' | 'desc'; + +interface FloatingToolbarStyle { + left: number; + top: number; + width: number; + visible: boolean; +} + +const EMPTY_STATUS_BAR = calculateStatusBarData([]); + interface OpenAISectionProps { configs: OpenAIProviderConfig[]; keyStats: KeyStats; @@ -34,6 +50,11 @@ interface OpenAISectionProps { onDelete: (index: number) => void; } +interface IndexedOpenAIProvider { + config: OpenAIProviderConfig; + originalIndex: number; +} + export function OpenAISection({ configs, keyStats, @@ -48,7 +69,152 @@ export function OpenAISection({ onDelete, }: OpenAISectionProps) { const { t } = useTranslation(); + const pageTransitionLayer = usePageTransitionLayer(); + const isTransitionAnimating = pageTransitionLayer?.isAnimating ?? false; const actionsDisabled = disableControls || loading || isSwitching; + const [sortOption, setSortOption] = useState('priority'); + const [sortDirection, setSortDirection] = useState('asc'); + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 }); + const [floatingToolbarStyle, setFloatingToolbarStyle] = useState({ + left: 0, + top: 0, + width: 0, + visible: false, + }); + const sectionRef = useRef(null); + const topToolbarAnchorRef = useRef(null); + const topDropdownRef = useRef(null); + const floatingDropdownRef = useRef(null); + + const shouldRenderFloatingToolbar = !isTransitionAnimating && floatingToolbarStyle.visible; + + useEffect(() => { + if (isTransitionAnimating) { + return; + } + + const updateFloatingToolbar = () => { + const section = sectionRef.current; + const anchor = topToolbarAnchorRef.current; + + if (!section || !anchor) { + return; + } + + const sectionRect = section.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + const rootStyles = getComputedStyle(document.documentElement); + 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; + + setFloatingToolbarStyle((prev) => { + const next = { + left: sectionRect.left, + top: fixedTop, + width: sectionRect.width, + visible: shouldShow, + }; + + if ( + prev.left === next.left && + prev.top === next.top && + prev.width === next.width && + prev.visible === next.visible + ) { + return prev; + } + + return next; + }); + }; + + updateFloatingToolbar(); + window.addEventListener('resize', updateFloatingToolbar); + window.addEventListener('scroll', updateFloatingToolbar, true); + + return () => { + window.removeEventListener('resize', updateFloatingToolbar); + window.removeEventListener('scroll', updateFloatingToolbar, true); + }; + }, [configs.length, isDropdownOpen, isTransitionAnimating, selectedModels, sortDirection, sortOption]); + + useEffect(() => { + if (!isDropdownOpen) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const clickedTop = topDropdownRef.current?.contains(target); + const clickedFloating = floatingDropdownRef.current?.contains(target); + + if (!clickedTop && !clickedFloating) { + setIsDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isDropdownOpen]); + + useEffect(() => { + if (!isDropdownOpen) { + return; + } + + const updateDropdownLayout = () => { + const wrapper = floatingToolbarStyle.visible + ? floatingDropdownRef.current + : topDropdownRef.current; + + if (!wrapper) { + return; + } + + const rect = wrapper.getBoundingClientRect(); + const viewportPadding = 12; + const dropdownGap = 4; + const preferredMaxHeight = 300; + const minimumMaxHeight = 120; + 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; + const maxHeight = Math.max(minimumMaxHeight, Math.min(preferredMaxHeight, availableSpace)); + + setDropdownLayout((prev) => { + if (prev.openAbove === openAbove && prev.maxHeight === maxHeight) { + return prev; + } + + return { openAbove, maxHeight }; + }); + }; + + updateDropdownLayout(); + window.addEventListener('resize', updateDropdownLayout); + window.addEventListener('scroll', updateDropdownLayout, true); + + return () => { + window.removeEventListener('resize', updateDropdownLayout); + window.removeEventListener('scroll', updateDropdownLayout, true); + }; + }, [floatingToolbarStyle.visible, isDropdownOpen]); + + const allModelNames = useMemo(() => { + const modelSet = new Set(); + configs.forEach((provider) => { + provider.models?.forEach((model) => { + if (model.name) { + modelSet.add(model.name); + } + }); + }); + return Array.from(modelSet).sort(); + }, [configs]); const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -70,144 +236,395 @@ export function OpenAISection({ return cache; }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + const sortedConfigs = useMemo(() => { + const indexed = configs.map((config, originalIndex) => ({ config, originalIndex })); + const filtered = indexed.filter(({ config }) => { + if (selectedModels.size === 0) return true; + return config.models?.some((model) => selectedModels.has(model.name)); + }); + + const sorted = [...filtered]; + const direction = sortDirection === 'desc' ? -1 : 1; + const providerStats = + sortOption === 'recent-success' + ? new Map( + sorted.map(({ config }) => [ + config, + getOpenAIProviderStats(config, keyStats), + ]) + ) + : null; + + switch (sortOption) { + case 'name': + sorted.sort((a, b) => direction * a.config.name.localeCompare(b.config.name)); + break; + case 'priority': + sorted.sort((a, b) => { + const priorityA = a.config.priority ?? Number.MAX_SAFE_INTEGER; + const priorityB = b.config.priority ?? Number.MAX_SAFE_INTEGER; + const priorityDiff = priorityA - priorityB; + + if (priorityDiff !== 0) { + return direction * priorityDiff; + } + + return direction * a.config.name.localeCompare(b.config.name); + }); + break; + case 'recent-success': + sorted.sort((a, b) => { + const successDiff = + (providerStats?.get(a.config)?.success ?? 0) - (providerStats?.get(b.config)?.success ?? 0); + + if (successDiff !== 0) { + return direction * successDiff; + } + + return direction * a.config.name.localeCompare(b.config.name); + }); + break; + default: + break; + } + + return sorted; + }, [configs, sortOption, sortDirection, keyStats, selectedModels]); + + const toggleModelSelection = (modelName: string) => { + setSelectedModels((prev) => { + const next = new Set(prev); + if (next.has(modelName)) { + next.delete(modelName); + } else { + next.add(modelName); + } + return next; + }); + }; + + const clearAllModels = () => { + setSelectedModels(new Set()); + }; + + const handleSortOptionChange = (value: SortOption) => { + setSortOption(value); + }; + + const toggleSortDirection = () => { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + }; + + const toggleDropdown = () => setIsDropdownOpen((prev) => !prev); + + const renderSortControls = () => ( + + handleSortOptionChange(e.target.value as SortOption)} + className={styles.sortSelect} + disabled={actionsDisabled} + > + {t('ai_providers.sort_by_priority')} + {t('ai_providers.sort_by_name')} + {t('ai_providers.sort_by_recent_success')} + + + {sortDirection === 'asc' ? '↑' : '↓'} + + + ); + + const renderToolbar = (isFloating = false) => { + const isActiveToolbar = isFloating === shouldRenderFloatingToolbar; + const dropdownClassName = + dropdownLayout.openAbove ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` : styles.modelDropdownList; + + return ( + + + + {selectedModels.size === 0 ? ( + + {t('ai_providers.model_search_placeholder')} + + ) : ( + <> + {Array.from(selectedModels).map((name) => ( + + {name} + { + e.stopPropagation(); + toggleModelSelection(name); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleModelSelection(name); + } + }} + > + × + + + ))} + > + )} + ▼ + + + {isActiveToolbar && isDropdownOpen && ( + + + setSelectedModels(new Set(allModelNames))} + className={styles.modelDropdownSelectAll} + > + {t('ai_providers.model_select_all')} + + {selectedModels.size > 0 && ( + + {t('ai_providers.model_search_clear')} + + )} + + {allModelNames.map((name) => ( + + toggleModelSelection(name)} + /> + {name} + + ))} + + )} + + {selectedModels.size > 0 && ( + + {t('ai_providers.model_search_clear')} + + )} + {renderSortControls()} + + {t('ai_providers.openai_add_button')} + + + ); + }; + + const renderStaticTitle = () => ( + + + {t('ai_providers.openai_title')} + + ); + + const renderProviderCard = ({ config: provider, originalIndex }: IndexedOpenAIProvider) => { + 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; + + return ( + + + {provider.name} + {provider.priority !== undefined && ( + + {t('common.priority')}: + {provider.priority} + + )} + {provider.prefix && ( + + {t('common.prefix')}: + {provider.prefix} + + )} + + {t('common.base_url')}: + {provider.baseUrl} + + {headerEntries.length > 0 && ( + + {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} + + )} + {apiKeyEntries.length > 0 && ( + + + {t('ai_providers.openai_keys_count')}: {apiKeyEntries.length} + + + {apiKeyEntries.map((entry, entryIndex) => { + const entryStats = getStatsForIdentity( + { authIndex: entry.authIndex, apiKey: entry.apiKey }, + keyStats + ); + return ( + + {entryIndex + 1} + {maskApiKey(entry.apiKey)} + {entry.proxyUrl && {entry.proxyUrl}} + + + {entryStats.success} + + + {entryStats.failure} + + + + ); + })} + + + )} + + {t('ai_providers.openai_models_count')}: + {provider.models?.length || 0} + + {provider.models?.length ? ( + + {provider.models.map((model) => ( + + {model.name} + {model.alias && model.alias !== model.name && ( + {model.alias} + )} + + ))} + + ) : null} + {provider.testModel && ( + + {t('ai_providers.openai_test_model')}: + {provider.testModel} + + )} + + + {t('stats.success')}: {stats.success} + + + {t('stats.failure')}: {stats.failure} + + + + + + onEdit(originalIndex)} disabled={actionsDisabled}> + {t('common.edit')} + + onDelete(originalIndex)} disabled={actionsDisabled}> + {t('common.delete')} + + + + ); + }; + return ( <> - - + + {renderToolbar(false)} + + } + > + {loading && sortedConfigs.length === 0 ? ( + {t('common.loading')} + ) : configs.length > 0 && sortedConfigs.length === 0 ? ( + + {t('ai_providers.model_search_clear')} + + )} /> - {t('ai_providers.openai_title')} - - } - extra={ - - {t('ai_providers.openai_add_button')} - - } - > - - items={configs} - loading={loading} - keyField={(item, index) => getOpenAIProviderKey(item, index)} - emptyTitle={t('ai_providers.openai_empty_title')} - emptyDescription={t('ai_providers.openai_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} - actionsDisabled={actionsDisabled} - renderContent={(item, index) => { - const stats = getOpenAIProviderStats(item, keyStats); - const headerEntries = Object.entries(item.headers || {}); - const apiKeyEntries = item.apiKeyEntries || []; - const statusData = - statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]); - - return ( - - {item.name} - {item.priority !== undefined && ( - - {t('common.priority')}: - {item.priority} - - )} - {item.prefix && ( - - {t('common.prefix')}: - {item.prefix} - - )} - - {t('common.base_url')}: - {item.baseUrl} - - {headerEntries.length > 0 && ( - - {headerEntries.map(([key, value]) => ( - - {key}: {value} - - ))} - - )} - {apiKeyEntries.length > 0 && ( - - - {t('ai_providers.openai_keys_count')}: {apiKeyEntries.length} - - - {apiKeyEntries.map((entry, entryIndex) => { - const entryStats = getStatsForIdentity( - { authIndex: entry.authIndex, apiKey: entry.apiKey }, - keyStats - ); - 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.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.failure')}: {stats.failure} - - - - - ); - }} - /> - + ) : sortedConfigs.length === 0 ? ( + + ) : ( + {sortedConfigs.map(renderProviderCard)} + )} + + + {typeof document !== 'undefined' && shouldRenderFloatingToolbar + ? createPortal( + + + {renderStaticTitle()} + {renderToolbar(true)} + + , + document.body + ) + : null} > ); } diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 9d1a1f7..c6fd8cf 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -8,14 +8,18 @@ interface ProviderListProps { loading: boolean; keyField: (item: T, index: number) => string; renderContent: (item: T, index: number) => ReactNode; - onEdit: (index: number) => void; - onDelete: (index: number) => void; + onEdit: (item: T, index: number) => void; + onDelete: (item: T, index: number) => void; emptyTitle: string; emptyDescription: string; deleteLabel?: string; actionsDisabled?: boolean; getRowDisabled?: (item: T, index: number) => boolean; renderExtraActions?: (item: T, index: number) => ReactNode; + listClassName?: string; + rowClassName?: string; + metaClassName?: string; + actionsClassName?: string; } export function ProviderList({ @@ -31,6 +35,10 @@ export function ProviderList({ actionsDisabled = false, getRowDisabled, renderExtraActions, + listClassName, + rowClassName, + metaClassName, + actionsClassName, }: ProviderListProps) { const { t } = useTranslation(); @@ -43,21 +51,21 @@ export function ProviderList({ } return ( - + {items.map((item, index) => { const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false; return ( - {renderContent(item, index)} - + {renderContent(item, index)} + onEdit(index)} + onClick={() => onEdit(item, index)} disabled={actionsDisabled} > {t('common.edit')} @@ -65,7 +73,7 @@ export function ProviderList({ onDelete(index)} + onClick={() => onDelete(item, index)} disabled={actionsDisabled} > {deleteLabel || t('common.delete')} diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx index 716d746..9061123 100644 --- a/src/components/providers/VertexSection/VertexSection.tsx +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -91,8 +91,8 @@ export function VertexSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.vertex_empty_title')} emptyDescription={t('ai_providers.vertex_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 173d5e7..a8acb93 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -402,6 +402,14 @@ "openai_add_button": "Add Provider", "openai_empty_title": "No OpenAI Compatible Providers", "openai_empty_desc": "Click the button above to add the first provider", + "openai_filtered_empty_title": "No matching providers", + "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_by_priority": "Sort by Priority", + "sort_by_recent_success": "Sort by Recent Success", + "sort_descending": "Sort descending", + "openai_test_model": "Test Model", "openai_add_modal_title": "Add OpenAI Compatible Provider", "openai_add_modal_name_label": "Provider Name:", "openai_add_modal_name_placeholder": "e.g.: openrouter", @@ -455,7 +463,10 @@ "openai_test_all_hint": "Test connection status for all keys", "openai_test_all_success": "All {{count}} keys passed the test", "openai_test_all_failed": "All {{count}} keys failed the test", - "openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed" + "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" }, "auth_files": { "title": "Auth Files Management", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4e648d9..86df803 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -402,6 +402,11 @@ "openai_add_button": "Добавить провайдера", "openai_empty_title": "Провайдеры OpenAI отсутствуют", "openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера", + "openai_filtered_empty_title": "Нет подходящих провайдеров", + "openai_filtered_empty_desc": "Ни один провайдер не соответствует текущему фильтру моделей. Очистите фильтр и попробуйте снова.", + "sort_by_name": "Сортировать по имени", + "sort_by_priority": "Сортировать по приоритету", + "sort_by_recent_success": "Сортировать по недавним успехам", "openai_add_modal_title": "Добавление совместимого с OpenAI провайдера", "openai_add_modal_name_label": "Имя провайдера:", "openai_add_modal_name_placeholder": "например: openrouter", @@ -455,7 +460,10 @@ "openai_test_all_hint": "Проверить состояние подключения для всех ключей", "openai_test_all_success": "Все {{count}} ключей прошли тест", "openai_test_all_failed": "Все {{count}} ключей не прошли тест", - "openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло" + "openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло", + "model_search_placeholder": "Фильтр по моделям...", + "model_search_clear": "Очистить", + "model_select_all": "Выбрать все" }, "auth_files": { "title": "Управление файлами авторизации", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9e7a592..b1ac355 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -402,6 +402,14 @@ "openai_add_button": "添加提供商", "openai_empty_title": "暂无OpenAI兼容提供商", "openai_empty_desc": "点击上方按钮添加第一个提供商", + "openai_filtered_empty_title": "没有匹配的提供商", + "openai_filtered_empty_desc": "当前模型筛选下没有匹配的提供商,请清除筛选后重试。", + "sort_by_name": "按名称排序", + "sort_ascending": "升序排序", + "sort_by_priority": "按优先级排序", + "sort_by_recent_success": "按最近成功数排序", + "sort_descending": "降序排序", + "openai_test_model": "测试模型", "openai_add_modal_title": "添加OpenAI兼容提供商", "openai_add_modal_name_label": "提供商名称:", "openai_add_modal_name_placeholder": "例如: openrouter", @@ -455,7 +463,10 @@ "openai_test_all_hint": "测试所有密钥的连接状态", "openai_test_all_success": "所有 {{count}} 个密钥测试通过", "openai_test_all_failed": "所有 {{count}} 个密钥测试失败", - "openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败" + "openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败", + "model_search_placeholder": "按模型筛选...", + "model_search_clear": "清除", + "model_select_all": "全选" }, "auth_files": { "title": "认证文件管理", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 4c2597f..203fd62 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -402,6 +402,14 @@ "openai_add_button": "新增供應商", "openai_empty_title": "暫無 OpenAI 相容供應商", "openai_empty_desc": "點擊上方按鈕新增第一個供應商", + "openai_filtered_empty_title": "沒有匹配的供應商", + "openai_filtered_empty_desc": "目前模型篩選下沒有匹配的供應商,請清除篩選後再試一次。", + "sort_by_name": "依名稱排序", + "sort_ascending": "升冪排序", + "sort_by_priority": "依優先順序排序", + "sort_by_recent_success": "依最近成功排序", + "sort_descending": "降冪排序", + "openai_test_model": "測試模型", "openai_add_modal_title": "新增 OpenAI 相容供應商", "openai_add_modal_name_label": "供應商名稱:", "openai_add_modal_name_placeholder": "例如: openrouter", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index c5a835e..46ed02a 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -52,16 +52,314 @@ } } -.providerList { +.openaiProviderList { display: grid; gap: $spacing-md; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + @media (min-width: 1400px) { + grid-template-columns: repeat(3, 1fr); + } + @include mobile { grid-template-columns: 1fr; } } +.openaiProviderCard { + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: $spacing-md; + background: var(--bg-primary); + display: flex; + flex-direction: column; + align-items: stretch; + gap: $spacing-sm; + min-height: 0; +} + +.openaiProviderMeta { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 0; +} + +.openaiProviderActions { + display: flex; + gap: $spacing-sm; + flex-wrap: wrap; + justify-content: flex-end; +} + +.openaiProviderTitle { + font-weight: 700; + color: var(--text-primary); +} + +// 排序控件 +.sortControls { + display: flex; + align-items: center; + gap: $spacing-xs; +} + +.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; + + &: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; + } +} + +// 排序方向按钮 +.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); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.15s ease; + + &: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; + } +} + + +// 卡片头部操作区 +.cardHeaderActions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $spacing-sm; +} + +.openaiToolbarAnchorHidden { + visibility: hidden; + pointer-events: none; +} + +.openaiFloatingToolbar { + position: fixed; + z-index: 20; + background: var(--bg-primary); + box-shadow: none; + overflow: visible; + padding: 0; +} + +.openaiFloatingToolbar :global(.card-header) { + margin-bottom: 0; + padding: $spacing-sm $spacing-lg; + border-radius: 0; +} + + +// 模型多选下拉框 +.modelMultiSelectWrapper { + position: relative; +} + +.modelSelectedTags { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + padding: 4px 8px; + border: 1px solid var(--border-primary); + border-radius: 6px; + background: var(--bg-secondary); + cursor: pointer; + transition: all 0.15s ease; + min-height: 32px; + + &:hover { + border-color: var(--primary-color); + background: var(--bg-tertiary); + } +} + +.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; + + &:hover { + background: var(--bg-tertiary); + } +} + +.modelSelectArrow { + font-size: 10px; + color: var(--text-tertiary); + margin-left: 4px; +} + +.modelDropdownList { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 280px; + max-height: 300px; + overflow-y: auto; + 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); + z-index: 1000; +} + +.modelDropdownListAbove { + top: auto; + bottom: calc(100% + 4px); +} + +.modelDropdownHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-xs; + padding: $spacing-xs $spacing-sm; + border-bottom: 1px solid var(--border-primary); + background: var(--bg-tertiary); +} + +.modelDropdownSelectAll, +.modelDropdownClear { + padding: 4px 8px; + font-size: 12px; + background: transparent; + border: none; + color: var(--primary-color); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; + + &: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); + + &:hover { + background: rgba(59, 130, 246, 0.22); + border-color: rgba(59, 130, 246, 0.32); + color: var(--primary-color); + } +} + +.modelDropdownItem { + display: flex; + align-items: center; + gap: $spacing-sm; + padding: $spacing-xs $spacing-sm; + cursor: pointer; + transition: background 0.15s ease; + + &:hover { + background: var(--bg-secondary); + } + + input[type="checkbox"] { + margin: 0; + cursor: pointer; + } + + span { + flex: 1; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + + // 成功失败次数统计样式 .cardStats { display: flex; @@ -173,6 +471,7 @@ display: inline-flex; align-items: center; gap: 4px; + flex-wrap: wrap; background: var(--bg-quinary, #f8f9fa); color: var(--text-secondary); border: 1px solid var(--border-secondary); @@ -190,11 +489,13 @@ .modelName { font-weight: 600; color: var(--text-primary); + overflow-wrap: anywhere; } .modelAlias { color: var(--text-tertiary); font-style: italic; + overflow-wrap: anywhere; &::before { content: '→ '; @@ -1045,3 +1346,151 @@ color: #f1b0a6; } } + +// ============================================ +// Model Search Page Styles +// ============================================ + +.modelSearchContainer { + width: 100%; + max-width: 1400px; + margin: 0 auto; +} + +.modelSearchContent { + display: flex; + flex-direction: column; + gap: $spacing-xl; + padding-bottom: calc( + var(--provider-nav-height, 60px) + 12px + env(safe-area-inset-bottom) + #{$spacing-md} + ); +} + +.modelSearchToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + flex-wrap: wrap; + margin: $spacing-sm 0; +} + +.modelSearchToolbarActions { + display: flex; + align-items: center; + gap: $spacing-xs; + flex-wrap: wrap; +} + +.modelSearchSelectionSummary { + font-size: 13px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.modelSearchList { + display: flex; + flex-direction: column; + gap: $spacing-lg; + margin-top: $spacing-md; +} + +.modelSearchGroup { + border: 1px solid var(--border-primary); + border-radius: $radius-md; + overflow: hidden; + background: var(--bg-secondary); +} + +.modelSearchGroupHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + padding: $spacing-md; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + + h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } +} + +.modelSearchGroupCount { + font-size: 13px; + color: var(--text-tertiary); + font-weight: 500; +} + +.modelSearchGroupList { + display: flex; + flex-direction: column; + gap: $spacing-xs; + padding: $spacing-sm; +} + +.modelSearchRow { + display: flex; + align-items: flex-start; + gap: $spacing-sm; + width: 100%; + padding: $spacing-sm $spacing-md; + border: 1px solid transparent; + border-radius: $radius-sm; + background: var(--bg-primary); + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; + + input[type='checkbox'] { + margin-top: 2px; + cursor: pointer; + } + + &:hover { + border-color: var(--primary-color); + background: var(--bg-secondary); + } +} + +.modelSearchRowSelected { + border-color: var(--primary-color); + background: var(--bg-tertiary); +} + +.modelSearchSelectionLabel { + flex: 1; + min-width: 0; +} + +.modelSearchMeta { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modelSearchName { + font-weight: 600; + color: var(--text-primary); + word-break: break-all; +} + +.modelSearchAlias { + margin-left: 6px; + color: var(--text-tertiary); + font-style: italic; + + &::before { + content: '→ '; + } +} + +.modelSearchDesc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} diff --git a/src/styles/layout.scss b/src/styles/layout.scss index 7c38ab0..c2f3494 100644 --- a/src/styles/layout.scss +++ b/src/styles/layout.scss @@ -31,7 +31,7 @@ border-bottom: 1px solid var(--border-color); position: sticky; top: 0; - z-index: 10; + z-index: $z-dropdown + 1; width: 100%; @media (max-width: $breakpoint-mobile) {