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:
LTbinglingfeng
2026-05-25 01:34:44 +08:00
Unverified
parent 191a4c5cc5
commit fafc4b78e0
12 changed files with 773 additions and 32 deletions
@@ -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);
}
+17
View File
@@ -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",
+17
View File
@@ -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": "Открыть",
+17
View File
@@ -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": "打开目录",
+17
View File
@@ -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": "開啟目錄",