diff --git a/src/features/providers/ProvidersWorkbenchPage.tsx b/src/features/providers/ProvidersWorkbenchPage.tsx index aec5742..f8d704a 100644 --- a/src/features/providers/ProvidersWorkbenchPage.tsx +++ b/src/features/providers/ProvidersWorkbenchPage.tsx @@ -4,9 +4,17 @@ import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer' import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { Skeleton } from '@/components/ui/Skeleton'; import { useAuthStore, useNotificationStore } from '@/stores'; +import { useProviderRecentRequests } from '@/components/providers/hooks/useProviderRecentRequests'; +import { getOpenAIProviderRecentWindowStats } from '@/components/providers/utils'; +import type { OpenAIProviderConfig } from '@/types'; import { ProviderHeaderCard } from './components/ProviderHeaderCard'; import { ProviderCategoryList } from './components/ProviderCategoryList'; import { ProviderResourcePanel } from './components/ProviderResourcePanel'; +import type { OpenAIPanelControls } from './components/ProviderResourcePanel'; +import type { + OpenAISortBy, + SortDir, +} from './components/OpenAIBrandToolbar'; import { ProviderSheet } from './sheets/ProviderSheet'; import { useProviderWorkbench } from './useProviderWorkbench'; import type { ProviderBrand, ProviderResource } from './types'; @@ -62,6 +70,11 @@ export function ProvidersWorkbenchPage() { const workbench = useProviderWorkbench(); const [activeBrand, setActiveBrand] = useState('gemini'); const [filter, setFilter] = useState(''); + const [openaiSortBy, setOpenaiSortBy] = useState('name'); + const [openaiSortDir, setOpenaiSortDir] = useState('asc'); + const [openaiSelectedModels, setOpenaiSelectedModels] = useState>( + () => new Set() + ); const [sheetState, setSheetState] = useState({ open: false, brand: 'gemini', @@ -69,9 +82,17 @@ export function ProvidersWorkbenchPage() { resource: null, }); + const connected = connectionStatus === 'connected'; + const { usageByProvider, refreshRecentRequests } = useProviderRecentRequests({ + enabled: connected, + }); + const handleRefresh = useCallback(async () => { - await workbench.refetch(); - }, [workbench]); + await Promise.allSettled([ + workbench.refetch(), + refreshRecentRequests().catch(() => undefined), + ]); + }, [refreshRecentRequests, workbench]); useHeaderRefresh(handleRefresh, isCurrentLayer); @@ -87,6 +108,87 @@ export function ProvidersWorkbenchPage() { return activeGroup.resources.filter((r) => matchesFilter(r, normalized)); }, [activeGroup, filter]); + const isOpenAI = activeGroup?.id === 'openaiCompatibility'; + + const availableOpenaiModels = useMemo(() => { + if (!isOpenAI || !activeGroup) return []; + const seen = new Set(); + activeGroup.resources.forEach((r) => { + const cfg = r.raw as OpenAIProviderConfig; + cfg.models?.forEach((m) => { + const name = (m.name ?? '').trim(); + if (name) seen.add(name); + }); + }); + return Array.from(seen).sort(); + }, [activeGroup, isOpenAI]); + + const visibleResources = useMemo(() => { + if (!isOpenAI) return filteredResources; + + let arr = filteredResources; + if (openaiSelectedModels.size > 0) { + arr = arr.filter((r) => { + const cfg = r.raw as OpenAIProviderConfig; + return Boolean( + cfg.models?.some((m) => openaiSelectedModels.has((m.name ?? '').trim())) + ); + }); + } + + const sorted = [...arr].sort((a, b) => { + let diff = 0; + if (openaiSortBy === 'name') { + const an = (a.name ?? a.identifier ?? '').toLowerCase(); + const bn = (b.name ?? b.identifier ?? '').toLowerCase(); + diff = an.localeCompare(bn); + } else if (openaiSortBy === 'priority') { + const ap = (a.raw as OpenAIProviderConfig).priority ?? 0; + const bp = (b.raw as OpenAIProviderConfig).priority ?? 0; + diff = ap - bp; + } else { + const aStats = getOpenAIProviderRecentWindowStats( + a.raw as OpenAIProviderConfig, + usageByProvider + ); + const bStats = getOpenAIProviderRecentWindowStats( + b.raw as OpenAIProviderConfig, + usageByProvider + ); + diff = aStats.success - bStats.success; + } + return openaiSortDir === 'asc' ? diff : -diff; + }); + + return sorted; + }, [ + filteredResources, + isOpenAI, + openaiSelectedModels, + openaiSortBy, + openaiSortDir, + usageByProvider, + ]); + + const openaiControls = useMemo(() => { + if (!isOpenAI) return undefined; + return { + sortBy: openaiSortBy, + sortDir: openaiSortDir, + onSortBy: setOpenaiSortBy, + onSortDir: setOpenaiSortDir, + availableModels: availableOpenaiModels, + selectedModels: openaiSelectedModels, + onSelectedModelsChange: setOpenaiSelectedModels, + }; + }, [ + availableOpenaiModels, + isOpenAI, + openaiSelectedModels, + openaiSortBy, + openaiSortDir, + ]); + const totalResources = useMemo( () => groups.reduce( @@ -272,6 +374,7 @@ export function ProvidersWorkbenchPage() { onSelect={(brand) => { setActiveBrand(brand); setFilter(''); + setOpenaiSelectedModels(new Set()); // 关闭 Sheet 以避免数据错位 if (sheetState.open && sheetState.brand !== brand) { closeSheet(); @@ -282,9 +385,11 @@ export function ProvidersWorkbenchPage() { group={activeGroup} filter={filter} onFilterChange={setFilter} - filteredResources={filteredResources} + filteredResources={visibleResources} selectedId={sheetState.open ? sheetState.resource?.id ?? null : null} disableMutations={disableMutations} + usageByProvider={usageByProvider} + openaiControls={openaiControls} onView={openView} onEdit={openEdit} onDelete={handleDelete} diff --git a/src/features/providers/components/OpenAIBrandToolbar.module.scss b/src/features/providers/components/OpenAIBrandToolbar.module.scss new file mode 100644 index 0000000..6b4f2f3 --- /dev/null +++ b/src/features/providers/components/OpenAIBrandToolbar.module.scss @@ -0,0 +1,146 @@ +@use '../../../styles/mixins' as *; +@use '../../../styles/variables' as *; + +.root { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.sortGroup { + display: flex; + align-items: center; + gap: 6px; +} + +.label { + font-size: 11px; + color: var(--muted-foreground); + white-space: nowrap; +} + +.dirBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + transition: background-color $transition-fast, color $transition-fast; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } +} + +.filterGroup { + position: relative; +} + +.filterTrigger { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + transition: background-color $transition-fast, border-color $transition-fast; + + &:hover:not(:disabled) { + border-color: var(--primary-color); + color: var(--primary-color); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.filterPanel { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + max-width: 320px; + z-index: $z-dropdown; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.filterToolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.filterToolbarBtn { + padding: 2px 8px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--primary-color); + color: var(--primary-color); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.filterEmpty { + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--muted-foreground); +} + +.filterList { + list-style: none; + margin: 0; + padding: 0; + max-height: 220px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.filterItem { + padding: 4px 6px; + border-radius: var(--radius-sm); + + &:hover { + background: var(--bg-tertiary); + } +} + +.filterItemLabel { + font-family: $font-mono; + font-size: 12px; + word-break: break-all; +} diff --git a/src/features/providers/components/OpenAIBrandToolbar.tsx b/src/features/providers/components/OpenAIBrandToolbar.tsx new file mode 100644 index 0000000..6e9199e --- /dev/null +++ b/src/features/providers/components/OpenAIBrandToolbar.tsx @@ -0,0 +1,168 @@ +import { useMemo, useRef, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + IconChevronDown, + IconChevronUp, + IconSlidersHorizontal, +} from '@/components/ui/icons'; +import { Select } from '@/components/ui/Select'; +import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox'; +import styles from './OpenAIBrandToolbar.module.scss'; + +export type OpenAISortBy = 'name' | 'priority' | 'recent-success'; +export type SortDir = 'asc' | 'desc'; + +interface OpenAIBrandToolbarProps { + sortBy: OpenAISortBy; + sortDir: SortDir; + onSortBy: (value: OpenAISortBy) => void; + onSortDir: (value: SortDir) => void; + availableModels: ReadonlyArray; + selectedModels: ReadonlySet; + onSelectedModelsChange: (next: Set) => void; +} + +export function OpenAIBrandToolbar({ + sortBy, + sortDir, + onSortBy, + onSortDir, + availableModels, + selectedModels, + onSelectedModelsChange, +}: OpenAIBrandToolbarProps) { + const { t } = useTranslation(); + const [filterOpen, setFilterOpen] = useState(false); + const containerRef = useRef(null); + + const sortOptions = useMemo( + () => [ + { value: 'name', label: t('providersPage.toolbar.sort.name') }, + { value: 'priority', label: t('providersPage.toolbar.sort.priority') }, + { + value: 'recent-success', + label: t('providersPage.toolbar.sort.recentSuccess'), + }, + ], + [t] + ); + + useEffect(() => { + if (!filterOpen) return; + const onClickOutside = (e: PointerEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setFilterOpen(false); + } + }; + document.addEventListener('pointerdown', onClickOutside); + return () => document.removeEventListener('pointerdown', onClickOutside); + }, [filterOpen]); + + const toggleModel = (name: string) => { + const next = new Set(selectedModels); + if (next.has(name)) next.delete(name); + else next.add(name); + onSelectedModelsChange(next); + }; + + const selectAll = () => onSelectedModelsChange(new Set(availableModels)); + const clearAll = () => onSelectedModelsChange(new Set()); + + const filterLabel = + selectedModels.size === 0 + ? t('providersPage.toolbar.filter.allModels') + : t('providersPage.toolbar.filter.selectedModels', { + selected: selectedModels.size, + total: availableModels.length, + }); + + return ( +
+
+ {t('providersPage.toolbar.sortBy')} + onFilterChange(event.target.value)} + placeholder={t('providersPage.table.filterPlaceholder')} + /> +
+ ) : null}
- {group.id !== 'ampcode' ? ( -
- - onFilterChange(event.target.value)} - placeholder={t('providersPage.table.filterPlaceholder')} + {openaiControls ? ( +
+
) : null} @@ -127,6 +162,7 @@ export function ProviderResourcePanel({ resources={filteredResources} selectedId={selectedId} disableMutations={disableMutations} + usageByProvider={usageByProvider} onView={onView} onEdit={onEdit} onDelete={onDelete} diff --git a/src/features/providers/components/ProviderResourceTable.module.scss b/src/features/providers/components/ProviderResourceTable.module.scss index 26f36fc..1e34005 100644 --- a/src/features/providers/components/ProviderResourceTable.module.scss +++ b/src/features/providers/components/ProviderResourceTable.module.scss @@ -104,6 +104,14 @@ white-space: nowrap; } +.statusCell { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; + min-width: 0; +} + .statusActive { border-color: var(--primary-30); background: var(--primary-10); diff --git a/src/features/providers/components/ProviderResourceTable.tsx b/src/features/providers/components/ProviderResourceTable.tsx index 6d96d3e..1ef2ca4 100644 --- a/src/features/providers/components/ProviderResourceTable.tsx +++ b/src/features/providers/components/ProviderResourceTable.tsx @@ -16,25 +16,54 @@ import { TableRow, } from '@/components/ui/Table'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar'; +import { + getOpenAIProviderRecentStatusData, + getProviderRecentStatusData, + type ProviderRecentUsageMap, +} from '@/components/providers/utils'; +import type { OpenAIProviderConfig } from '@/types'; +import type { StatusBarData } from '@/utils/recentRequests'; import type { ProviderResource } from '../types'; import styles from './ProviderResourceTable.module.scss'; +import statusBarStyles from './providerStatusBar.module.scss'; interface ProviderResourceTableProps { resources: ProviderResource[]; selectedId?: string | null; disableMutations?: boolean; + usageByProvider?: ProviderRecentUsageMap; onView: (resource: ProviderResource) => void; onEdit: (resource: ProviderResource) => void; onDelete: (resource: ProviderResource) => void; onToggleDisabled?: (resource: ProviderResource, disabled: boolean) => void; } -const columnWidths = ['20%', '22%', '8%', '22%', '8%', '20%']; +const columnWidths = ['18%', '18%', '6%', '14%', '24%', '20%']; + +const resolveStatusBarData = ( + resource: ProviderResource, + usageByProvider: ProviderRecentUsageMap +): StatusBarData => { + if (resource.brand === 'openaiCompatibility') { + return getOpenAIProviderRecentStatusData( + resource.raw as OpenAIProviderConfig, + usageByProvider + ); + } + return getProviderRecentStatusData( + usageByProvider, + resource.brand, + resource.apiKey ?? undefined, + resource.baseUrl ?? undefined + ); +}; export function ProviderResourceTable({ resources, selectedId, disableMutations, + usageByProvider, onView, onEdit, onDelete, @@ -191,7 +220,17 @@ export function ProviderResourceTable({ )} {renderModelsSummary(resource)} - {renderStatus(resource)} + +
+ {renderStatus(resource)} + {usageByProvider && resource.brand !== 'ampcode' ? ( + + ) : null} +
+
{!isAmpcode && onToggleDisabled ? ( diff --git a/src/features/providers/components/providerStatusBar.module.scss b/src/features/providers/components/providerStatusBar.module.scss new file mode 100644 index 0000000..cf95a75 --- /dev/null +++ b/src/features/providers/components/providerStatusBar.module.scss @@ -0,0 +1,157 @@ +@use '../../../styles/mixins' as *; +@use '../../../styles/variables' as *; + +.statusBar { + display: flex; + align-items: center; + gap: 6px; + max-width: 100%; +} + +.statusBlocks { + display: flex; + gap: 2px; + flex: 1; + min-width: 120px; + position: relative; +} + +.statusBlockWrapper { + flex: 1; + min-width: 4px; + position: relative; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.statusBlock { + width: 100%; + height: 6px; + border-radius: 2px; + transition: transform 0.15s ease, opacity 0.15s ease; + + .statusBlockWrapper:hover &, + .statusBlockWrapper.statusBlockActive & { + transform: scaleY(1.8); + opacity: 0.9; + } +} + +.statusBlockIdle { + background-color: var(--border-color); +} + +.statusTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: $z-dropdown; + pointer-events: none; + color: var(--text-primary); + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--bg-primary); + } + + &::before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--border-color); + } +} + +.statusTooltipLeft { + left: 0; + transform: translateX(0); + + &::after, + &::before { + left: 8px; + transform: none; + } +} + +.statusTooltipRight { + left: auto; + right: 0; + transform: translateX(0); + + &::after, + &::before { + left: auto; + right: 8px; + transform: none; + } +} + +.tooltipTime { + color: var(--text-secondary); + display: block; + margin-bottom: 2px; +} + +.tooltipStats { + display: flex; + align-items: center; + gap: 8px; +} + +.tooltipSuccess { + color: var(--success-color, #22c55e); +} + +.tooltipFailure { + color: var(--danger-color, #ef4444); +} + +.tooltipRate { + color: var(--text-secondary); + margin-left: 2px; +} + +.statusRate { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + padding: 1px 6px; + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.statusRateHigh { + color: var(--success-badge-text, #065f46); + background: var(--success-badge-bg, #d1fae5); +} + +.statusRateMedium { + color: var(--warning-text, #92400e); + background: var(--warning-bg, #fef3c7); +} + +.statusRateLow { + color: var(--failure-badge-text); + background: var(--failure-badge-bg); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 74bd281..1111aec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1625,6 +1625,23 @@ "close": "Close", "apply": "Apply ({{count}})" }, + "toolbar": { + "sortBy": "Sort by", + "sort": { + "name": "Name", + "priority": "Priority", + "recentSuccess": "Recent success", + "directionAsc": "Ascending", + "directionDesc": "Descending" + }, + "filter": { + "allModels": "All models", + "selectedModels": "{{selected}}/{{total}} models", + "selectAll": "Select all", + "clear": "Clear", + "empty": "No models configured yet" + } + }, "modelCatalog": { "summaryTitle": "Model catalog", "openAction": "Open catalog", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4b04ffe..c0ae063 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1622,6 +1622,23 @@ "close": "Закрыть", "apply": "Применить ({{count}})" }, + "toolbar": { + "sortBy": "Сортировка", + "sort": { + "name": "Имя", + "priority": "Приоритет", + "recentSuccess": "Недавние успехи", + "directionAsc": "По возрастанию", + "directionDesc": "По убыванию" + }, + "filter": { + "allModels": "Все модели", + "selectedModels": "{{selected}}/{{total}} моделей", + "selectAll": "Выбрать все", + "clear": "Очистить", + "empty": "Модели ещё не настроены" + } + }, "modelCatalog": { "summaryTitle": "Каталог моделей", "openAction": "Открыть", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4122f21..5d32272 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1625,6 +1625,23 @@ "close": "关闭", "apply": "应用 ({{count}})" }, + "toolbar": { + "sortBy": "排序", + "sort": { + "name": "名称", + "priority": "优先级", + "recentSuccess": "近期成功数", + "directionAsc": "升序", + "directionDesc": "降序" + }, + "filter": { + "allModels": "全部模型", + "selectedModels": "{{selected}}/{{total}} 个模型", + "selectAll": "全选", + "clear": "清空", + "empty": "暂无已配置模型" + } + }, "modelCatalog": { "summaryTitle": "模型目录", "openAction": "打开目录", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 2579b92..9baf67e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1651,6 +1651,23 @@ "close": "關閉", "apply": "套用 ({{count}})" }, + "toolbar": { + "sortBy": "排序", + "sort": { + "name": "名稱", + "priority": "優先級", + "recentSuccess": "近期成功數", + "directionAsc": "升序", + "directionDesc": "降序" + }, + "filter": { + "allModels": "全部模型", + "selectedModels": "{{selected}}/{{total}} 個模型", + "selectAll": "全選", + "clear": "清空", + "empty": "尚無已設定模型" + } + }, "modelCatalog": { "summaryTitle": "模型目錄", "openAction": "開啟目錄",