mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(providers): bring back status bar, sort and model filter on the table
- Wire useProviderRecentRequests into ProvidersWorkbenchPage and pass usageByProvider down to the resource table; the header refresh now refreshes recent requests in parallel with the provider snapshot - ProviderResourceTable status column hosts the existing status badge plus an inline ProviderStatusBar driven by the recent-requests usage; column widths rebalanced so the bar fits on a single row - New OpenAIBrandToolbar gives the OpenAI-compatible brand a sort control (name / priority / recent-success with direction) and a multi-select model filter dropdown; ProviderResourcePanel renders it via the new openaiControls prop - ProvidersWorkbenchPage computes the available model union, sorts / filters resources when the OpenAI brand is active, and resets the selected models when switching brands - Add providerStatusBar.module.scss for the in-row status bar styling and i18n keys (en/zh-CN/zh-TW/ru) for the new toolbar
This commit is contained in:
@@ -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<ProviderBrand>('gemini');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [openaiSortBy, setOpenaiSortBy] = useState<OpenAISortBy>('name');
|
||||
const [openaiSortDir, setOpenaiSortDir] = useState<SortDir>('asc');
|
||||
const [openaiSelectedModels, setOpenaiSelectedModels] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const [sheetState, setSheetState] = useState<SheetState>({
|
||||
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<string>();
|
||||
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<OpenAIPanelControls | undefined>(() => {
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
selectedModels: ReadonlySet<string>;
|
||||
onSelectedModelsChange: (next: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function OpenAIBrandToolbar({
|
||||
sortBy,
|
||||
sortDir,
|
||||
onSortBy,
|
||||
onSortDir,
|
||||
availableModels,
|
||||
selectedModels,
|
||||
onSelectedModelsChange,
|
||||
}: OpenAIBrandToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.sortGroup}>
|
||||
<span className={styles.label}>{t('providersPage.toolbar.sortBy')}</span>
|
||||
<Select
|
||||
value={sortBy}
|
||||
options={sortOptions}
|
||||
onChange={(value) => onSortBy(value as OpenAISortBy)}
|
||||
ariaLabel={t('providersPage.toolbar.sortBy')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dirBtn}
|
||||
onClick={() => onSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
aria-label={
|
||||
sortDir === 'asc'
|
||||
? t('providersPage.toolbar.sort.directionAsc')
|
||||
: t('providersPage.toolbar.sort.directionDesc')
|
||||
}
|
||||
title={
|
||||
sortDir === 'asc'
|
||||
? t('providersPage.toolbar.sort.directionAsc')
|
||||
: t('providersPage.toolbar.sort.directionDesc')
|
||||
}
|
||||
>
|
||||
{sortDir === 'asc' ? (
|
||||
<IconChevronUp size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup} ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterTrigger}
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
disabled={availableModels.length === 0}
|
||||
>
|
||||
<IconSlidersHorizontal size={14} />
|
||||
<span>{filterLabel}</span>
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
{filterOpen ? (
|
||||
<div className={styles.filterPanel}>
|
||||
<div className={styles.filterToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToolbarBtn}
|
||||
onClick={selectAll}
|
||||
disabled={availableModels.length === 0}
|
||||
>
|
||||
{t('providersPage.toolbar.filter.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToolbarBtn}
|
||||
onClick={clearAll}
|
||||
disabled={selectedModels.size === 0}
|
||||
>
|
||||
{t('providersPage.toolbar.filter.clear')}
|
||||
</button>
|
||||
</div>
|
||||
{availableModels.length === 0 ? (
|
||||
<div className={styles.filterEmpty}>
|
||||
{t('providersPage.toolbar.filter.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.filterList}>
|
||||
{availableModels.map((name) => (
|
||||
<li key={name} className={styles.filterItem}>
|
||||
<SelectionCheckbox
|
||||
checked={selectedModels.has(name)}
|
||||
onChange={() => toggleModel(name)}
|
||||
label={<span className={styles.filterItemLabel}>{name}</span>}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headerMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
@@ -25,6 +31,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.headerToolbarRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.titleArea {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -6,8 +6,14 @@ import geminiLogo from '@/assets/icons/gemini.svg';
|
||||
import openaiLogo from '@/assets/icons/openai-light.svg';
|
||||
import vertexLogo from '@/assets/icons/vertex.svg';
|
||||
import { IconPlus, IconSearch } from '@/components/ui/icons';
|
||||
import type { ProviderRecentUsageMap } from '@/components/providers/utils';
|
||||
import type { ProviderBrand, ProviderGroup, ProviderResource } from '../types';
|
||||
import { ProviderResourceTable } from './ProviderResourceTable';
|
||||
import {
|
||||
OpenAIBrandToolbar,
|
||||
type OpenAISortBy,
|
||||
type SortDir,
|
||||
} from './OpenAIBrandToolbar';
|
||||
import styles from './ProviderResourcePanel.module.scss';
|
||||
|
||||
const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
|
||||
@@ -19,6 +25,16 @@ const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
|
||||
ampcode: { src: ampcodeLogo },
|
||||
};
|
||||
|
||||
export interface OpenAIPanelControls {
|
||||
sortBy: OpenAISortBy;
|
||||
sortDir: SortDir;
|
||||
onSortBy: (value: OpenAISortBy) => void;
|
||||
onSortDir: (value: SortDir) => void;
|
||||
availableModels: ReadonlyArray<string>;
|
||||
selectedModels: ReadonlySet<string>;
|
||||
onSelectedModelsChange: (next: Set<string>) => void;
|
||||
}
|
||||
|
||||
interface ProviderResourcePanelProps {
|
||||
group: ProviderGroup;
|
||||
filter: string;
|
||||
@@ -26,6 +42,8 @@ interface ProviderResourcePanelProps {
|
||||
filteredResources: ProviderResource[];
|
||||
selectedId: string | null;
|
||||
disableMutations?: boolean;
|
||||
usageByProvider?: ProviderRecentUsageMap;
|
||||
openaiControls?: OpenAIPanelControls;
|
||||
onView: (resource: ProviderResource) => void;
|
||||
onEdit: (resource: ProviderResource) => void;
|
||||
onDelete: (resource: ProviderResource) => void;
|
||||
@@ -40,6 +58,8 @@ export function ProviderResourcePanel({
|
||||
filteredResources,
|
||||
selectedId,
|
||||
disableMutations,
|
||||
usageByProvider,
|
||||
openaiControls,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -54,35 +74,50 @@ export function ProviderResourcePanel({
|
||||
return (
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleArea}>
|
||||
<div className={styles.titleRow}>
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`${styles.logo} ${logo.invertOnDark ? styles.logoInvertOnDark : ''}`}
|
||||
/>
|
||||
) : null}
|
||||
<h2 className={styles.title}>
|
||||
{t(`providersPage.providerNames.${group.id}`)}
|
||||
</h2>
|
||||
<div className={styles.headerMain}>
|
||||
<div className={styles.titleArea}>
|
||||
<div className={styles.titleRow}>
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`${styles.logo} ${logo.invertOnDark ? styles.logoInvertOnDark : ''}`}
|
||||
/>
|
||||
) : null}
|
||||
<h2 className={styles.title}>
|
||||
{t(`providersPage.providerNames.${group.id}`)}
|
||||
</h2>
|
||||
</div>
|
||||
<p className={styles.subtitle}>
|
||||
{t('providersPage.table.description', { route: group.path })}
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.subtitle}>
|
||||
{t('providersPage.table.description', { route: group.path })}
|
||||
</p>
|
||||
{group.id !== 'ampcode' ? (
|
||||
<div className={styles.searchWrap}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<IconSearch size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.searchInput}
|
||||
value={filter}
|
||||
onChange={(event) => onFilterChange(event.target.value)}
|
||||
placeholder={t('providersPage.table.filterPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{group.id !== 'ampcode' ? (
|
||||
<div className={styles.searchWrap}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<IconSearch size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.searchInput}
|
||||
value={filter}
|
||||
onChange={(event) => onFilterChange(event.target.value)}
|
||||
placeholder={t('providersPage.table.filterPlaceholder')}
|
||||
{openaiControls ? (
|
||||
<div className={styles.headerToolbarRow}>
|
||||
<OpenAIBrandToolbar
|
||||
sortBy={openaiControls.sortBy}
|
||||
sortDir={openaiControls.sortDir}
|
||||
onSortBy={openaiControls.onSortBy}
|
||||
onSortDir={openaiControls.onSortDir}
|
||||
availableModels={openaiControls.availableModels}
|
||||
selectedModels={openaiControls.selectedModels}
|
||||
onSelectedModelsChange={openaiControls.onSelectedModelsChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -127,6 +162,7 @@ export function ProviderResourcePanel({
|
||||
resources={filteredResources}
|
||||
selectedId={selectedId}
|
||||
disableMutations={disableMutations}
|
||||
usageByProvider={usageByProvider}
|
||||
onView={onView}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{renderModelsSummary(resource)}</TableCell>
|
||||
<TableCell>{renderStatus(resource)}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.statusCell}>
|
||||
{renderStatus(resource)}
|
||||
{usageByProvider && resource.brand !== 'ampcode' ? (
|
||||
<ProviderStatusBar
|
||||
statusData={resolveStatusBarData(resource, usageByProvider)}
|
||||
styles={statusBarStyles}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell alignRight>
|
||||
<div className={styles.actions}>
|
||||
{!isAmpcode && onToggleDisabled ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть",
|
||||
|
||||
@@ -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": "打开目录",
|
||||
|
||||
@@ -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": "開啟目錄",
|
||||
|
||||
Reference in New Issue
Block a user