forked from boos/BoosAPI
fix: restore frontend source code (src/web/) that was accidentally deleted
Frontend source is needed for development/building the SPA. Only Node.js runtime server code should be removed.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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
@@ -0,0 +1,125 @@
|
||||
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
@@ -0,0 +1,132 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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('该路由当前不可编辑优先级');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,591 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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('只要更高优先级还有可用通道,后面的通道本次就不会参与选择。');
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
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('以后不会自动跟随');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
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
|
||||
? '固定到这条令牌;它目前也是账号默认,但以后不会自动跟随'
|
||||
: '固定到这条令牌;不随账号默认变化';
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
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)',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user