mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(OpenAISection): enhance sorting and filtering UI components
This commit is contained in:
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
@@ -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<IndexedOpenAIProvider[]>(() => {
|
||||
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 = () => (
|
||||
<div className={styles.sortControls}>
|
||||
<select
|
||||
<Select
|
||||
value={sortOption}
|
||||
onChange={(e) => handleSortOptionChange(e.target.value as SortOption)}
|
||||
options={sortOptions}
|
||||
onChange={(value) => handleSortOptionChange(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"
|
||||
ariaLabel={t('ai_providers.sort_by_priority')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={toggleSortDirection}
|
||||
className={styles.sortDirectionButton}
|
||||
disabled={actionsDisabled}
|
||||
title={sortDirection === 'asc' ? t('ai_providers.sort_ascending') : t('ai_providers.sort_descending')}
|
||||
title={
|
||||
sortDirection === 'asc'
|
||||
? t('ai_providers.sort_ascending')
|
||||
: t('ai_providers.sort_descending')
|
||||
}
|
||||
aria-label={
|
||||
sortDirection === 'asc'
|
||||
? t('ai_providers.sort_ascending')
|
||||
: t('ai_providers.sort_descending')
|
||||
}
|
||||
>
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
<span className={styles.sortDirectionIcon}>
|
||||
{sortDirection === 'asc' ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />}
|
||||
</span>
|
||||
<span>
|
||||
{sortDirection === 'asc'
|
||||
? t('ai_providers.sort_asc_short')
|
||||
: t('ai_providers.sort_desc_short')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className={styles.cardHeaderActions}>
|
||||
@@ -360,97 +402,110 @@ export function OpenAISection({
|
||||
className={styles.modelMultiSelectWrapper}
|
||||
ref={isFloating ? floatingDropdownRef : topDropdownRef}
|
||||
>
|
||||
<div className={styles.modelSelectedTags}>
|
||||
{selectedModels.size === 0 ? (
|
||||
<div
|
||||
className={[
|
||||
styles.modelFilterControl,
|
||||
modelFilterActive ? styles.modelFilterControlActive : '',
|
||||
actionsDisabled ? styles.modelFilterControlDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelFilterTrigger}
|
||||
onClick={toggleDropdown}
|
||||
disabled={actionsDisabled}
|
||||
title={modelFilterTitle}
|
||||
aria-label={modelFilterTitle}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isActiveToolbar && isDropdownOpen}
|
||||
>
|
||||
<span className={styles.modelFilterIcon} aria-hidden="true">
|
||||
<IconSlidersHorizontal size={14} />
|
||||
</span>
|
||||
<span className={styles.modelFilterText}>{modelFilterLabel}</span>
|
||||
{modelFilterActive && (
|
||||
<span className={styles.modelFilterCount}>{selectedModelNames.length}</span>
|
||||
)}
|
||||
<span className={styles.modelFilterChevron} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{modelFilterActive && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelSelectButton}
|
||||
onClick={toggleDropdown}
|
||||
className={styles.modelFilterInlineClear}
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
aria-label={t('ai_providers.model_search_clear')}
|
||||
title={t('ai_providers.model_search_clear')}
|
||||
>
|
||||
<span className={styles.modelSelectPlaceholder}>
|
||||
{t('ai_providers.model_search_placeholder')}
|
||||
</span>
|
||||
<span className={styles.modelSelectArrow}>▼</span>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{Array.from(selectedModels).map((name) => (
|
||||
<span key={`top-${name}`} className={styles.modelFilterTag}>
|
||||
<span className={styles.modelTagName}>{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelTagRemove}
|
||||
disabled={actionsDisabled}
|
||||
aria-label={`${t('common.delete')} ${name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelSelection(name);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelSelectArrowButton}
|
||||
onClick={toggleDropdown}
|
||||
disabled={actionsDisabled}
|
||||
aria-label={t('ai_providers.model_search_placeholder')}
|
||||
>
|
||||
<span className={styles.modelSelectArrow}>▼</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActiveToolbar && isDropdownOpen && (
|
||||
<div className={dropdownClassName} style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}>
|
||||
<div
|
||||
className={dropdownClassName}
|
||||
style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}
|
||||
>
|
||||
<div className={styles.modelDropdownHeader}>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedModels(new Set(allModelNames))}
|
||||
className={styles.modelDropdownSelectAll}
|
||||
disabled={actionsDisabled || allModelNames.length === 0}
|
||||
>
|
||||
{t('ai_providers.model_select_all')}
|
||||
</button>
|
||||
{selectedModels.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
{modelFilterActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
className={styles.modelDropdownClear}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.modelDropdownItems}
|
||||
role="group"
|
||||
aria-label={t('ai_providers.model_search_placeholder')}
|
||||
>
|
||||
{allModelNames.length === 0 ? (
|
||||
<div className={styles.modelDropdownEmpty}>
|
||||
{t('ai_providers.model_filter_empty')}
|
||||
</div>
|
||||
) : (
|
||||
allModelNames.map((name) => (
|
||||
<SelectionCheckbox
|
||||
key={`top-option-${name}`}
|
||||
checked={selectedModels.has(name)}
|
||||
onChange={() => toggleModelSelection(name)}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.modelDropdownItem}
|
||||
labelClassName={styles.modelDropdownItemLabel}
|
||||
label={<span title={name}>{name}</span>}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{allModelNames.map((name) => (
|
||||
<label key={`top-option-${name}`} className={styles.modelDropdownItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModels.has(name)}
|
||||
onChange={() => toggleModelSelection(name)}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedModels.size > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.modelFilterClearButton}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</Button>
|
||||
)}
|
||||
{renderSortControls()}
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.openaiAddButton}
|
||||
>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -525,12 +581,18 @@ export function OpenAISection({
|
||||
>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && <span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>}
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
@@ -573,10 +635,20 @@ export function OpenAISection({
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</div>
|
||||
<div className={styles.openaiProviderActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => onEdit(originalIndex)} disabled={actionsDisabled}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(originalIndex)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => onDelete(originalIndex)} disabled={actionsDisabled}>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(originalIndex)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -604,11 +676,16 @@ export function OpenAISection({
|
||||
<EmptyState
|
||||
title={t('ai_providers.openai_filtered_empty_title')}
|
||||
description={t('ai_providers.openai_filtered_empty_desc')}
|
||||
action={(
|
||||
<Button variant="secondary" size="sm" onClick={clearAllModels} disabled={actionsDisabled}>
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</Button>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
) : sortedConfigs.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -622,21 +699,21 @@ export function OpenAISection({
|
||||
</div>
|
||||
{typeof document !== 'undefined' && shouldRenderFloatingToolbar
|
||||
? createPortal(
|
||||
<div
|
||||
className={`card ${styles.openaiFloatingToolbar}`}
|
||||
style={{
|
||||
left: `${floatingToolbarStyle.left}px`,
|
||||
top: `${floatingToolbarStyle.top}px`,
|
||||
width: `${floatingToolbarStyle.width}px`,
|
||||
}}
|
||||
>
|
||||
<div className="card-header">
|
||||
<div className="title">{renderStaticTitle()}</div>
|
||||
{renderToolbar(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
<div
|
||||
className={`card ${styles.openaiFloatingToolbar}`}
|
||||
style={{
|
||||
left: `${floatingToolbarStyle.left}px`,
|
||||
top: `${floatingToolbarStyle.top}px`,
|
||||
width: `${floatingToolbarStyle.width}px`,
|
||||
}}
|
||||
>
|
||||
<div className="card-header">
|
||||
<div className="title">{renderStaticTitle()}</div>
|
||||
{renderToolbar(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Управление файлами авторизации",
|
||||
|
||||
@@ -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": "认证文件管理",
|
||||
|
||||
@@ -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": "驗證檔案管理",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user