[codex] add route priority bucket editor (#350)

* feat: add route priority bucket editor

* fix: address route editor review comments
This commit is contained in:
Cita
2026-03-31 21:23:02 +08:00
committed by GitHub
parent baa11d7787
commit e74e11375c
12 changed files with 763 additions and 192 deletions
+105 -12
View File
@@ -1,6 +1,5 @@
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { arrayMove } from '@dnd-kit/sortable';
import type { DragEndEvent } from '@dnd-kit/core';
import { api } from '../api.js';
import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../components/BrandIcon.js';
@@ -45,8 +44,8 @@ import type {
import {
AUTO_ROUTE_DECISION_LIMIT,
ROUTE_RENDER_CHUNK,
isExactModelPattern,
isExplicitGroupRoute,
isExactModelPattern,
isRouteExactModel,
matchesModelPattern,
normalizeRouteMode,
@@ -58,6 +57,7 @@ import {
inferEndpointTypesFromPlatform,
getModelPatternError,
} from './token-routes/utils.js';
import { applyPriorityBucketDrag, splitPriorityBucketAfterChannel } from './token-routes/priorityBuckets.js';
import { useRouteChannels } from './token-routes/useRouteChannels.js';
import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js';
import ManualRoutePanel from './token-routes/ManualRoutePanel.js';
@@ -999,17 +999,41 @@ export default function TokenRoutes() {
const { active, over } = event;
if (!over || active.id === over.id) return;
const route = routeSummaries.find((item) => item.id === routeId);
if (!route) return;
const channels = channelsByRouteId[routeId] || [];
const oldIndex = channels.findIndex((channel) => channel.id === Number(active.id));
const newIndex = channels.findIndex((channel) => channel.id === Number(over.id));
const reordered = applyPriorityBucketDrag(channels, active.id, over.id);
const changedChannels = reordered.filter((channel) => {
const previous = channels.find((item) => item.id === channel.id);
return (previous?.priority ?? 0) !== channel.priority;
});
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return;
if (changedChannels.length === 0) return;
const previousChannels = [...channels];
const reordered = arrayMove(channels, oldIndex, newIndex).map((channel, index) => ({
...channel,
priority: index,
}));
if (isExplicitGroupRoute(route)) {
const changedSourceRouteIds = Array.from(new Set(
changedChannels
.map((channel) => channel.routeId)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0),
));
if (changedSourceRouteIds.length > 0) {
const affectedGroups = routeSummaries.filter((candidate) => (
candidate.id !== route.id
&& isExplicitGroupRoute(candidate)
&& (candidate.sourceRouteIds || []).some((sourceRouteId) => changedSourceRouteIds.includes(sourceRouteId))
));
if (affectedGroups.length > 0) {
const affectedNames = affectedGroups.map((candidate) => resolveRouteTitle(candidate));
const confirmFn = typeof globalThis.confirm === 'function' ? globalThis.confirm : null;
const confirmed = !confirmFn
|| confirmFn(`当前群组的优先级桶会直接回写来源通道,并同步影响:${affectedNames.join('、')}。是否继续?`);
if (!confirmed) return;
}
}
}
const previousChannels = channels.map((channel) => ({ ...channel }));
setChannels(routeId, reordered);
setSavingPriorityByRoute((prev) => ({ ...prev, [routeId]: true }));
@@ -1022,7 +1046,6 @@ export default function TokenRoutes() {
})),
);
const route = routeSummaries.find((r) => r.id === routeId);
if (route && isRouteExactModel(route)) {
try {
const res = await api.getRouteDecision(route.modelPattern);
@@ -1042,6 +1065,74 @@ export default function TokenRoutes() {
}
};
const handleSplitPriorityBucket = async (routeId: number, channelId: number) => {
if (savingPriorityByRoute[routeId]) return;
const route = routeSummaries.find((item) => item.id === routeId);
if (!route) return;
const channels = channelsByRouteId[routeId] || [];
const reordered = splitPriorityBucketAfterChannel(channels, channelId);
const changedChannels = reordered.filter((channel) => {
const previous = channels.find((item) => item.id === channel.id);
return (previous?.priority ?? 0) !== channel.priority;
});
if (changedChannels.length === 0) return;
if (isExplicitGroupRoute(route)) {
const changedSourceRouteIds = Array.from(new Set(
changedChannels
.map((channel) => channel.routeId)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0),
));
if (changedSourceRouteIds.length > 0) {
const affectedGroups = routeSummaries.filter((candidate) => (
candidate.id !== route.id
&& isExplicitGroupRoute(candidate)
&& (candidate.sourceRouteIds || []).some((sourceRouteId) => changedSourceRouteIds.includes(sourceRouteId))
));
if (affectedGroups.length > 0) {
const affectedNames = affectedGroups.map((candidate) => resolveRouteTitle(candidate));
const confirmFn = typeof globalThis.confirm === 'function' ? globalThis.confirm : null;
const confirmed = !confirmFn
|| confirmFn(`当前群组的优先级桶会直接回写来源通道,并同步影响:${affectedNames.join('、')}。是否继续?`);
if (!confirmed) return;
}
}
}
const previousChannels = channels.map((channel) => ({ ...channel }));
setChannels(routeId, reordered);
setSavingPriorityByRoute((prev) => ({ ...prev, [routeId]: true }));
try {
await api.batchUpdateChannels(
reordered.map((channel) => ({
id: channel.id,
priority: channel.priority,
})),
);
if (route && isRouteExactModel(route)) {
try {
const res = await api.getRouteDecision(route.modelPattern);
setDecisionByRoute((prev) => ({
...prev,
[routeId]: (res?.decision || null) as RouteDecision | null,
}));
} catch {
// ignore route decision refresh failures after split
}
}
} catch (e: any) {
setChannels(routeId, previousChannels);
toast.error(e.message || '保存通道优先级失败,已回滚');
} finally {
setSavingPriorityByRoute((prev) => ({ ...prev, [routeId]: false }));
}
};
const handleSiteBlockModel = async (channelId: number, routeId: number) => {
const channels = channelsByRouteId[routeId] || [];
const channel = channels.find((c) => c.id === channelId);
@@ -1454,7 +1545,7 @@ export default function TokenRoutes() {
{/* Info tip */}
<div className="info-tip" style={{ marginBottom: 12 }}>
{tr('系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
</div>
{/* Manual route panel */}
@@ -1625,6 +1716,7 @@ export default function TokenRoutes() {
onDeleteChannel={stableDeleteChannel}
onToggleChannelEnabled={stableToggleChannelEnabled}
onChannelDragEnd={stableChannelDragEnd}
onSplitPriorityBucket={handleSplitPriorityBucket}
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
onCreateTokenForMissing={stableCreateTokenForMissing}
@@ -1663,6 +1755,7 @@ export default function TokenRoutes() {
onDeleteChannel={stableDeleteChannel}
onToggleChannelEnabled={stableToggleChannelEnabled}
onChannelDragEnd={stableChannelDragEnd}
onSplitPriorityBucket={handleSplitPriorityBucket}
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
onCreateTokenForMissing={stableCreateTokenForMissing}
@@ -56,6 +56,7 @@ describe('RouteCard', () => {
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
onSplitPriorityBucket={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
+106 -133
View File
@@ -28,6 +28,7 @@ import type {
} from './types.js';
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import { SortableChannelRow } from './SortableChannelRow.js';
import { SortableBucketSeparator } from './SortableBucketSeparator.js';
import {
getRouteRoutingStrategyLabel,
normalizeRouteRoutingStrategyValue,
@@ -37,8 +38,11 @@ import {
isExplicitGroupRoute,
resolveRouteTitle,
resolveRouteIcon,
buildSourceGroupKey,
} from './utils.js';
import {
buildPriorityBucketEditorItems,
buildPriorityBuckets,
} from './priorityBuckets.js';
type RouteCardProps = {
route: RouteSummaryRow;
@@ -67,6 +71,7 @@ type RouteCardProps = {
onDeleteChannel: (channelId: number, routeId: number) => void;
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
onSplitPriorityBucket: (routeId: number, channelId: number) => void;
// Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
@@ -116,6 +121,7 @@ function RouteCardInner({
onDeleteChannel,
onToggleChannelEnabled,
onChannelDragEnd,
onSplitPriorityBucket,
missingTokenSiteItems,
missingTokenGroupItems,
onCreateTokenForMissing,
@@ -159,25 +165,8 @@ function RouteCardInner({
(routeDecision?.candidates || []).map((c) => [c.channelId, c]),
);
const channelGroups = (() => {
if (!channels || channels.length === 0) return [];
const groups = new Map<string, RouteChannel[]>();
for (const channel of channels) {
const key = (channel.sourceModel || '').trim() || '__ungrouped__';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(channel);
}
return Array.from(groups.entries())
.sort((a, b) => {
if (a[0] === '__ungrouped__') return 1;
if (b[0] === '__ungrouped__') return -1;
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
})
.map(([sourceModel, chans]) => ({
sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel,
channels: chans,
}));
})();
const priorityBuckets = buildPriorityBuckets(channels || []);
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
// Collapsed card
if (!expanded) {
@@ -400,11 +389,11 @@ function RouteCardInner({
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;当前策略以群组设置为准,来源模型会尽量跟随同步,但已单独自定义或被其他群组复用的来源模型不会被覆盖。')}
{tr('该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。')}
</div>
) : !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
{tr('通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。')}
</div>
) : null}
@@ -487,120 +476,104 @@ function RouteCardInner({
) : channels && channels.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{channelGroups.map((group) => {
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
const supportsCollapse = !exactRoute && !!group.sourceModel;
const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
<SortableContext items={bucketEditorItems.map((item) => item.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{priorityBuckets.map((bucket, bucketIndex) => {
const sourceModelCount = new Set(
bucket.channels
.map((channel) => (channel.sourceModel || '').trim())
.filter(Boolean),
).size;
return (
<div key={`${route.id}-${group.sourceModel || '__ungrouped__'}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{group.sourceModel ? (
supportsCollapse ? (
<button
type="button"
onClick={() => onToggleSourceGroup(groupKey)}
aria-expanded={isGroupExpanded}
className="btn btn-ghost"
style={{
fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '4px 6px', border: '1px dashed var(--color-border)',
borderRadius: 'var(--radius-sm)', background: 'transparent',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span>来源模型</span>
<code style={{ fontSize: 11, border: '1px solid var(--color-border)', borderRadius: 6, padding: '2px 6px', background: 'var(--color-bg)' }}>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
return (
<div key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div
className="route-priority-bucket-header"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '4px 2px',
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{`P${bucketIndex}`}
</span>
<span>{`${bucket.channels.length} ${tr('通道')}`}</span>
{!exactRoute && sourceModelCount > 0 ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{`${sourceModelCount} ${tr('来源模型')}`}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-text-muted)' }}>
{isGroupExpanded ? '收起' : '展开'}
<svg width="12" height="12" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: isGroupExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</span>
</button>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 2 }}>
<span>来源模型</span>
<code style={{ fontSize: 11, border: '1px solid var(--color-border)', borderRadius: 6, padding: '2px 6px', background: 'var(--color-bg)' }}>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</div>
)
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
{explicitGroupRoute ? (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={!!savingPriority}
readOnly
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
/>
);
})}
</SortableContext>
) : (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
/>
);
})}
</SortableContext>
)}
</AnimatedCollapseSection>
{!isGroupExpanded && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
已收起,点击展开查看通道
) : null}
</div>
)}
</div>
);
})}
</div>
{bucket.channels.map((channel, channelIndex) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<div key={channel.id} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<SortableChannelRow
channel={channel}
displayPriority={bucketIndex}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
readOnly={readOnlyRoute}
channelManagementDisabled={channelManagementDisabled}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
onSiteBlockModel={channelManagementDisabled ? undefined : () => onSiteBlockModel(channel.id, route.id)}
/>
{!readOnlyRoute && channelIndex < bucket.channels.length - 1 ? (
<button
type="button"
className="btn btn-ghost"
onClick={() => onSplitPriorityBucket(route.id, channel.id)}
disabled={savingPriority}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
border: '1px dashed var(--color-border)',
borderRadius: 999,
background: 'transparent',
color: 'var(--color-text-muted)',
padding: '2px 10px',
fontSize: 11,
alignSelf: 'center',
}}
>
{`拆分为 P${bucketIndex + 1}`}
</button>
) : null}
</div>
);
})}
{bucketIndex < priorityBuckets.length - 1 ? (
<SortableBucketSeparator
id={String(bucketEditorItems.find((item) => item.kind === 'separator' && item.id === `priority-separator:${bucketIndex}`)?.id || `priority-separator:${bucketIndex}`)}
beforePriority={bucketIndex}
afterPriority={bucketIndex + 1}
isSavingPriority={savingPriority}
/>
) : null}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
</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>
);
}
@@ -13,11 +13,13 @@ import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } fro
export function SortableChannelRow({
channel,
displayPriority,
decisionCandidate,
isExactRoute,
loadingDecision,
isSavingPriority,
readOnly = false,
channelManagementDisabled = false,
mobile = false,
tokenOptions,
activeTokenId,
@@ -41,13 +43,16 @@ export function SortableChannelRow({
disabled: isSavingPriority || readOnly,
});
const resolvedPriority = displayPriority ?? channel.priority ?? 0;
const managementLocked = readOnly || channelManagementDisabled;
const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : 1,
display: 'grid',
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center',
gap: 8,
padding: mobile ? '10px 12px' : '8px 12px',
@@ -93,8 +98,8 @@ export function SortableChannelRow({
opacity: readOnly ? 0.65 : 1,
marginTop: 2,
}}
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
aria-label="拖拽调整优先级"
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
@@ -114,10 +119,10 @@ export function SortableChannelRow({
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(channel.priority ?? 0),
...getPriorityTagStyle(resolvedPriority),
}}
>
P{channel.priority ?? 0}
P{resolvedPriority}
</span>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
@@ -218,7 +223,7 @@ export function SortableChannelRow({
</span>
</div>
{!readOnly && (
{!managementLocked && (
<button
type="button"
className="btn btn-link"
@@ -230,7 +235,7 @@ export function SortableChannelRow({
)}
</div>
{!readOnly && mobileDetailsOpen && (
{!managementLocked && mobileDetailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ width: '100%' }}>
<ModernSelect
@@ -317,8 +322,8 @@ export function SortableChannelRow({
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
}}
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
aria-label="拖拽调整优先级"
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
@@ -336,10 +341,10 @@ export function SortableChannelRow({
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(channel.priority ?? 0),
...getPriorityTagStyle(resolvedPriority),
}}
>
P{channel.priority ?? 0}
P{resolvedPriority}
</span>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
@@ -444,7 +449,7 @@ export function SortableChannelRow({
</div>
</div>
{!readOnly ? (
{!managementLocked ? (
mobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
<div style={{ width: '100%' }}>
@@ -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));
}
+3
View File
@@ -16,6 +16,7 @@ export type RouteChannelDraft = {
export type RouteChannel = {
id: number;
routeId?: number;
accountId: number;
tokenId: number | null;
sourceModel?: string | null;
@@ -124,11 +125,13 @@ export type MissingTokenGroupRouteSiteActionItem = {
export type SortableChannelRowProps = {
channel: RouteChannel;
displayPriority?: number;
decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean;
loadingDecision: boolean;
isSavingPriority: boolean;
readOnly?: boolean;
channelManagementDisabled?: boolean;
mobile?: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;
+158 -33
View File
@@ -14,6 +14,7 @@ const { apiMock, getBrandMock } = vi.hoisted(() => ({
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
addRoute: vi.fn(),
batchUpdateChannels: vi.fn(),
},
getBrandMock: vi.fn(),
}));
@@ -94,15 +95,18 @@ describe('TokenRoutes grouped source models', () => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
vi.stubGlobal('confirm', vi.fn(() => true));
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
apiMock.addRoute.mockResolvedValue({});
apiMock.batchUpdateChannels.mockResolvedValue({ success: true, channels: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
@@ -154,18 +158,18 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('collapses source-model groups by default for wildcard routes', async () => {
it('renders wildcard route channels in priority buckets and keeps source models as row badges', async () => {
const channels = [
{
id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
id: 11, routeId: 1, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
priority: 0, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'user_a' }, site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
{
id: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
priority: 0, weight: 1, enabled: true, manualOverride: false,
id: 12, routeId: 1, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
priority: 1, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'user_b' }, site: { name: 'site-b' },
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
@@ -209,37 +213,66 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
// After expansion, source models are visible but groups collapsed for wildcard
// After expansion, channels render in route-global buckets instead of source-model subgroups
const expandedText = collectText(root.root);
expect(expandedText).toContain('P0');
expect(expandedText).toContain('P1');
expect(expandedText).toContain('user_a');
expect(expandedText).toContain('user_b');
expect(expandedText).toContain('claude-opus-4-5');
expect(expandedText).toContain('claude-opus-4-6');
// Users are inside collapsed source groups
expect(expandedText).not.toContain('user_a');
expect(expandedText).not.toContain('user_b');
} finally {
root?.unmount();
}
});
it('expands a source-model group after user click', async () => {
const channels = [
it('writes explicit-group priority edits back to source channels and confirms shared-source impact', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 21, modelPattern: 'claude-proxy-a', displayName: 'claude-proxy-a',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 22, modelPattern: 'claude-proxy-b', displayName: 'claude-proxy-b',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [12],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 101, routeId: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
priority: 0, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'user_a' }, site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
];
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
id: 102, routeId: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-sonnet-4-5',
priority: 1, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'user_b' }, site: { name: 'site-b' },
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
},
]);
apiMock.getRouteChannels.mockResolvedValue(channels);
let root!: WebTestRenderer;
try {
@@ -254,24 +287,121 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
// Expand the card first
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('claude-proxy-a'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).not.toContain('user_a');
const dragContext = root.root.find((node) => typeof node.props?.onDragEnd === 'function');
expect(dragContext).toBeTruthy();
const toggleButton = findButtonByText(root.root, 'claude-opus-4-5');
await act(async () => {
toggleButton.props.onClick();
await dragContext.props.onDragEnd({
active: { id: 102 },
over: { id: 101 },
});
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('user_a');
expect(globalThis.confirm).toHaveBeenCalledWith(expect.stringContaining('claude-proxy-b'));
expect(apiMock.batchUpdateChannels).toHaveBeenCalledWith([
{ id: 101, priority: 0 },
{ id: 102, priority: 0 },
]);
} finally {
root?.unmount();
}
});
it('does not rewrite shared-source priorities when the confirmation is cancelled', async () => {
vi.stubGlobal('confirm', vi.fn(() => false));
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 21, modelPattern: 'claude-proxy-a', displayName: 'claude-proxy-a',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 22, modelPattern: 'claude-proxy-b', displayName: 'claude-proxy-b',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [12],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 101, routeId: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
priority: 0, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'user_a' }, site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
{
id: 102, routeId: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-sonnet-4-5',
priority: 1, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'user_b' }, site: { name: 'site-b' },
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandBtn = root.root.find((node) =>
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('claude-proxy-a'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const dragContext = root.root.find((node) => typeof node.props?.onDragEnd === 'function');
await act(async () => {
await dragContext.props.onDragEnd({
active: { id: 102 },
over: { id: 101 },
});
});
await flushMicrotasks();
expect(globalThis.confirm).toHaveBeenCalledWith(expect.stringContaining('claude-proxy-b'));
expect(apiMock.batchUpdateChannels).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('P1');
} finally {
root?.unmount();
}
@@ -1376,7 +1506,7 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('reuses the standard channel row presentation for explicit-group details in read-only mode', async () => {
it('reuses the standard channel row presentation for explicit-group details while keeping channel management hidden', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null,
@@ -1435,18 +1565,13 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick();
});
await flushMicrotasks();
const expandedText = collectText(root.root);
expect(expandedText).toContain('P0');
expect(expandedText).toContain('当前生效:token-a');
expect(expandedText).toContain('选中概率');
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true);
expect(expandedText).not.toContain('保存');
expect(expandedText).not.toContain('移除');
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级桶').props.disabled).toBe(false);
expect(root.root.findAll((node) => node.type === 'button' && collectText(node).trim() === '保存')).toHaveLength(0);
expect(root.root.findAll((node) => node.type === 'button' && collectText(node).trim() === '移除')).toHaveLength(0);
} finally {
root?.unmount();
}