refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
@@ -1,302 +0,0 @@
import { useState, useMemo } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import ModernSelect from '../../components/ModernSelect.js';
import { api } from '../../api.js';
import { useToast } from '../../components/Toast.js';
import { tr } from '../../i18n.js';
import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
} from './tokenBindingPresentation.js';
type ChannelSelection = {
accountId: number;
tokenId?: number;
sourceModel?: string;
};
type AddChannelModalProps = {
open: boolean;
onClose: () => void;
routeId: number;
routeTitle: string;
candidateView: RouteCandidateView;
onSuccess: () => void;
missingTokenHints?: RouteMissingTokenHint[];
onCreateTokenForMissing?: (accountId: number, modelName: string) => void;
existingChannelAccountIds?: Set<number>;
};
export default function AddChannelModal({
open,
onClose,
routeId,
routeTitle,
candidateView,
onSuccess,
missingTokenHints,
onCreateTokenForMissing,
existingChannelAccountIds,
}: AddChannelModalProps) {
const toast = useToast();
const [searchQuery, setSearchQuery] = useState('');
const [selectedAccounts, setSelectedAccounts] = useState<Record<number, ChannelSelection>>({});
const [submitting, setSubmitting] = useState(false);
const filteredAccounts = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return candidateView.accountOptions;
return candidateView.accountOptions.filter((option) =>
option.label.toLowerCase().includes(q),
);
}, [candidateView.accountOptions, searchQuery]);
const missingAccounts = useMemo(() => {
if (!missingTokenHints || missingTokenHints.length === 0) return [];
const seen = new Map<number, { accountId: number; label: string; modelName: string }>();
for (const hint of missingTokenHints) {
for (const account of hint.accounts) {
if (!seen.has(account.accountId)) {
const label = `${account.username || `account-${account.accountId}`} @ ${account.siteName}`;
seen.set(account.accountId, { accountId: account.accountId, label, modelName: hint.modelName });
}
}
}
return Array.from(seen.values());
}, [missingTokenHints]);
const filteredMissingAccounts = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return missingAccounts;
return missingAccounts.filter((item) => item.label.toLowerCase().includes(q));
}, [missingAccounts, searchQuery]);
const selectedCount = Object.keys(selectedAccounts).length;
const toggleAccount = (account: RouteAccountOption) => {
setSelectedAccounts((prev) => {
if (prev[account.id]) {
const next = { ...prev };
delete next[account.id];
return next;
}
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
return {
...prev,
[account.id]: {
accountId: account.id,
},
};
});
};
const updateTokenForAccount = (accountId: number, tokenId: number, sourceModel: string) => {
setSelectedAccounts((prev) => {
if (!prev[accountId]) return prev;
return {
...prev,
[accountId]: {
...prev[accountId],
tokenId: tokenId || undefined,
sourceModel: sourceModel || undefined,
},
};
});
};
const handleSubmit = async () => {
const channels = Object.values(selectedAccounts);
if (channels.length === 0) return;
setSubmitting(true);
try {
const result = await api.batchAddChannels(routeId, channels);
const msg = `已添加 ${result.created} 个通道` +
(result.skipped > 0 ? `,跳过 ${result.skipped} 个重复` : '') +
(result.errors.length > 0 ? `,${result.errors.length} 个错误` : '');
toast.success(msg);
setSelectedAccounts({});
setSearchQuery('');
onSuccess();
onClose();
} catch (e: any) {
toast.error(e.message || '批量添加通道失败');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setSelectedAccounts({});
setSearchQuery('');
onClose();
}
};
return (
<CenteredModal
open={open}
onClose={handleClose}
title={`${tr('添加通道')} - ${routeTitle}`}
maxWidth={560}
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('已选')} {selectedCount} {tr('个通道')}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" onClick={handleClose} disabled={submitting}>
{tr('取消')}
</button>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={submitting || selectedCount === 0}
>
{submitting ? (
<><span className="spinner spinner-sm" /> {tr('添加中...')}</>
) : (
`${tr('批量添加')} (${selectedCount})`
)}
</button>
</div>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="toolbar-search" style={{ width: '100%' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={tr('搜索账号...')}
/>
</div>
<div style={{ maxHeight: 360, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredAccounts.length === 0 && filteredMissingAccounts.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
{candidateView.accountOptions.length === 0 && missingAccounts.length === 0
? tr('当前没有可用的账号,请确认已有账号的令牌支持调用此模型')
: tr('没有匹配的账号')}
</div>
) : (
<>
{filteredAccounts.map((account) => {
const isSelected = !!selectedAccounts[account.id];
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
const selection = selectedAccounts[account.id];
const isExisting = existingChannelAccountIds?.has(account.id);
const tokenBinding = describeTokenBinding(tokens, selection?.tokenId || 0);
return (
<div
key={account.id}
onClick={() => toggleAccount(account)}
style={{
padding: '8px 10px',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${isSelected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: isSelected ? 'color-mix(in srgb, var(--color-primary) 6%, transparent)' : 'transparent',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={isSelected}
readOnly
style={{ cursor: 'pointer', pointerEvents: 'none' }}
/>
<span style={{ fontSize: 13, fontWeight: 500 }}>{account.label}</span>
{isExisting && (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('已添加')}</span>
)}
</div>
{isSelected && tokens.length > 0 && (
<div style={{ marginTop: 6, paddingLeft: 24 }} onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌绑定')}:</div>
<ModernSelect
size="sm"
value={(() => {
if (!selection?.tokenId) return '0';
return `${selection.tokenId}::${selection.sourceModel || ''}`;
})()}
onChange={(nextValue) => {
if (nextValue === '0') {
updateTokenForAccount(account.id, 0, '');
return;
}
const [tokenRaw, ...sourceParts] = nextValue.split('::');
updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
}}
options={[
{
value: '0',
label: tr('跟随账号默认'),
description: tokenBinding.followOptionDescription,
},
...tokens.map((token: RouteTokenOption) => ({
value: `${token.id}::${token.sourceModel || ''}`,
label: buildFixedTokenOptionLabel(token, {
includeDefaultTag: true,
includeSourceModel: true,
}),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder={tr('选择绑定方式')}
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
)}
</div>
);
})}
{/* Missing token hints */}
{filteredMissingAccounts.length > 0 && (
<div style={{ borderTop: '1px dashed var(--color-border)', paddingTop: 8, marginTop: 4, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
{tr('以下账号可用此模型但缺少令牌')}:
</div>
{filteredMissingAccounts.map((item) => (
<div
key={item.accountId}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 'var(--radius-sm)',
border: '1px dashed var(--color-border)', background: 'var(--color-bg)',
}}
>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{item.label}</span>
{onCreateTokenForMissing && (
<button
type="button"
className="btn btn-link"
style={{ fontSize: 11, padding: '2px 6px' }}
onClick={() => onCreateTokenForMissing(item.accountId, item.modelName)}
>
{tr('创建令牌')}
</button>
)}
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</CenteredModal>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,125 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
vi.mock('@dnd-kit/sortable', async () => {
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable');
return {
...actual,
useSortable: () => ({
attributes: { 'data-sortable-handle': 'true' },
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: { x: 0, y: 18, scaleX: 0.82, scaleY: 1.24 },
transition: 'transform 200ms ease',
isDragging: false,
}),
};
});
import RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function buildRoute(overrides: Partial<RouteSummaryRow> = {}): RouteSummaryRow {
return {
id: 42,
modelPattern: 'gpt-5.4',
displayName: 'gpt-5.4',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 3,
enabledChannelCount: 3,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
...overrides,
};
}
function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
return {
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-5.4',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'openai' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
...overrides,
};
}
describe('RouteCard sortable shell', () => {
it('applies sortable transform to the outer channel shell instead of the inner row card', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 1 }),
]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const shell = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-shell'
&& node.props['data-channel-id'] === 11
));
const innerRow = shell.find((node) => (
node.type === 'div'
&& node.props.style
&& node.props.style.borderRadius === 14
&& collectText(node).includes('user_a')
));
expect(shell.props.style.transform).toContain('translate3d');
expect(shell.props.style.transform).not.toContain('scale');
expect(String(shell.props.style.transition || '')).toContain('transform');
expect(innerRow.props.style.transform).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,132 +0,0 @@
import { act, create } from 'react-test-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import RouteFilterBar from './RouteFilterBar.js';
function renderBar(collapsed: boolean) {
return (
<RouteFilterBar
totalRouteCount={3}
activeBrand={null}
setActiveBrand={vi.fn()}
activeSite={null}
setActiveSite={vi.fn()}
activeEndpointType={null}
setActiveEndpointType={vi.fn()}
activeGroupFilter={null}
setActiveGroupFilter={vi.fn()}
enabledFilter="all"
setEnabledFilter={vi.fn()}
enabledCounts={{ enabled: 2, disabled: 1 }}
brandList={{ list: [], otherCount: 0 }}
siteList={[]}
endpointTypeList={[]}
groupRouteList={[]}
collapsed={collapsed}
onToggle={vi.fn()}
/>
);
}
describe('RouteFilterBar', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('uses the shared collapse presence wrapper while expanded', () => {
const root = create(renderBar(false));
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
const expandedPanel = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(String(presence.props.className)).toContain('anim-collapse');
expect(String(presence.props.className)).toContain('is-open');
expect(String(expandedPanel.props.className)).not.toContain('is-closing');
});
it('keeps the collapse wrapper mounted while collapsed so expand can animate from existing layout', () => {
const root = create(renderBar(true));
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(presence.props.className)).not.toContain('is-open');
});
it('keeps expanded controls mounted briefly while closing, then removes them after the collapse window', () => {
vi.useFakeTimers();
const root = create(renderBar(false));
act(() => {
root.update(renderBar(true));
});
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(presence.props.className)).not.toContain('is-open');
const expandedWhileClosing = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedWhileClosing).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(181);
});
const expandedAfterWindow = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedAfterWindow).toHaveLength(0);
});
it('mounts expanded controls before flipping the presence wrapper open so expand does not start from an empty shell', () => {
const rafCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal('window', {
requestAnimationFrame: (callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
},
cancelAnimationFrame: vi.fn(),
});
const root = create(renderBar(true));
act(() => {
root.update(renderBar(false));
});
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
const expandedBeforeOpen = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedBeforeOpen).toHaveLength(1);
expect(String(presence.props.className)).not.toContain('is-open');
expect(rafCallbacks).toHaveLength(1);
act(() => {
rafCallbacks[0](0);
});
const openPresence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(openPresence.props.className)).toContain('is-open');
});
});
@@ -1,328 +0,0 @@
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react';
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
const FILTER_EXPANDED_CONTENT_UNMOUNT_MS = 180;
type RouteFilterBarProps = {
totalRouteCount: number;
activeBrand: string | null;
setActiveBrand: (brand: string | null) => void;
activeSite: string | null;
setActiveSite: (site: string | null) => void;
activeEndpointType: string | null;
setActiveEndpointType: (endpointType: string | null) => void;
activeGroupFilter: GroupFilter;
setActiveGroupFilter: (filter: GroupFilter) => void;
enabledFilter: EnabledFilter;
setEnabledFilter: (filter: EnabledFilter) => void;
enabledCounts: { enabled: number; disabled: number };
brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
siteList: [string, { count: number; siteId: number }][];
endpointTypeList: [string, number][];
groupRouteList: GroupRouteItem[];
collapsed: boolean;
onToggle: () => void;
};
function FilterChip({
active,
label,
count,
icon,
onClick,
}: {
active: boolean;
label: string;
count?: number;
icon?: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
className={`filter-chip ${active ? 'active' : ''}`}
onClick={onClick}
>
{icon && <span className="filter-chip-icon">{icon}</span>}
<span className="filter-chip-label">{label}</span>
{count !== undefined && <span className="filter-chip-count">{count}</span>}
</button>
);
}
function FilterRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="route-filter-row">
<span className="route-filter-row-label">{label}</span>
<div className="route-filter-row-chips">{children}</div>
</div>
);
}
function ActiveFilterSummary({
activeBrand,
activeSite,
activeGroupFilter,
activeEndpointType,
enabledFilter,
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType' | 'enabledFilter'>) {
const tags: string[] = [];
if (enabledFilter === 'enabled') tags.push('状态=启用');
else if (enabledFilter === 'disabled') tags.push('状态=禁用');
if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
if (activeSite) tags.push(`站点=${activeSite}`);
if (activeGroupFilter === '__all__') tags.push('群组=全部');
else if (typeof activeGroupFilter === 'number') tags.push(`群组=#${activeGroupFilter}`);
if (activeEndpointType) tags.push(`能力=${activeEndpointType}`);
if (tags.length === 0) return <span style={{ color: 'var(--color-text-muted)' }}>{tr('全部')}</span>;
return <span>{tags.join(', ')}</span>;
}
export default function RouteFilterBar(props: RouteFilterBarProps) {
const {
totalRouteCount,
activeBrand,
setActiveBrand,
activeSite,
setActiveSite,
activeEndpointType,
setActiveEndpointType,
activeGroupFilter,
setActiveGroupFilter,
enabledFilter,
setEnabledFilter,
enabledCounts,
brandList,
siteList,
endpointTypeList,
groupRouteList,
collapsed,
onToggle,
} = props;
const [renderExpandedContent, setRenderExpandedContent] = useState(!collapsed);
const [presenceOpen, setPresenceOpen] = useState(!collapsed);
useEffect(() => {
if (!collapsed) return undefined;
const timerId = globalThis.setTimeout(() => setRenderExpandedContent(false), FILTER_EXPANDED_CONTENT_UNMOUNT_MS);
return () => globalThis.clearTimeout(timerId);
}, [collapsed]);
useLayoutEffect(() => {
if (collapsed) {
setPresenceOpen(false);
return undefined;
}
setRenderExpandedContent(true);
setPresenceOpen(false);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setPresenceOpen(true));
return () => window.cancelAnimationFrame(rafId);
}
setPresenceOpen(true);
return undefined;
}, [collapsed]);
return (
<div className="route-filter-bar">
{/* Collapsed summary */}
<button
type="button"
className="route-filter-bar-summary"
onClick={onToggle}
>
<svg
width="12" height="12" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: collapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform 0.2s ease' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
<span style={{ fontWeight: 500, fontSize: 13 }}>{tr('筛选')}:</span>
<ActiveFilterSummary
activeBrand={activeBrand}
activeSite={activeSite}
activeGroupFilter={activeGroupFilter}
activeEndpointType={activeEndpointType}
enabledFilter={enabledFilter}
/>
</button>
{/* Expanded panel */}
<div className={`anim-collapse route-filter-bar-presence ${presenceOpen ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
{renderExpandedContent && (
<div className="route-filter-bar-expanded">
{/* Status row */}
<FilterRow label={tr('状态')}>
<FilterChip
active={enabledFilter === 'all'}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setEnabledFilter('all')}
/>
<FilterChip
active={enabledFilter === 'enabled'}
label={tr('仅启用')}
count={enabledCounts.enabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-success)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')}
/>
<FilterChip
active={enabledFilter === 'disabled'}
label={tr('仅禁用')}
count={enabledCounts.disabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')}
/>
</FilterRow>
{/* Brand row */}
<FilterRow label={tr('品牌')}>
<FilterChip
active={!activeBrand}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setActiveBrand(null)}
/>
{brandList.list.map(([brandName, { count, brand }]) => (
<FilterChip
key={brandName}
active={activeBrand === brandName}
label={brandName}
count={count}
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
/>
))}
{brandList.otherCount > 0 && (
<FilterChip
active={activeBrand === '__other__'}
label={tr('其他')}
count={brandList.otherCount}
icon={<span style={{ fontSize: 10 }}>?</span>}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
/>
)}
</FilterRow>
{/* Site row */}
{siteList.length > 0 && (
<FilterRow label={tr('站点')}>
<FilterChip
active={!activeSite}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚡</span>}
onClick={() => setActiveSite(null)}
/>
{siteList.map(([siteName, { count }]) => (
<FilterChip
key={siteName}
active={activeSite === siteName}
label={siteName}
count={count}
icon={
<span
style={{
fontSize: 8,
background: hashColor(siteName),
color: 'white',
borderRadius: 3,
padding: '1px 2px',
lineHeight: 1,
}}
>
{siteAvatarLetters(siteName)}
</span>
}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
/>
))}
</FilterRow>
)}
{/* Group row */}
<FilterRow label={tr('群组')}>
<FilterChip
active={activeGroupFilter === '__all__'}
label={tr('全部群组')}
count={groupRouteList.length}
icon={<span style={{ fontSize: 10 }}>◎</span>}
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
/>
{groupRouteList.map((groupRoute) => (
<FilterChip
key={groupRoute.id}
active={activeGroupFilter === groupRoute.id}
label={groupRoute.title}
count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount}
icon={
groupRoute.icon.kind === 'brand' ? (
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'auto' ? (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} />
) : undefined
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
))}
</FilterRow>
{/* Endpoint type row */}
<FilterRow label={tr('能力')}>
<FilterChip
active={!activeEndpointType}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(null)}
/>
{endpointTypeList.map(([endpointType, count]) => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
return (
<FilterChip
key={endpointType}
active={activeEndpointType === endpointType}
label={endpointType}
count={count}
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
/>
);
})}
{endpointTypeList.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
)}
</FilterRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', border: '1px solid var(--color-border)' }}
onClick={onToggle}
>
{tr('收起筛选面板')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -1,74 +0,0 @@
import { type CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type SortableBucketSeparatorProps = {
id: string;
beforePriority: number;
afterPriority: number;
isSavingPriority: boolean;
};
export function SortableBucketSeparator({
id,
beforePriority,
afterPriority,
isSavingPriority,
}: SortableBucketSeparatorProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id,
disabled: isSavingPriority,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '4px 2px',
color: 'var(--color-text-muted)',
};
return (
<div ref={setNodeRef} style={style}>
<div style={{ flex: 1, height: 1, background: 'var(--color-border)' }} />
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority}
className="btn btn-ghost"
aria-label={`拖拽调整 P${beforePriority} / P${afterPriority} 分界线`}
data-tooltip={`拖拽调整 P${beforePriority} / P${afterPriority} 分界线`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
border: '1px solid var(--color-border)',
borderRadius: 999,
background: 'var(--color-bg)',
padding: '2px 10px',
fontSize: 11,
color: 'var(--color-text-muted)',
cursor: isSavingPriority ? 'not-allowed' : 'grab',
}}
>
<span>{`P${beforePriority}`}</span>
<span style={{ fontSize: 10 }}>||</span>
<span>{`P${afterPriority}`}</span>
</button>
<div style={{ flex: 1, height: 1, background: 'var(--color-border)' }} />
</div>
);
}
@@ -1,152 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import { DndContext } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableChannelRow } from './SortableChannelRow.js';
import type { RouteChannel } from './types.js';
function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
return {
id: 301,
routeId: 88,
accountId: 7,
tokenId: null,
sourceModel: 'gpt-4.1',
priority: 0,
weight: 100,
enabled: true,
manualOverride: true,
successCount: 12,
failCount: 1,
cooldownUntil: null,
account: {
username: 'cc',
accessToken: null,
extraConfig: null,
credentialMode: 'oauth',
},
site: {
id: 99,
name: 'codelab',
platform: 'openai',
},
token: null,
...overrides,
};
}
describe('SortableChannelRow layering', () => {
it('does not force a base z-index on desktop rows when they are not being dragged', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const row = root.root.find((node) => (
node.type === 'div'
&& node.props['data-layer-root']
));
expect(row.props.style.zIndex).toBeUndefined();
expect(row.props.style.borderLeft).toBeUndefined();
});
it('disables row tooltips while a drag interaction is in progress', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
dragInProgress
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const tooltipNodes = root.root.findAll((node) => node.props['data-tooltip'] !== undefined);
expect(tooltipNodes).toHaveLength(0);
});
it('treats channel-management-disabled rows as non-interactive for the drag handle', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
channelManagementDisabled
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const dragHandle = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '拖拽调整优先级桶'
));
expect(dragHandle.props.disabled).toBe(true);
expect(dragHandle.props['data-tooltip']).toBe('该路由当前不可编辑优先级');
});
});
@@ -1,591 +0,0 @@
import { useState, type CSSProperties } from 'react';
import ModernSelect from '../../components/ModernSelect.js';
import type { SortableChannelRowProps } from './types.js';
import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
}
function formatRouteUnitMemberLabel(member: { accountId: number; username: string | null; siteName: string | null }): string {
const accountLabel = member.username?.trim() || `account-${member.accountId}`;
const siteLabel = member.siteName?.trim();
return siteLabel ? `${accountLabel} @ ${siteLabel}` : accountLabel;
}
export function SortableChannelRow({
channel,
displayPriority,
showPriorityBadge = true,
dragging = false,
dragHandleProps,
dragHandleRef,
decisionCandidate,
isExactRoute,
loadingDecision,
isSavingPriority,
readOnly = false,
channelManagementDisabled = false,
dragInProgress = false,
mobile = false,
tokenOptions,
activeTokenId,
isUpdatingToken,
onTokenDraftChange,
onSaveToken,
onDeleteChannel,
onToggleEnabled,
onSiteBlockModel,
}: SortableChannelRowProps) {
const resolvedPriority = displayPriority ?? channel.priority ?? 0;
const managementLocked = readOnly || channelManagementDisabled;
const suppressTooltips = dragInProgress || dragging;
const rowTransition = [
'box-shadow 180ms ease',
'background-color 180ms ease',
'border-color 180ms ease',
'opacity 180ms ease',
].filter(Boolean).join(', ');
const dragHandleStyle: CSSProperties = {
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: `1px solid ${dragging ? 'color-mix(in srgb, var(--color-info) 34%, var(--color-border-light))' : 'var(--color-border-light)'}`,
borderRadius: 10,
backgroundColor: dragging
? 'color-mix(in srgb, var(--color-bg-card) 80%, var(--color-info) 20%)'
: 'color-mix(in srgb, var(--color-bg-card) 90%, white 10%)',
boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.62)',
color: dragging ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
cursor: isSavingPriority || managementLocked ? 'not-allowed' : 'grab',
opacity: managementLocked ? 0.65 : 1,
transition: 'background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease',
};
const rowStyle: CSSProperties = {
transition: rowTransition || undefined,
opacity: dragging ? 0.92 : channel.enabled === false ? 0.56 : 1,
display: 'grid',
gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center',
gap: mobile ? 8 : 6,
padding: mobile ? '8px 9px' : '5px 8px',
border: `1px solid ${dragging ? 'color-mix(in srgb, var(--color-info) 38%, var(--color-border-light))' : 'color-mix(in srgb, var(--color-border-light) 92%, transparent)'}`,
borderRadius: 14,
backgroundColor: dragging
? 'color-mix(in srgb, var(--color-bg-card) 82%, var(--color-info) 18%)'
: 'color-mix(in srgb, var(--color-bg-card) 96%, white 4%)',
boxShadow: dragging
? '0 18px 34px rgba(15, 23, 42, 0.12)'
: '0 10px 22px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.7)',
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
const tokenBinding = describeTokenBinding(
tokenOptions,
activeTokenId,
channel.token?.name ?? null,
{
connectionMode: resolveTokenBindingConnectionMode(channel.account),
accountName: channel.account?.username || `account-${channel.accountId}`,
},
);
const routeUnit = channel.routeUnit ?? null;
const routeUnitName = routeUnit?.name?.trim() || 'OAuth 路由池';
const routeUnitStrategyLabel = routeUnit ? getRouteUnitStrategyLabel(routeUnit.strategy) : '';
const routeUnitMemberSummary = routeUnit?.members?.length
? routeUnit.members.map((member) => formatRouteUnitMemberLabel(member)).join('、')
: null;
const routeUnitMemberSummaryText = routeUnitMemberSummary ? `成员:${routeUnitMemberSummary}` : null;
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false);
if (mobile) {
return (
<div data-layer-root style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<button
type="button"
ref={dragHandleRef}
{...dragHandleProps}
disabled={isSavingPriority || managementLocked}
className="btn btn-ghost"
style={{
marginTop: 2,
...dragHandleStyle,
}}
data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
<circle cx="9" cy="2" r="1" />
<circle cx="3" cy="6" r="1" />
<circle cx="9" cy="6" r="1" />
<circle cx="3" cy="10" r="1" />
<circle cx="9" cy="10" r="1" />
</svg>
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
{showPriorityBadge ? (
<span
className="badge"
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(resolvedPriority),
}}
>
P{resolvedPriority}
</span>
) : null}
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
成功/失败 <span style={{ color: 'var(--color-success)', fontWeight: 600 }}>{channel.successCount || 0}</span>
<span style={{ color: 'var(--color-text-muted)', margin: '0 2px' }}>/</span>
<span style={{ color: 'var(--color-danger)', fontWeight: 600 }}>{channel.failCount || 0}</span>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
fontSize: 10,
background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{tokenBinding.bindingModeLabel}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
{channel.manualOverride ? (
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
>
手动配置
</span>
) : null}
{routeUnit ? (
<>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
OAuth 路由池
</span>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{routeUnitName}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnit.memberCount} 成员
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnitStrategyLabel}
</span>
</>
) : null}
</div>
{routeUnitMemberSummaryText ? (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
{routeUnitMemberSummaryText}
</span>
</div>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
<div
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
width: 60,
height: 4,
background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999,
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, decisionState.probability))}%`,
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.24s ease, background-color 0.18s ease',
}}
/>
</div>
<span
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{decisionState.probability.toFixed(1)}%
</span>
</div>
{!managementLocked && (
<button
type="button"
className="btn btn-link"
onClick={() => setMobileDetailsOpen((current) => !current)}
style={{ marginLeft: 'auto' }}
>
{mobileDetailsOpen ? '收起配置' : '配置通道'}
</button>
)}
</div>
{!managementLocked && mobileDetailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 6, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ width: '100%' }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{
value: '0',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 3, fontSize: 10.5, color: 'var(--color-text-muted)', lineHeight: 1.35 }}>
{tokenBinding.helperText}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
>
站点屏蔽
</button>
) : null}
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
移除
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
return (
<div data-layer-root style={rowStyle}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 6, fontSize: 12, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
ref={dragHandleRef}
{...dragHandleProps}
disabled={isSavingPriority || managementLocked}
className="btn btn-ghost"
style={dragHandleStyle}
data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
<circle cx="9" cy="2" r="1" />
<circle cx="3" cy="6" r="1" />
<circle cx="9" cy="6" r="1" />
<circle cx="3" cy="10" r="1" />
<circle cx="9" cy="10" r="1" />
</svg>
</button>
{showPriorityBadge ? (
<span
className="badge"
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(resolvedPriority),
}}
>
P{resolvedPriority}
</span>
) : null}
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{tokenBinding.bindingModeLabel}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
{channel.manualOverride ? (
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
>
手动配置
</span>
) : null}
{channel.enabled === false ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
) : null}
{routeUnit ? (
<>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
OAuth 路由池
</span>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{routeUnitName}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnit.memberCount} 成员
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnitStrategyLabel}
</span>
</>
) : null}
{routeUnitMemberSummaryText ? (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, width: '100%', flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
{routeUnitMemberSummaryText}
</span>
</div>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', marginTop: mobile ? 0 : 1, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
<div
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
width: 60,
height: 4,
background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999,
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, decisionState.probability))}%`,
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.24s ease, background-color 0.18s ease',
}}
/>
</div>
<span
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{decisionState.probability.toFixed(1)}%
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>成功/失败</span>
<span style={{ fontSize: 11 }}>
<span style={{ color: 'var(--color-success)', fontWeight: 600 }}>{channel.successCount || 0}</span>
<span style={{ color: 'var(--color-text-muted)', margin: '0 2px' }}>/</span>
<span style={{ color: 'var(--color-danger)', fontWeight: 600 }}>{channel.failCount || 0}</span>
</span>
</div>
</div>
{!managementLocked ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{
value: '0',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 3, fontSize: 10.5, color: 'var(--color-text-muted)', lineHeight: 1.35 }}>
{tokenBinding.helperText}
</div>
</div>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
</div>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
data-tooltip={suppressTooltips ? undefined : (channel.enabled === false ? '启用此通道' : '禁用此通道')}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
data-tooltip={suppressTooltips ? undefined : `将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
>
站点屏蔽
</button>
) : null}
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
移除
</button>
</div>
</>
) : null}
</div>
);
}
@@ -1,104 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { RouteChannel } from './types.js';
import {
applyPriorityBucketDrag,
buildPriorityBuckets,
createPriorityBucketSeparatorId,
splitPriorityBucketAfterChannel,
} from './priorityBuckets.js';
function buildChannel(id: number, priority: number): RouteChannel {
return {
id,
routeId: 1,
accountId: 100 + id,
tokenId: 200 + id,
sourceModel: id % 2 === 0 ? 'model-b' : 'model-a',
priority,
weight: 10,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
cooldownUntil: null,
account: { username: `user-${id}` },
site: { id: 300 + id, name: `site-${id}`, platform: 'new-api' },
token: { id: 200 + id, name: `token-${id}`, accountId: 100 + id, enabled: true, isDefault: true },
};
}
describe('priority bucket helpers', () => {
it('groups duplicate priorities into the same bucket', () => {
const buckets = buildPriorityBuckets([
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 2),
buildChannel(4, 2),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0]).toMatchObject({ priority: 0 });
expect(buckets[0].channels.map((channel) => channel.id)).toEqual([1, 2]);
expect(buckets[1]).toMatchObject({ priority: 2 });
expect(buckets[1].channels.map((channel) => channel.id)).toEqual([3, 4]);
});
it('moves a channel across a separator and preserves duplicate priorities', () => {
const reordered = applyPriorityBucketDrag(
[
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 1),
buildChannel(4, 2),
],
3,
1,
);
expect(reordered.map((channel) => ({ id: channel.id, priority: channel.priority }))).toEqual([
{ id: 1, priority: 0 },
{ id: 2, priority: 0 },
{ id: 3, priority: 0 },
{ id: 4, priority: 1 },
]);
});
it('moves a separator within adjacent buckets and dense-renormalizes priorities', () => {
const reordered = applyPriorityBucketDrag(
[
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 1),
buildChannel(4, 2),
buildChannel(5, 2),
],
createPriorityBucketSeparatorId(0),
3,
);
expect(reordered.map((channel) => ({ id: channel.id, priority: channel.priority }))).toEqual([
{ id: 1, priority: 0 },
{ id: 2, priority: 0 },
{ id: 3, priority: 0 },
{ id: 4, priority: 1 },
{ id: 5, priority: 1 },
]);
});
it('can split a single shared-priority bucket into a new next bucket', () => {
const reordered = splitPriorityBucketAfterChannel(
[
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 0),
],
1,
);
expect(reordered.map((channel) => ({ id: channel.id, priority: channel.priority }))).toEqual([
{ id: 1, priority: 0 },
{ id: 2, priority: 1 },
{ id: 3, priority: 1 },
]);
});
});
@@ -1,158 +0,0 @@
import type { RouteChannel } from './types.js';
import { normalizeChannels } from './utils.js';
export const PRIORITY_BUCKET_SEPARATOR_PREFIX = 'priority-separator:';
export type PriorityBucket = {
priority: number;
channels: RouteChannel[];
};
type PriorityBucketEditorChannelItem = {
id: number;
kind: 'channel';
channel: RouteChannel;
};
type PriorityBucketEditorSeparatorItem = {
id: string;
kind: 'separator';
};
export type PriorityBucketEditorItem = PriorityBucketEditorChannelItem | PriorityBucketEditorSeparatorItem;
export function createPriorityBucketSeparatorId(index: number): string {
return `${PRIORITY_BUCKET_SEPARATOR_PREFIX}${index}`;
}
export function isPriorityBucketSeparatorId(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(PRIORITY_BUCKET_SEPARATOR_PREFIX);
}
export function buildPriorityBuckets(channels: RouteChannel[]): PriorityBucket[] {
const grouped = new Map<number, RouteChannel[]>();
for (const channel of normalizeChannels(channels || [])) {
const priority = channel.priority ?? 0;
if (!grouped.has(priority)) grouped.set(priority, []);
grouped.get(priority)!.push(channel);
}
return Array.from(grouped.entries()).map(([priority, bucketChannels]) => ({
priority,
channels: bucketChannels,
}));
}
export function buildPriorityBucketEditorItems(channels: RouteChannel[]): PriorityBucketEditorItem[] {
const buckets = buildPriorityBuckets(channels);
const items: PriorityBucketEditorItem[] = [];
buckets.forEach((bucket, index) => {
for (const channel of bucket.channels) {
items.push({ id: channel.id, kind: 'channel', channel });
}
if (index < buckets.length - 1) {
items.push({ id: createPriorityBucketSeparatorId(index), kind: 'separator' });
}
});
return items;
}
export function splitPriorityBucketAfterChannel(
channels: RouteChannel[],
channelId: number,
): RouteChannel[] {
const normalized = normalizeChannels(channels || []);
if (normalized.length <= 1) return normalized;
const items = buildPriorityBucketEditorItems(normalized);
const channelIndex = items.findIndex((item) => item.kind === 'channel' && item.id === channelId);
if (channelIndex < 0) return normalized;
const nextItem = items[channelIndex + 1];
if (!nextItem || nextItem.kind === 'separator') {
return normalized;
}
const next = [...items];
next.splice(channelIndex + 1, 0, {
id: `${PRIORITY_BUCKET_SEPARATOR_PREFIX}split:${channelId}`,
kind: 'separator',
});
return denseRenormalizeChannels(next);
}
function moveItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
const next = [...items];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}
function denseRenormalizeChannels(items: PriorityBucketEditorItem[]): RouteChannel[] {
let rawBucketIndex = 0;
let nextPriority = 0;
const rawToDense = new Map<number, number>();
const reordered: RouteChannel[] = [];
for (const item of items) {
if (item.kind === 'separator') {
rawBucketIndex += 1;
continue;
}
if (!rawToDense.has(rawBucketIndex)) {
rawToDense.set(rawBucketIndex, nextPriority);
nextPriority += 1;
}
reordered.push({
...item.channel,
priority: rawToDense.get(rawBucketIndex)!,
});
}
return normalizeChannels(reordered);
}
export function applyPriorityBucketDrag(
channels: RouteChannel[],
activeId: string | number,
overId: string | number,
): RouteChannel[] {
const normalized = normalizeChannels(channels || []);
if (normalized.length === 0 || activeId === overId) return normalized;
const items = buildPriorityBucketEditorItems(normalized);
const activeIndex = items.findIndex((item) => item.id === activeId);
const overIndex = items.findIndex((item) => item.id === overId);
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) {
return normalized;
}
const activeItem = items[activeIndex];
if (activeItem.kind === 'separator') {
const targetItem = items[overIndex];
if (targetItem.kind !== 'channel') {
return normalized;
}
let previousSeparatorIndex = -1;
for (let index = activeIndex - 1; index >= 0; index -= 1) {
if (items[index]?.kind === 'separator') {
previousSeparatorIndex = index;
break;
}
}
let nextSeparatorIndex = items.length;
for (let index = activeIndex + 1; index < items.length; index += 1) {
if (items[index]?.kind === 'separator') {
nextSeparatorIndex = index;
break;
}
}
if (overIndex <= previousSeparatorIndex || overIndex >= nextSeparatorIndex) {
return normalized;
}
}
return denseRenormalizeChannels(moveItem(items, activeIndex, overIndex));
}
@@ -1,90 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
applyPriorityRailDrop,
buildPriorityRailNodeStyle,
buildPriorityRailDragTargets,
buildPriorityRailSections,
createPriorityRailNewLayerId,
} from './priorityRail.js';
describe('priorityRail helpers', () => {
it('groups channels into visible priority sections and preserves in-layer order', () => {
const sections = buildPriorityRailSections([
{ id: 11, priority: 0 },
{ id: 12, priority: 0 },
{ id: 21, priority: 1 },
]);
expect(sections).toEqual([
{ priority: 0, channelCount: 2, channelIds: [11, 12] },
{ priority: 1, channelCount: 1, channelIds: [21] },
]);
});
it('exposes a temporary new-layer target only when drag state requests it', () => {
const sections = buildPriorityRailSections([
{ id: 11, priority: 0 },
{ id: 21, priority: 1 },
]);
expect(
buildPriorityRailDragTargets(sections, {
activeChannelId: 11,
hoveredPriority: 1,
showNewLayerTarget: true,
}),
).toEqual([
{ kind: 'existing_layer', priority: 0, highlighted: false },
{ kind: 'existing_layer', priority: 1, highlighted: true },
{ kind: 'new_layer', priority: 2, highlighted: false },
]);
});
it('moves a channel into an existing layer when dropped onto another channel', () => {
const reordered = applyPriorityRailDrop(
[
{ id: 11, priority: 0 },
{ id: 12, priority: 0 },
{ id: 21, priority: 1 },
],
21,
11,
);
expect(reordered).toEqual([
{ id: 11, priority: 0 },
{ id: 12, priority: 0 },
{ id: 21, priority: 0 },
]);
});
it('creates a new next layer when dropped onto a drag-only new-layer target', () => {
const reordered = applyPriorityRailDrop(
[
{ id: 11, priority: 0 },
{ id: 12, priority: 0 },
{ id: 21, priority: 1 },
],
12,
createPriorityRailNewLayerId(0),
);
expect(reordered).toEqual([
{ id: 11, priority: 0 },
{ id: 12, priority: 1 },
{ id: 21, priority: 2 },
]);
});
it('keeps priority color hierarchy even when a rail node is highlighted', () => {
const p0 = buildPriorityRailNodeStyle(0, false);
const p0Highlighted = buildPriorityRailNodeStyle(0, true);
const p1Highlighted = buildPriorityRailNodeStyle(1, true);
expect(p0.background).not.toBe('var(--color-bg)');
expect(String(p0Highlighted.background)).toContain('var(--color-bg)');
expect(String(p0Highlighted.background)).not.toContain('white');
expect(p0Highlighted.color).toBe('var(--color-primary)');
expect(p0Highlighted.background).not.toBe(p1Highlighted.background);
});
});
-133
View File
@@ -1,133 +0,0 @@
import type { CSSProperties } from 'react';
import type { PriorityRailDragTarget, PriorityRailSection } from './types.js';
import { getPriorityTagStyle } from './utils.js';
type PriorityRailChannelLike = {
id: number;
priority: number;
};
type BuildPriorityRailDragTargetsOptions = {
activeChannelId: number;
hoveredPriority: number | null;
showNewLayerTarget: boolean;
};
export const PRIORITY_RAIL_NEW_LAYER_PREFIX = 'priority-rail:new-layer:';
export function createPriorityRailNewLayerId(priority: number): string {
return `${PRIORITY_RAIL_NEW_LAYER_PREFIX}${priority}`;
}
export function isPriorityRailNewLayerId(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(PRIORITY_RAIL_NEW_LAYER_PREFIX);
}
function parsePriorityRailNewLayerPriority(value: string): number | null {
const raw = value.slice(PRIORITY_RAIL_NEW_LAYER_PREFIX.length);
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
}
export function buildPriorityRailSections(
channels: PriorityRailChannelLike[],
): PriorityRailSection[] {
const grouped = new Map<number, number[]>();
for (const channel of channels || []) {
const priority = Number.isFinite(channel.priority) ? channel.priority : 0;
if (!grouped.has(priority)) grouped.set(priority, []);
grouped.get(priority)!.push(channel.id);
}
return Array.from(grouped.entries())
.sort((a, b) => a[0] - b[0])
.map(([priority, channelIds]) => ({
priority,
channelCount: channelIds.length,
channelIds,
}));
}
function normalizePriorityRailChannels<T extends PriorityRailChannelLike>(channels: T[]): T[] {
return [...(channels || [])].sort((a, b) => {
const priorityA = Number.isFinite(a.priority) ? a.priority : 0;
const priorityB = Number.isFinite(b.priority) ? b.priority : 0;
if (priorityA === priorityB) return a.id - b.id;
return priorityA - priorityB;
});
}
export function buildPriorityRailDragTargets(
sections: PriorityRailSection[],
options: BuildPriorityRailDragTargetsOptions,
): PriorityRailDragTarget[] {
const targets: PriorityRailDragTarget[] = sections.map((section) => ({
kind: 'existing_layer',
priority: section.priority,
highlighted: section.priority === options.hoveredPriority,
}));
if (options.showNewLayerTarget) {
const highestPriority = sections.reduce((max, section) => Math.max(max, section.priority), -1);
targets.push({
kind: 'new_layer',
priority: highestPriority + 1,
highlighted: false,
});
}
return targets;
}
export function applyPriorityRailDrop<T extends PriorityRailChannelLike>(
channels: T[],
activeId: number,
overId: number | string,
): T[] {
const normalized = normalizePriorityRailChannels(channels);
const activeChannel = normalized.find((channel) => channel.id === activeId);
if (!activeChannel) return normalized;
if (isPriorityRailNewLayerId(overId)) {
const afterPriority = parsePriorityRailNewLayerPriority(overId);
if (afterPriority == null) return normalized;
const targetPriority = afterPriority + 1;
return normalizePriorityRailChannels(
normalized.map((channel) => {
const priority = Number.isFinite(channel.priority) ? channel.priority : 0;
if (channel.id === activeId) return { ...channel, priority: targetPriority };
if (channel.id !== activeId && priority > afterPriority) {
return { ...channel, priority: priority + 1 };
}
return channel;
}),
);
}
const targetChannel = normalized.find((channel) => channel.id === Number(overId));
if (!targetChannel || targetChannel.id === activeId) return normalized;
const targetPriority = Number.isFinite(targetChannel.priority) ? targetChannel.priority : 0;
return normalizePriorityRailChannels(
normalized.map((channel) => (
channel.id === activeId
? { ...channel, priority: targetPriority }
: channel
)),
);
}
export function buildPriorityRailNodeStyle(priority: number, highlighted: boolean): CSSProperties {
const tone = getPriorityTagStyle(priority);
return {
border: `1px solid ${highlighted ? 'var(--color-primary)' : 'color-mix(in srgb, currentColor 24%, transparent)'}`,
background: highlighted
? `color-mix(in srgb, ${tone.background} 78%, var(--color-bg))`
: tone.background,
color: highlighted ? 'var(--color-primary)' : tone.color,
};
}
@@ -1,36 +0,0 @@
import { tr } from '../../i18n.js';
import type { RouteRoutingStrategy } from './types.js';
export function normalizeRouteRoutingStrategyValue(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
if (value === 'round_robin' || value === 'stable_first') return value;
return 'weighted';
}
export function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') return tr('轮询');
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
export function getRouteRoutingStrategyDescription(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') {
return tr('忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却');
}
if (strategy === 'stable_first') {
return tr('先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位');
}
return tr('P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择');
}
export function getRouteRoutingStrategyHint(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') {
return tr('当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。');
}
if (strategy === 'stable_first') {
return tr('当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。');
}
return tr('只要更高优先级还有可用通道,后面的通道本次就不会参与选择。');
}
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
describe('translateOnlyRectSortingStrategy', () => {
it('uses rect deltas without introducing scale for variable-height rows', () => {
const rects = [
{ top: 0, left: 0, width: 200, height: 80, right: 200, bottom: 80 },
{ top: 92, left: 0, width: 200, height: 56, right: 200, bottom: 148 },
{ top: 160, left: 0, width: 200, height: 120, right: 200, bottom: 280 },
];
const siblingTransform = translateOnlyRectSortingStrategy({
rects,
activeIndex: 0,
overIndex: 2,
index: 1,
activeNodeRect: rects[0],
});
expect(siblingTransform).toEqual({
x: 0,
y: -92,
scaleX: 1,
scaleY: 1,
});
});
});
@@ -1,23 +0,0 @@
import { arrayMove, type SortingStrategy } from '@dnd-kit/sortable';
export const translateOnlyRectSortingStrategy: SortingStrategy = ({
rects,
activeIndex,
overIndex,
index,
}) => {
const newRects = arrayMove(rects, overIndex, activeIndex);
const oldRect = rects[index];
const newRect = newRects[index];
if (!newRect || !oldRect) {
return null;
}
return {
x: newRect.left - oldRect.left,
y: newRect.top - oldRect.top,
scaleX: 1,
scaleY: 1,
};
};
@@ -1,141 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
describe('tokenBindingPresentation', () => {
it('describes follow-account-default mode with the current default token', () => {
const result = describeTokenBinding([
{ id: 1, name: 'token-a', isDefault: true },
{ id: 2, name: 'token-b', isDefault: false },
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('token-a');
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('当前生效:token-a');
});
it('describes fixed mode when the selected token is also the account default', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 1);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('default');
expect(result.helperText).toContain('它目前也是账号默认');
expect(result.helperText).toContain('不会跟着变');
});
it('describes fixed mode when the selected token is not the account default', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化');
expect(result.followOptionLabel).toBe('跟随账号默认');
});
it('describes follow-account-default mode when no default token exists yet', () => {
const result = describeTokenBinding([
{ id: 2, name: 'backup', isDefault: false },
{ id: 3, name: 'spare', isDefault: false },
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
});
it('describes follow-api-setting mode for direct apikey connections', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'apikey',
accountName: 'elysiver_api',
});
expect(result.bindingModeLabel).toBe('API令牌');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('elysiver_api');
expect(result.helperText).toContain('连接「elysiver_api」保存的 API Key');
expect(result.followOptionLabel).toBe('固定使用:elysiver_api(跟随 API Key 设置)');
expect(result.followOptionDescription).toContain('API Key');
});
it('describes oauth direct binding without default-token wording', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('OAuth授权');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('mail@urlk.cn');
expect(result.helperText).toContain('OAuth 授权');
expect(result.helperText).not.toContain('默认令牌');
expect(result.followOptionLabel).toBe('固定使用:mail@urlk.cn(OAuth 授权)');
expect(result.followOptionDescription).toContain('OAuth 授权');
});
it('uses oauth fallback copy when a fixed token route is switched back to oauth direct binding', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.followOptionLabel).toBe('OAuth授权');
expect(result.followOptionDescription).toContain('mail@urlk.cn');
expect(result.followOptionDescription).not.toContain('当前生效:default');
});
it('resolves token binding connection mode from stored account fields', () => {
expect(resolveTokenBindingConnectionMode({
accessToken: '',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
})).toBe('apikey');
expect(resolveTokenBindingConnectionMode({
accessToken: 'session-token',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe('session');
expect(resolveTokenBindingConnectionMode({
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe('oauth');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
name: 'shared-token',
isDefault: true,
sourceModel: 'gpt-4o-mini',
};
expect(buildFixedTokenOptionLabel(token, {
includeDefaultTag: true,
includeSourceModel: true,
})).toBe('固定使用:shared-token(当前账号默认) [gpt-4o-mini]');
expect(buildFixedTokenOptionDescription(token)).toContain('以后不会自动跟随');
});
});
@@ -1,179 +0,0 @@
export type TokenBindingOption = {
id: number;
name: string;
isDefault: boolean;
sourceModel?: string;
};
export type TokenBindingPresentation = {
isFollowingAccountDefault: boolean;
bindingModeLabel: string;
badgeTone: 'info' | 'warning';
effectiveTokenName: string;
helperText: string;
followOptionLabel: string;
followOptionDescription: string;
};
type TokenBindingConnectionMode = 'session' | 'apikey' | 'oauth';
type TokenBindingContext = {
connectionMode?: TokenBindingConnectionMode;
accountName?: string | null;
};
function parseExtraConfigHints(extraConfig?: string | null): {
credentialMode: Extract<TokenBindingConnectionMode, 'session' | 'apikey'> | null;
hasOauthProvider: boolean;
} {
if (typeof extraConfig !== 'string' || !extraConfig.trim()) {
return {
credentialMode: null,
hasOauthProvider: false,
};
}
try {
const parsed = JSON.parse(extraConfig) as {
credentialMode?: unknown;
oauth?: { provider?: unknown } | null;
};
const raw = String(parsed?.credentialMode || '').trim().toLowerCase();
return {
credentialMode: raw === 'session' || raw === 'apikey' ? raw : null,
hasOauthProvider: typeof parsed?.oauth?.provider === 'string' && parsed.oauth.provider.trim().length > 0,
};
} catch {}
return {
credentialMode: null,
hasOauthProvider: false,
};
}
function buildDirectBindingPresentation(
connectionMode: Extract<TokenBindingConnectionMode, 'apikey' | 'oauth'>,
accountName: string,
): TokenBindingPresentation {
if (connectionMode === 'oauth') {
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'OAuth授权',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」的 OAuth 授权,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(OAuth 授权)`,
followOptionDescription: `直接使用连接「${accountName}」的 OAuth 授权`,
};
}
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'API令牌',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」保存的 API Key,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(跟随 API Key 设置)`,
followOptionDescription: `直接使用连接「${accountName}」保存的 API Key`,
};
}
export function resolveTokenBindingConnectionMode(account?: {
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
} | null): TokenBindingConnectionMode {
const parsedHints = parseExtraConfigHints(account?.extraConfig);
if (parsedHints.hasOauthProvider) return 'oauth';
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'session') return 'session';
if (rawMode === 'apikey') return 'apikey';
if (parsedHints.credentialMode) return parsedHints.credentialMode;
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
return options.find((option) => option.isDefault) || null;
}
export function describeTokenBinding(
options: TokenBindingOption[],
activeTokenId: number,
fallbackTokenName?: string | null,
context: TokenBindingContext = {},
): TokenBindingPresentation {
const defaultToken = getDefaultTokenOption(options);
const selectedToken = activeTokenId
? options.find((option) => option.id === activeTokenId) || null
: null;
const connectionMode = context.connectionMode || 'session';
const accountName = String(context.accountName || '').trim() || '当前连接';
if (!activeTokenId) {
if (connectionMode === 'apikey' || connectionMode === 'oauth') {
return buildDirectBindingPresentation(connectionMode, accountName);
}
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
return {
isFollowingAccountDefault: true,
bindingModeLabel: '跟随账号默认',
badgeTone: 'info',
effectiveTokenName,
helperText: defaultToken
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
: '跟随账号默认。当前账号还没有默认令牌。',
followOptionLabel: '跟随账号默认',
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
};
}
const effectiveTokenName = selectedToken?.name || fallbackTokenName || `token-${activeTokenId}`;
return {
isFollowingAccountDefault: false,
bindingModeLabel: '固定令牌',
badgeTone: 'warning',
effectiveTokenName,
helperText: selectedToken?.isDefault
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
followOptionLabel: connectionMode === 'oauth'
? 'OAuth授权'
: (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'),
followOptionDescription: connectionMode === 'oauth'
? `直接使用连接「${accountName}」的 OAuth 授权`
: (connectionMode === 'apikey'
? `直接使用连接「${accountName}」保存的 API Key`
: (defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换')),
};
}
export function buildFixedTokenOptionLabel(
token: TokenBindingOption,
options: {
includeDefaultTag?: boolean;
includeSourceModel?: boolean;
} = {},
): string {
let label = `固定使用:${token.name}`;
if (options.includeDefaultTag && token.isDefault) {
label += '(当前账号默认)';
}
if (options.includeSourceModel && token.sourceModel) {
label += ` [${token.sourceModel}]`;
}
return label;
}
export function buildFixedTokenOptionDescription(token: TokenBindingOption): string {
return token.isDefault
? '固定到这条令牌;它目前也是账号默认,但以后不会自动跟随'
: '固定到这条令牌;不随账号默认变化';
}
-194
View File
@@ -1,194 +0,0 @@
import type { ButtonHTMLAttributes, ReactNode, RefCallback } from 'react';
import type { BrandInfo } from '../../components/BrandIcon.js';
import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
export type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export type OAuthRouteUnitStrategy = 'round_robin' | 'stick_until_unavailable';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteChannelDraft = {
accountId: number;
tokenId: number;
sourceModel: string;
};
export type RouteChannelRouteUnitMember = {
accountId: number;
username: string | null;
siteName: string | null;
};
export type RouteChannelRouteUnit = {
id: number | string;
name: string | null;
strategy: OAuthRouteUnitStrategy;
memberCount: number;
members?: RouteChannelRouteUnitMember[];
};
export type RouteChannel = {
id: number;
routeId?: number;
accountId: number;
tokenId: number | null;
sourceModel?: string | null;
priority: number;
weight: number;
enabled: boolean;
manualOverride: boolean;
successCount: number;
failCount: number;
cooldownUntil?: string | null;
account?: {
username: string | null;
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
};
site?: {
id: number;
name: string | null;
platform: string | null;
};
token?: {
id: number;
name: string;
accountId: number;
enabled: boolean;
isDefault: boolean;
} | null;
oauthRouteUnitId?: number | null;
routeUnit?: RouteChannelRouteUnit | null;
};
export type RouteRow = {
id: number;
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
routeMode?: RouteMode | null;
sourceRouteIds?: number[];
modelMapping?: string | null;
routingStrategy?: RouteRoutingStrategy | null;
decisionSnapshot?: RouteDecision | null;
decisionRefreshedAt?: string | null;
enabled: boolean;
channels: RouteChannel[];
};
export type RouteSummaryRow = {
id: number;
modelPattern: string;
displayName: string | null;
displayIcon: string | null;
routeMode?: RouteMode | null;
sourceRouteIds?: number[];
modelMapping: string | null;
routingStrategy?: RouteRoutingStrategy | null;
enabled: boolean;
channelCount: number;
enabledChannelCount: number;
siteNames: string[];
decisionSnapshot: RouteDecision | null;
decisionRefreshedAt: string | null;
kind?: RouteRowKind;
readOnly?: boolean;
isVirtual?: boolean;
};
export type ChannelDecisionState = {
probability: number;
showBar: boolean;
reasonText: string;
reasonColor: string;
};
export type RouteTokenOption = {
id: number;
name: string;
isDefault: boolean;
sourceModel?: string;
};
export type RouteIconOption = {
value: string;
label: string;
description?: string;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
export type MissingTokenRouteSiteActionItem = {
key: string;
siteName: string;
accountId: number;
accountLabel: string;
};
export type MissingTokenGroupRouteSiteActionItem = {
key: string;
siteName: string;
accountId: number;
accountLabel: string;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
groupCoverageUncertain?: boolean;
};
export type SortableChannelRowProps = {
channel: RouteChannel;
displayPriority?: number;
showPriorityBadge?: boolean;
dragging?: boolean;
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>;
dragHandleRef?: RefCallback<HTMLButtonElement>;
decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean;
loadingDecision: boolean;
isSavingPriority: boolean;
readOnly?: boolean;
channelManagementDisabled?: boolean;
dragInProgress?: boolean;
mobile?: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;
isUpdatingToken: boolean;
onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: () => void;
onDeleteChannel: () => void;
onToggleEnabled: (enabled: boolean) => void;
onSiteBlockModel?: () => void;
};
export type GroupRouteItem = {
id: number;
title: string;
icon: { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
brand: BrandInfo | null;
modelPattern: string;
channelCount: number;
sourceRouteCount: number;
};
export type PriorityRailSection = {
priority: number;
channelCount: number;
channelIds: number[];
};
export type PriorityRailDragTarget =
| {
kind: 'existing_layer';
priority: number;
highlighted: boolean;
}
| {
kind: 'new_layer';
priority: number;
highlighted: boolean;
};
@@ -1,50 +0,0 @@
import { useState, useCallback, useRef } from 'react';
import { api } from '../../api.js';
import { normalizeChannels } from './utils.js';
import type { RouteChannel } from './types.js';
export function useRouteChannels() {
const [channelsByRouteId, setChannelsByRouteId] = useState<Record<number, RouteChannel[]>>({});
const [loadingChannelsByRouteId, setLoadingChannelsByRouteId] = useState<Record<number, boolean>>({});
const channelsByRouteIdRef = useRef(channelsByRouteId);
channelsByRouteIdRef.current = channelsByRouteId;
const loadChannels = useCallback(async (routeId: number, force = false) => {
if (!force && channelsByRouteIdRef.current[routeId]) return channelsByRouteIdRef.current[routeId];
setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: true }));
try {
const channels = await api.getRouteChannels(routeId);
const sorted = normalizeChannels(channels || []);
setChannelsByRouteId((prev) => ({ ...prev, [routeId]: sorted }));
return sorted;
} catch (error) {
console.error(`Failed to load channels for route ${routeId}:`, error);
throw error;
} finally {
setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: false }));
}
}, []);
const invalidateChannels = useCallback((routeId?: number) => {
setChannelsByRouteId((prev) => {
if (routeId === undefined) {
return {};
}
const next = { ...prev };
delete next[routeId];
return next;
});
}, []);
const setChannels = useCallback((routeId: number, channels: RouteChannel[]) => {
setChannelsByRouteId((prev) => ({ ...prev, [routeId]: channels }));
}, []);
return {
channelsByRouteId,
loadingChannelsByRouteId,
loadChannels,
invalidateChannels,
setChannels,
};
}
@@ -1,8 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isExactModelPattern } from './utils.js';
describe('isExactModelPattern', () => {
it('treats bracket-prefixed literal model names as exact patterns', () => {
expect(isExactModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true);
});
});
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../components/BrandIcon.js', () => ({
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon.trim().toLowerCase(),
}));
import {
ROUTE_ICON_NONE_VALUE,
normalizeRouteDisplayIconValue,
resolveRouteIcon,
} from './utils.js';
describe('token route icon helpers', () => {
it('preserves the explicit no-icon sentinel during normalization', () => {
expect(normalizeRouteDisplayIconValue(ROUTE_ICON_NONE_VALUE)).toBe(ROUTE_ICON_NONE_VALUE);
});
it('treats the explicit no-icon sentinel as no icon', () => {
expect(resolveRouteIcon({ displayIcon: ROUTE_ICON_NONE_VALUE })).toEqual({ kind: 'none' });
});
});
-293
View File
@@ -1,293 +0,0 @@
import type { CSSProperties } from 'react';
import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
import { normalizeTokenRouteMode, type RouteDecisionCandidate, type RouteMode } from '../../../shared/tokenRouteContract.js';
import type { RouteRow, RouteChannel, ChannelDecisionState, RouteSummaryRow } from './types.js';
import {
isExactTokenRouteModelPattern,
isTokenRouteRegexPattern,
matchesTokenRouteModelPattern,
parseTokenRouteRegexPattern,
} from '../../../shared/tokenRoutePatterns.js';
export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__';
export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
openai: 'chatgpt',
gemini: 'gemini',
anthropic: 'claude',
anthroic: 'claude',
claude: 'claude',
};
export const PLATFORM_ENDPOINT_FALLBACK_MAP: Record<string, string[]> = {
openai: ['openai'],
'new-api': ['openai'],
'one-api': ['openai'],
'one-hub': ['openai'],
'done-hub': ['openai'],
sub2api: ['openai'],
veloera: ['openai'],
cliproxyapi: ['openai'],
claude: ['anthropic'],
gemini: ['gemini'],
anyrouter: ['openai', 'anthropic'],
};
export const PLATFORM_ALIASES: Record<string, string> = {
anthropic: 'claude',
google: 'gemini',
'new api': 'new-api',
newapi: 'new-api',
'one api': 'one-api',
oneapi: 'one-api',
};
export function isRegexModelPattern(modelPattern: string): boolean {
return isTokenRouteRegexPattern(modelPattern);
}
export function isExactModelPattern(modelPattern: string): boolean {
return isExactTokenRouteModelPattern(modelPattern);
}
export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode {
return normalizeTokenRouteMode(routeMode);
}
export function isExplicitGroupRoute(route: Pick<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'modelPattern' | 'routeMode'>): boolean {
return !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern);
}
export function parseRegexModelPattern(modelPattern: string): { regex: { test(value: string): boolean } | null; error: string | null } {
return parseTokenRouteRegexPattern(modelPattern);
}
export function matchesModelPattern(model: string, pattern: string): boolean {
return matchesTokenRouteModelPattern(model, pattern);
}
export function getModelPatternError(modelPattern: string): string | null {
const normalized = modelPattern.trim();
if (!normalized) return null;
if (!isRegexModelPattern(normalized)) return null;
const parsed = parseRegexModelPattern(normalized);
if (!parsed.error) return null;
return `模型匹配正则错误:${parsed.error}`;
}
export function resolveRouteTitle(route: Pick<RouteRow | RouteSummaryRow, 'displayName' | 'modelPattern'>): string {
const title = (route.displayName || '').trim();
return title || route.modelPattern;
}
export function resolveRouteBrand(route: Pick<RouteRow | RouteSummaryRow, 'displayName' | 'modelPattern'>): BrandInfo | null {
const displayName = (route.displayName || '').trim();
if (displayName) {
const byDisplayName = getBrand(displayName);
if (byDisplayName) return byDisplayName;
}
return getBrand(route.modelPattern);
}
export function toBrandIconValue(icon: string): string {
return `${ROUTE_BRAND_ICON_PREFIX}${icon}`;
}
export function parseBrandIconValue(raw: string): string | null {
const normalized = (raw || '').trim();
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
return normalizeBrandIconKey(icon);
}
export function isRouteIconNoneValue(raw: string | null | undefined): boolean {
return (raw || '').trim() === ROUTE_ICON_NONE_VALUE;
}
export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim();
if (isRouteIconNoneValue(normalized)) return ROUTE_ICON_NONE_VALUE;
const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon);
return normalized;
}
export function resolveEndpointTypeIconModel(endpointType: string): string | null {
const key = String(endpointType || '').trim().toLowerCase();
if (!key) return null;
return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null;
}
export function normalizePlatformKey(platform: string | null | undefined): string {
const raw = String(platform || '').trim().toLowerCase();
if (!raw) return '';
return PLATFORM_ALIASES[raw] || raw;
}
export function inferEndpointTypesFromPlatform(platform: string | null | undefined): string[] {
const key = normalizePlatformKey(platform);
if (!key) return [];
const mapped = PLATFORM_ENDPOINT_FALLBACK_MAP[key];
if (Array.isArray(mapped) && mapped.length > 0) return mapped;
if (key.includes('claude') || key.includes('anthropic')) return ['anthropic'];
if (key.includes('gemini')) return ['gemini'];
if (key.includes('openai') || key.includes('new-api') || key.includes('one-api')) return ['openai'];
return [];
}
export function siteAvatarLetters(siteName: string): string {
const normalized = String(siteName || '').trim();
if (!normalized) return 'S';
const parts = normalized.replace(/[-_/.]/g, ' ').split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
const compact = normalized.replace(/\s+/g, '');
return compact.slice(0, 2).toUpperCase();
}
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim();
if (isRouteIconNoneValue(icon)) return { kind: 'none' };
if (!icon) return { kind: 'auto' };
const brandIcon = parseBrandIconValue(icon);
if (brandIcon) return { kind: 'brand', value: brandIcon };
return { kind: 'text', value: icon };
}
export function normalizeChannels(channels: RouteChannel[]): RouteChannel[] {
return [...(channels || [])].sort((a, b) => {
const pa = a.priority ?? 0;
const pb = b.priority ?? 0;
if (pa === pb) return (a.id ?? 0) - (b.id ?? 0);
return pa - pb;
});
}
export function normalizeRoutes(routeRows: any[]): RouteRow[] {
return (routeRows || []).map((route) => ({
...(route as RouteRow),
channels: normalizeChannels(route.channels || []),
}));
}
export function buildSourceGroupKey(routeId: number, sourceModel: string): string {
const normalizedSourceModel = sourceModel.trim() || '__ungrouped__';
return `${routeId}::${normalizedSourceModel}`;
}
export function getPriorityTagStyle(priority: number): CSSProperties {
if (priority <= 0) {
return {
background: 'color-mix(in srgb, var(--color-success) 16%, transparent)',
color: 'var(--color-success)',
};
}
if (priority === 1) {
return {
background: 'color-mix(in srgb, var(--color-info) 16%, transparent)',
color: 'var(--color-info)',
};
}
return {
background: 'rgba(100,116,139,0.18)',
color: 'var(--color-text-secondary)',
};
}
export function getProbabilityColor(probability: number): string {
if (probability >= 80) return 'var(--color-success)';
if (probability >= 60) return 'color-mix(in srgb, var(--color-success) 50%, var(--color-warning))';
if (probability >= 40) return 'var(--color-warning)';
if (probability >= 20) return 'color-mix(in srgb, var(--color-warning) 45%, var(--color-danger))';
if (probability > 0) return 'var(--color-danger)';
return 'var(--color-border)';
}
export function getChannelDecisionState(
candidate: RouteDecisionCandidate | undefined,
channel: RouteChannel,
isExactRoute: boolean,
loadingDecision: boolean,
): ChannelDecisionState {
if (!isExactRoute && !candidate) {
return {
probability: 0,
showBar: true,
reasonText: loadingDecision ? '计算中...' : '实时决策',
reasonColor: 'var(--color-text-muted)',
};
}
if (!candidate) {
return {
probability: 0,
showBar: true,
reasonText: loadingDecision ? '计算中...' : '无可用通道',
reasonColor: 'var(--color-text-muted)',
};
}
if (candidate.avoidedByRecentFailure) {
return {
probability: 0,
showBar: true,
reasonText: '失败避让',
reasonColor: 'var(--color-warning)',
};
}
if (!candidate.eligible) {
const nowIso = new Date().toISOString();
const cooldownActive = !!channel.cooldownUntil && channel.cooldownUntil > nowIso;
if (cooldownActive || candidate.reason.includes('冷却中')) {
return {
probability: 0,
showBar: true,
reasonText: '冷却中',
reasonColor: 'var(--color-danger)',
};
}
return {
probability: 0,
showBar: true,
reasonText: candidate.reason || '不可用',
reasonColor: 'var(--color-text-muted)',
};
}
const probability = Number(candidate.probability || 0);
if (probability <= 0) {
if (candidate.recentlyFailed) {
return {
probability: 0,
showBar: false,
reasonText: '近期失败',
reasonColor: 'var(--color-warning)',
};
}
return {
probability: 0,
showBar: false,
reasonText: candidate.reason || '概率为 0%',
reasonColor: 'var(--color-text-muted)',
};
}
return {
probability,
showBar: true,
reasonText: '',
reasonColor: 'var(--color-text-muted)',
};
}