forked from boos/BoosAPI
[codex] add route priority bucket editor (#350)
* feat: add route priority bucket editor * fix: address route editor review comments
This commit is contained in:
+105
-12
@@ -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()}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user