mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(openai): add provider sorting and model filters
This commit is contained in:
@@ -89,8 +89,8 @@ export function ClaudeSection({
|
||||
keyField={(item) => item.apiKey}
|
||||
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) => (
|
||||
|
||||
@@ -89,8 +89,8 @@ export function CodexSection({
|
||||
keyField={(item) => item.apiKey}
|
||||
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) => (
|
||||
|
||||
@@ -89,8 +89,8 @@ export function GeminiSection({
|
||||
keyField={(item) => item.apiKey}
|
||||
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) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -18,6 +18,10 @@ import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
|
||||
type SortOption = 'name' | 'priority' | 'recent-success';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type ToolbarPosition = 'top' | 'bottom';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
@@ -45,6 +49,38 @@ export function OpenAISection({
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const [sortOption, setSortOption] = useState<SortOption>('priority');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
const [activeDropdown, setActiveDropdown] = useState<ToolbarPosition | null>(null);
|
||||
const topDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const bottomDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTop = topDropdownRef.current?.contains(target);
|
||||
const clickedBottom = bottomDropdownRef.current?.contains(target);
|
||||
|
||||
if (!clickedTop && !clickedBottom) {
|
||||
setActiveDropdown(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const allModelNames = useMemo(() => {
|
||||
const modelSet = new Set<string>();
|
||||
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<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
@@ -65,6 +101,171 @@ export function OpenAISection({
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
|
||||
const sortedConfigs = useMemo(() => {
|
||||
const filtered = configs.filter((provider) => {
|
||||
if (selectedModels.size === 0) return true;
|
||||
return provider.models?.some((model) => selectedModels.has(model.name));
|
||||
});
|
||||
|
||||
const sorted = [...filtered];
|
||||
const direction = sortDirection === 'desc' ? -1 : 1;
|
||||
|
||||
switch (sortOption) {
|
||||
case 'name':
|
||||
sorted.sort((a, b) => direction * a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'priority':
|
||||
sorted.sort((a, b) => {
|
||||
const priorityA = a.priority ?? Number.MAX_SAFE_INTEGER;
|
||||
const priorityB = b.priority ?? Number.MAX_SAFE_INTEGER;
|
||||
return direction * (priorityA - priorityB) || a.name.localeCompare(b.name);
|
||||
});
|
||||
break;
|
||||
case 'recent-success':
|
||||
sorted.sort((a, b) => {
|
||||
const statsA = getOpenAIProviderStats(a.apiKeyEntries, keyStats, a.prefix);
|
||||
const statsB = getOpenAIProviderStats(b.apiKeyEntries, keyStats, b.prefix);
|
||||
return direction * (statsA.success - statsB.success) || a.name.localeCompare(b.name);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [configs, sortOption, sortDirection, keyStats, selectedModels]);
|
||||
|
||||
const getProviderKey = (item: OpenAIProviderConfig) => `${item.name}-${item.prefix ?? ''}-${item.baseUrl}`;
|
||||
|
||||
const getProviderIndex = (item: OpenAIProviderConfig) =>
|
||||
configs.findIndex((config) => config === item);
|
||||
|
||||
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 toggleSortDirection = () => {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
};
|
||||
|
||||
const toggleDropdown = (position: ToolbarPosition) => {
|
||||
setActiveDropdown((prev) => (prev === position ? null : position));
|
||||
};
|
||||
|
||||
const renderSortControls = () => (
|
||||
<div className={styles.sortControls}>
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={(e) => setSortOption(e.target.value as SortOption)}
|
||||
className={styles.sortSelect}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
<option value="priority">{t('ai_providers.sort_by_priority')}</option>
|
||||
<option value="name">{t('ai_providers.sort_by_name')}</option>
|
||||
<option value="recent-success">{t('ai_providers.sort_by_recent_success')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSortDirection}
|
||||
className={styles.sortDirectionButton}
|
||||
disabled={actionsDisabled}
|
||||
title={sortDirection === 'asc' ? t('common.sort_ascending') : t('common.sort_descending')}
|
||||
>
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderToolbar = (position: ToolbarPosition) => {
|
||||
const isDropdownOpen = activeDropdown === position;
|
||||
const dropdownRef = position === 'top' ? topDropdownRef : bottomDropdownRef;
|
||||
const wrapperClassName =
|
||||
position === 'top' ? styles.cardHeaderActions : `${styles.cardHeaderActions} ${styles.cardFooterActions}`;
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className={styles.modelMultiSelectWrapper} ref={dropdownRef}>
|
||||
<div className={styles.modelSelectedTags} onClick={() => toggleDropdown(position)}>
|
||||
{selectedModels.size === 0 ? (
|
||||
<span className={styles.modelSelectPlaceholder}>
|
||||
{t('ai_providers.model_search_placeholder')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{Array.from(selectedModels).map((name) => (
|
||||
<span key={`${position}-${name}`} className={styles.modelTag}>
|
||||
<span className={styles.modelTagName}>{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelTagRemove}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelSelection(name);
|
||||
}}
|
||||
title={t('ai_providers.model_search_clear')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<span className={styles.modelSelectArrow}>▼</span>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className={styles.modelDropdownList}>
|
||||
<div className={styles.modelDropdownHeader}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedModels(new Set(allModelNames))}
|
||||
className={styles.modelDropdownSelectAll}
|
||||
>
|
||||
{t('ai_providers.model_select_all')}
|
||||
</button>
|
||||
{selectedModels.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllModels}
|
||||
className={styles.modelDropdownClear}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{allModelNames.map((name) => (
|
||||
<label key={`${position}-option-${name}`} className={styles.modelDropdownItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModels.has(name)}
|
||||
onChange={() => toggleModelSelection(name)}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSortControls()}
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -78,20 +279,26 @@ export function OpenAISection({
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
}
|
||||
extra={renderToolbar('top')}
|
||||
>
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
items={sortedConfigs}
|
||||
loading={loading}
|
||||
keyField={(_, index) => `openai-provider-${index}`}
|
||||
keyField={(item) => getProviderKey(item)}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(item) => {
|
||||
const index = getProviderIndex(item);
|
||||
if (index >= 0) {
|
||||
onEdit(index);
|
||||
}
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
const index = getProviderIndex(item);
|
||||
if (index >= 0) {
|
||||
onDelete(index);
|
||||
}
|
||||
}}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
@@ -195,6 +402,7 @@ export function OpenAISection({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.providerToolbarFooter}>{renderToolbar('bottom')}</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ interface ProviderListProps<T> {
|
||||
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;
|
||||
@@ -57,7 +57,7 @@ export function ProviderList<T>({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(index)}
|
||||
onClick={() => onEdit(item, index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
@@ -65,7 +65,7 @@ export function ProviderList<T>({
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(index)}
|
||||
onClick={() => onDelete(item, index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{deleteLabel || t('common.delete')}
|
||||
|
||||
@@ -89,8 +89,8 @@ export function VertexSection({
|
||||
keyField={(item) => item.apiKey}
|
||||
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) => (
|
||||
|
||||
@@ -402,6 +402,25 @@
|
||||
"openai_add_button": "Add Provider",
|
||||
"openai_empty_title": "No OpenAI Compatible Providers",
|
||||
"openai_empty_desc": "Click the button above to add the first provider",
|
||||
"sort_by_name": "Sort by Name",
|
||||
"sort_by_priority": "Sort by Priority",
|
||||
"sort_by_recent_success": "Sort by Recent Success",
|
||||
"sort_ascending": "Ascending",
|
||||
"sort_descending": "Descending",
|
||||
"model_search.title": "Model Search",
|
||||
"model_search.search_card_title": "Search All Models",
|
||||
"model_search.refresh": "Refresh",
|
||||
"model_search.search_label": "Search Models",
|
||||
"model_search.search_placeholder": "Filter by name, alias, description, or provider",
|
||||
"model_search.select_all": "Select All",
|
||||
"model_search.clear_selection": "Clear",
|
||||
"model_search.selected_count": "{{count}} selected",
|
||||
"model_search.loading": "Fetching models from all providers...",
|
||||
"model_search.empty_title": "No Models Found",
|
||||
"model_search.empty_desc": "No models could be fetched from the configured providers",
|
||||
"model_search.no_results": "No models match your search. Try a different keyword.",
|
||||
"model_search.fetch_failed": "Failed to fetch models",
|
||||
"model_search.models_count": "{{count}} models",
|
||||
"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 +474,11 @@
|
||||
"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",
|
||||
"model_selected_count": "{{count}} selected"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Auth Files Management",
|
||||
|
||||
@@ -402,6 +402,11 @@
|
||||
"openai_add_button": "Добавить провайдера",
|
||||
"openai_empty_title": "Провайдеры OpenAI отсутствуют",
|
||||
"openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера",
|
||||
"sort_by_name": "Сортировать по имени",
|
||||
"sort_by_priority": "Сортировать по приоритету",
|
||||
"sort_by_recent_success": "Сортировать по недавним успехам",
|
||||
"sort_ascending": "По возрастанию",
|
||||
"sort_descending": "По убыванию",
|
||||
"openai_add_modal_title": "Добавление совместимого с OpenAI провайдера",
|
||||
"openai_add_modal_name_label": "Имя провайдера:",
|
||||
"openai_add_modal_name_placeholder": "например: openrouter",
|
||||
@@ -455,7 +460,11 @@
|
||||
"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": "Выбрать все",
|
||||
"model_selected_count": "Выбрано: {{count}}"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Управление файлами авторизации",
|
||||
|
||||
@@ -402,6 +402,25 @@
|
||||
"openai_add_button": "添加提供商",
|
||||
"openai_empty_title": "暂无OpenAI兼容提供商",
|
||||
"openai_empty_desc": "点击上方按钮添加第一个提供商",
|
||||
"sort_by_name": "按名称排序",
|
||||
"sort_by_priority": "按优先级排序",
|
||||
"sort_by_recent_success": "按最近成功数排序",
|
||||
"sort_ascending": "正序",
|
||||
"sort_descending": "倒序",
|
||||
"model_search.title": "模型搜索",
|
||||
"model_search.search_card_title": "搜索所有模型",
|
||||
"model_search.refresh": "刷新",
|
||||
"model_search.search_label": "搜索模型",
|
||||
"model_search.search_placeholder": "按名称、别名、描述或提供商筛选",
|
||||
"model_search.select_all": "全选",
|
||||
"model_search.clear_selection": "清除",
|
||||
"model_search.selected_count": "已选择 {{count}} 个",
|
||||
"model_search.loading": "正在从所有提供商获取模型...",
|
||||
"model_search.empty_title": "未找到模型",
|
||||
"model_search.empty_desc": "无法从配置的提供商获取模型",
|
||||
"model_search.no_results": "没有匹配的模型,请尝试其他关键词。",
|
||||
"model_search.fetch_failed": "获取模型失败",
|
||||
"model_search.models_count": "{{count}} 个模型",
|
||||
"openai_add_modal_title": "添加OpenAI兼容提供商",
|
||||
"openai_add_modal_name_label": "提供商名称:",
|
||||
"openai_add_modal_name_placeholder": "例如: openrouter",
|
||||
@@ -455,7 +474,11 @@
|
||||
"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": "全选",
|
||||
"model_selected_count": "已选择 {{count}} 个"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "认证文件管理",
|
||||
|
||||
@@ -57,11 +57,249 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 排序控件
|
||||
.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;
|
||||
}
|
||||
|
||||
.cardFooterActions {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.providerToolbarFooter {
|
||||
margin-top: $spacing-md;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
// 模型多选下拉框
|
||||
.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;
|
||||
}
|
||||
|
||||
.modelTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 4px 8px 4px 8px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
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: rgba(0, 0, 0, 0.15);
|
||||
color: #000;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -1045,3 +1283,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;
|
||||
}
|
||||
|
||||
@@ -642,9 +642,17 @@ textarea {
|
||||
}
|
||||
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
gap: $spacing-sm;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.item-row {
|
||||
@@ -653,15 +661,16 @@ textarea {
|
||||
padding: $spacing-md;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $spacing-sm;
|
||||
min-height: 0;
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
@@ -679,6 +688,8 @@ textarea {
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user