Merge pull request #212 from liucong2013/feat/openai-provider-ui-enhancements

增强AI提供商列表筛选、排序与布局
This commit is contained in:
Supra4E8C
2026-04-24 23:11:14 +08:00
committed by GitHub
Unverified
14 changed files with 1080 additions and 164 deletions
+4 -1
View File
@@ -391,7 +391,10 @@ export function PageTransition({
}
>
<PageTransitionLayerContext.Provider
value={PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status]}
value={{
...PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status],
isAnimating,
}}
>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
+4 -3
View File
@@ -5,15 +5,16 @@ export type LayerStatus = 'current' | 'exiting' | 'stacked';
export type PageTransitionLayerContextValue = {
status: LayerStatus;
isCurrentLayer: boolean;
isAnimating: boolean;
};
export const PageTransitionLayerContext =
createContext<PageTransitionLayerContextValue | null>(null);
export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<LayerStatus, PageTransitionLayerContextValue> = {
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() {
@@ -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) => (
@@ -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) => (
@@ -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) => (
@@ -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<SortOption>('priority');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 });
const [floatingToolbarStyle, setFloatingToolbarStyle] = useState<FloatingToolbarStyle>({
left: 0,
top: 0,
width: 0,
visible: false,
});
const sectionRef = useRef<HTMLDivElement>(null);
const topToolbarAnchorRef = useRef<HTMLDivElement>(null);
const topDropdownRef = useRef<HTMLDivElement>(null);
const floatingDropdownRef = useRef<HTMLDivElement>(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<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>>();
@@ -70,144 +236,395 @@ export function OpenAISection({
return cache;
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
const sortedConfigs = useMemo<IndexedOpenAIProvider[]>(() => {
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 = () => (
<div className={styles.sortControls}>
<select
value={sortOption}
onChange={(e) => handleSortOptionChange(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('ai_providers.sort_ascending') : t('ai_providers.sort_descending')}
>
{sortDirection === 'asc' ? '↑' : '↓'}
</button>
</div>
);
const renderToolbar = (isFloating = false) => {
const isActiveToolbar = isFloating === shouldRenderFloatingToolbar;
const dropdownClassName =
dropdownLayout.openAbove ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` : styles.modelDropdownList;
return (
<div className={styles.cardHeaderActions}>
<div
className={styles.modelMultiSelectWrapper}
ref={isFloating ? floatingDropdownRef : topDropdownRef}
>
<button
type="button"
className={styles.modelSelectedTags}
onClick={toggleDropdown}
disabled={actionsDisabled}
>
{selectedModels.size === 0 ? (
<span className={styles.modelSelectPlaceholder}>
{t('ai_providers.model_search_placeholder')}
</span>
) : (
<>
{Array.from(selectedModels).map((name) => (
<span key={`top-${name}`} className={styles.modelFilterTag}>
<span className={styles.modelTagName}>{name}</span>
<span
role="button"
tabIndex={0}
className={styles.modelTagRemove}
onClick={(e) => {
e.stopPropagation();
toggleModelSelection(name);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
toggleModelSelection(name);
}
}}
>
×
</span>
</span>
))}
</>
)}
<span className={styles.modelSelectArrow}></span>
</button>
{isActiveToolbar && isDropdownOpen && (
<div className={dropdownClassName} style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}>
<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={`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}>
{t('ai_providers.openai_add_button')}
</Button>
</div>
);
};
const renderStaticTitle = () => (
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')}
</span>
);
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 (
<div
key={`openai-provider-${originalIndex}`}
className={styles.openaiProviderCard}
style={actionsDisabled ? { opacity: 0.6 } : undefined}
>
<div className={styles.openaiProviderMeta}>
<div className={styles.openaiProviderTitle}>{provider.name}</div>
{provider.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{provider.priority}</span>
</div>
)}
{provider.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{provider.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{provider.baseUrl}</span>
</div>
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
keyStats
);
return (
<div
key={`${entry.authIndex ?? 'none'}-${entry.apiKey}-${entryIndex}`}
className={styles.apiKeyEntryCard}
>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && <span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>}
<div className={styles.apiKeyEntryStats}>
<span className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}>
<IconCheck size={12} /> {entryStats.success}
</span>
<span className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{provider.models?.length || 0}</span>
</div>
{provider.models?.length ? (
<div className={styles.modelTagList}>
{provider.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{provider.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_test_model')}:</span>
<span className={styles.fieldValue}>{provider.testModel}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</div>
<div className={styles.openaiProviderActions}>
<Button variant="secondary" size="sm" onClick={() => onEdit(originalIndex)} disabled={actionsDisabled}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(originalIndex)} disabled={actionsDisabled}>
{t('common.delete')}
</Button>
</div>
</div>
);
};
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
<div ref={sectionRef}>
<Card
title={renderStaticTitle()}
extra={
<div
ref={topToolbarAnchorRef}
className={shouldRenderFloatingToolbar ? styles.openaiToolbarAnchorHidden : undefined}
>
{renderToolbar(false)}
</div>
}
>
{loading && sortedConfigs.length === 0 ? (
<div className="hint">{t('common.loading')}</div>
) : configs.length > 0 && sortedConfigs.length === 0 ? (
<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}>
{t('ai_providers.model_search_clear')}
</Button>
)}
/>
{t('ai_providers.openai_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
}
>
<ProviderList<OpenAIProviderConfig>
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 (
<Fragment>
<div className="item-title">{item.name}</div>
{item.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{item.priority}</span>
</div>
)}
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
keyStats
);
return (
<div
key={getOpenAIEntryKey(entry, entryIndex)}
className={styles.apiKeyEntryCard}
>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
)}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
</div>
{item.models?.length ? (
<div className={styles.modelTagList}>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{item.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Test Model:</span>
<span className={styles.fieldValue}>{item.testModel}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
) : sortedConfigs.length === 0 ? (
<EmptyState
title={t('ai_providers.openai_empty_title')}
description={t('ai_providers.openai_empty_desc')}
/>
) : (
<div className={styles.openaiProviderList}>{sortedConfigs.map(renderProviderCard)}</div>
)}
</Card>
</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
)
: null}
</>
);
}
+16 -8
View File
@@ -8,14 +8,18 @@ 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;
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<T>({
@@ -31,6 +35,10 @@ export function ProviderList<T>({
actionsDisabled = false,
getRowDisabled,
renderExtraActions,
listClassName,
rowClassName,
metaClassName,
actionsClassName,
}: ProviderListProps<T>) {
const { t } = useTranslation();
@@ -43,21 +51,21 @@ export function ProviderList<T>({
}
return (
<div className="item-list">
<div className={listClassName ?? 'item-list'}>
{items.map((item, index) => {
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item, index)}
className="item-row"
className={rowClassName ?? 'item-row'}
style={rowDisabled ? { opacity: 0.6 } : undefined}
>
<div className="item-meta">{renderContent(item, index)}</div>
<div className="item-actions">
<div className={metaClassName ?? 'item-meta'}>{renderContent(item, index)}</div>
<div className={actionsClassName ?? 'item-actions'}>
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(index)}
onClick={() => onEdit(item, index)}
disabled={actionsDisabled}
>
{t('common.edit')}
@@ -65,7 +73,7 @@ export function ProviderList<T>({
<Button
variant="danger"
size="sm"
onClick={() => onDelete(index)}
onClick={() => onDelete(item, index)}
disabled={actionsDisabled}
>
{deleteLabel || t('common.delete')}
@@ -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) => (
+12 -1
View File
@@ -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",
+9 -1
View File
@@ -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": "Управление файлами авторизации",
+12 -1
View File
@@ -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": "认证文件管理",
+8
View File
@@ -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",
+450 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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) {