[codex] add route priority bucket editor (#350)
* feat: add route priority bucket editor * fix: address route editor review comments
This commit is contained in:
@@ -155,6 +155,36 @@ describe('PUT /api/channels/batch', () => {
|
|||||||
expect(dbB?.manualOverride).toBe(true);
|
expect(dbB?.manualOverride).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves duplicate priorities in batch updates', async () => {
|
||||||
|
const channelA = await seedChannel({ priority: 9, weight: 17, manualOverride: false });
|
||||||
|
const channelB = await seedChannel({ priority: 8, weight: 23, manualOverride: false });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/channels/batch',
|
||||||
|
payload: {
|
||||||
|
updates: [
|
||||||
|
{ id: channelA.id, priority: 0 },
|
||||||
|
{ id: channelB.id, priority: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
success: boolean;
|
||||||
|
channels: Array<{ id: number; priority: number; weight: number; manualOverride: boolean }>;
|
||||||
|
};
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.channels.find((channel) => channel.id === channelA.id)?.priority).toBe(0);
|
||||||
|
expect(body.channels.find((channel) => channel.id === channelB.id)?.priority).toBe(0);
|
||||||
|
|
||||||
|
const dbA = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
|
||||||
|
const dbB = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
|
||||||
|
expect(dbA?.priority).toBe(0);
|
||||||
|
expect(dbB?.priority).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports the number of routes actually updated in route batch operations', async () => {
|
it('reports the number of routes actually updated in route batch operations', async () => {
|
||||||
const route = await db.insert(schema.tokenRoutes).values({
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: 'gpt-4o-mini',
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'退出登录': 'Log out',
|
'退出登录': 'Log out',
|
||||||
'拖拽 JSON 备份文件到此处': 'Drag and drop the JSON backup file here',
|
'拖拽 JSON 备份文件到此处': 'Drag and drop the JSON backup file here',
|
||||||
'拖拽调整优先级': 'Drag and drop to adjust priority',
|
'拖拽调整优先级': 'Drag and drop to adjust priority',
|
||||||
|
'拖拽调整优先级桶': 'Drag and drop to adjust priority buckets',
|
||||||
'唯一可用候选': 'The only available candidate',
|
'唯一可用候选': 'The only available candidate',
|
||||||
'未登录': 'Not logged in',
|
'未登录': 'Not logged in',
|
||||||
'未登录且未提供 access token': 'Not logged in and no access token provided',
|
'未登录且未提供 access token': 'Not logged in and no access token provided',
|
||||||
@@ -568,7 +569,9 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'无需操作': 'No action required',
|
'无需操作': 'No action required',
|
||||||
'无需重复执行': 'No need to repeat',
|
'无需重复执行': 'No need to repeat',
|
||||||
'系统': 'system',
|
'系统': 'system',
|
||||||
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'The system automatically generates routes based on model availability. Exact model routing will automatically filter accounts and tokens that only support that model. P0 has the highest priority, and the larger the number, the lower the priority. The selection probability indicates the probability of the channel being selected when the request arrives. The priority of cost sources is: measured cost → account configuration cost → catalog reference price → default fallback unit price.',
|
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'The system automatically generates routes based on model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes automatically filter accounts and tokens that only support that model. Priority edits inside group routes write back to the source channels directly. The selection probability indicates the probability of the channel being selected when the request arrives. The priority of cost sources is: measured cost → account configuration cost → catalog reference price → default fallback unit price.',
|
||||||
|
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, the impact is shown before saving.',
|
||||||
|
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
|
||||||
'系统设置': 'System settings',
|
'系统设置': 'System settings',
|
||||||
'显示调试': 'show debugging',
|
'显示调试': 'show debugging',
|
||||||
'项目链接': 'Project link',
|
'项目链接': 'Project link',
|
||||||
|
|||||||
+3
-1
@@ -194,7 +194,9 @@ const zhToEn: Record<string, string> = {
|
|||||||
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
|
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
|
||||||
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
|
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
|
||||||
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routes are decided in real time; probability explanation is estimated uniformly within the current route.',
|
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routes are decided in real time; probability explanation is estimated uniformly within the current route.',
|
||||||
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Exact model routes auto-filter accounts and tokens that support that model. Priority P0 is highest, larger numbers are lower. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes auto-filter accounts and tokens that support that model. Priority edits inside group routes write back to the source channels directly. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
||||||
|
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, you will be warned about the impact before saving.',
|
||||||
|
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
|
||||||
'代理端点': 'Proxy Endpoints',
|
'代理端点': 'Proxy Endpoints',
|
||||||
'路由行为': 'Routing Behavior',
|
'路由行为': 'Routing Behavior',
|
||||||
'指标口径': 'Metric Notes',
|
'指标口径': 'Metric Notes',
|
||||||
|
|||||||
+105
-12
@@ -1,6 +1,5 @@
|
|||||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { arrayMove } from '@dnd-kit/sortable';
|
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../components/BrandIcon.js';
|
import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../components/BrandIcon.js';
|
||||||
@@ -45,8 +44,8 @@ import type {
|
|||||||
import {
|
import {
|
||||||
AUTO_ROUTE_DECISION_LIMIT,
|
AUTO_ROUTE_DECISION_LIMIT,
|
||||||
ROUTE_RENDER_CHUNK,
|
ROUTE_RENDER_CHUNK,
|
||||||
isExactModelPattern,
|
|
||||||
isExplicitGroupRoute,
|
isExplicitGroupRoute,
|
||||||
|
isExactModelPattern,
|
||||||
isRouteExactModel,
|
isRouteExactModel,
|
||||||
matchesModelPattern,
|
matchesModelPattern,
|
||||||
normalizeRouteMode,
|
normalizeRouteMode,
|
||||||
@@ -58,6 +57,7 @@ import {
|
|||||||
inferEndpointTypesFromPlatform,
|
inferEndpointTypesFromPlatform,
|
||||||
getModelPatternError,
|
getModelPatternError,
|
||||||
} from './token-routes/utils.js';
|
} from './token-routes/utils.js';
|
||||||
|
import { applyPriorityBucketDrag, splitPriorityBucketAfterChannel } from './token-routes/priorityBuckets.js';
|
||||||
import { useRouteChannels } from './token-routes/useRouteChannels.js';
|
import { useRouteChannels } from './token-routes/useRouteChannels.js';
|
||||||
import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js';
|
import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js';
|
||||||
import ManualRoutePanel from './token-routes/ManualRoutePanel.js';
|
import ManualRoutePanel from './token-routes/ManualRoutePanel.js';
|
||||||
@@ -999,17 +999,41 @@ export default function TokenRoutes() {
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const route = routeSummaries.find((item) => item.id === routeId);
|
||||||
|
if (!route) return;
|
||||||
|
|
||||||
const channels = channelsByRouteId[routeId] || [];
|
const channels = channelsByRouteId[routeId] || [];
|
||||||
const oldIndex = channels.findIndex((channel) => channel.id === Number(active.id));
|
const reordered = applyPriorityBucketDrag(channels, active.id, over.id);
|
||||||
const newIndex = channels.findIndex((channel) => channel.id === Number(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];
|
if (isExplicitGroupRoute(route)) {
|
||||||
const reordered = arrayMove(channels, oldIndex, newIndex).map((channel, index) => ({
|
const changedSourceRouteIds = Array.from(new Set(
|
||||||
...channel,
|
changedChannels
|
||||||
priority: index,
|
.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);
|
setChannels(routeId, reordered);
|
||||||
setSavingPriorityByRoute((prev) => ({ ...prev, [routeId]: true }));
|
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)) {
|
if (route && isRouteExactModel(route)) {
|
||||||
try {
|
try {
|
||||||
const res = await api.getRouteDecision(route.modelPattern);
|
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 handleSiteBlockModel = async (channelId: number, routeId: number) => {
|
||||||
const channels = channelsByRouteId[routeId] || [];
|
const channels = channelsByRouteId[routeId] || [];
|
||||||
const channel = channels.find((c) => c.id === channelId);
|
const channel = channels.find((c) => c.id === channelId);
|
||||||
@@ -1454,7 +1545,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
{/* Info tip */}
|
{/* Info tip */}
|
||||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||||
{tr('系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
|
{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual route panel */}
|
{/* Manual route panel */}
|
||||||
@@ -1625,6 +1716,7 @@ export default function TokenRoutes() {
|
|||||||
onDeleteChannel={stableDeleteChannel}
|
onDeleteChannel={stableDeleteChannel}
|
||||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||||
onChannelDragEnd={stableChannelDragEnd}
|
onChannelDragEnd={stableChannelDragEnd}
|
||||||
|
onSplitPriorityBucket={handleSplitPriorityBucket}
|
||||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||||
@@ -1663,6 +1755,7 @@ export default function TokenRoutes() {
|
|||||||
onDeleteChannel={stableDeleteChannel}
|
onDeleteChannel={stableDeleteChannel}
|
||||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||||
onChannelDragEnd={stableChannelDragEnd}
|
onChannelDragEnd={stableChannelDragEnd}
|
||||||
|
onSplitPriorityBucket={handleSplitPriorityBucket}
|
||||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('RouteCard', () => {
|
|||||||
onDeleteChannel={vi.fn()}
|
onDeleteChannel={vi.fn()}
|
||||||
onToggleChannelEnabled={vi.fn()}
|
onToggleChannelEnabled={vi.fn()}
|
||||||
onChannelDragEnd={vi.fn()}
|
onChannelDragEnd={vi.fn()}
|
||||||
|
onSplitPriorityBucket={vi.fn()}
|
||||||
missingTokenSiteItems={[]}
|
missingTokenSiteItems={[]}
|
||||||
missingTokenGroupItems={[]}
|
missingTokenGroupItems={[]}
|
||||||
onCreateTokenForMissing={vi.fn()}
|
onCreateTokenForMissing={vi.fn()}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||||
import { SortableChannelRow } from './SortableChannelRow.js';
|
import { SortableChannelRow } from './SortableChannelRow.js';
|
||||||
|
import { SortableBucketSeparator } from './SortableBucketSeparator.js';
|
||||||
import {
|
import {
|
||||||
getRouteRoutingStrategyLabel,
|
getRouteRoutingStrategyLabel,
|
||||||
normalizeRouteRoutingStrategyValue,
|
normalizeRouteRoutingStrategyValue,
|
||||||
@@ -37,8 +38,11 @@ import {
|
|||||||
isExplicitGroupRoute,
|
isExplicitGroupRoute,
|
||||||
resolveRouteTitle,
|
resolveRouteTitle,
|
||||||
resolveRouteIcon,
|
resolveRouteIcon,
|
||||||
buildSourceGroupKey,
|
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import {
|
||||||
|
buildPriorityBucketEditorItems,
|
||||||
|
buildPriorityBuckets,
|
||||||
|
} from './priorityBuckets.js';
|
||||||
|
|
||||||
type RouteCardProps = {
|
type RouteCardProps = {
|
||||||
route: RouteSummaryRow;
|
route: RouteSummaryRow;
|
||||||
@@ -67,6 +71,7 @@ type RouteCardProps = {
|
|||||||
onDeleteChannel: (channelId: number, routeId: number) => void;
|
onDeleteChannel: (channelId: number, routeId: number) => void;
|
||||||
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
|
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
|
||||||
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
||||||
|
onSplitPriorityBucket: (routeId: number, channelId: number) => void;
|
||||||
// Missing token hints
|
// Missing token hints
|
||||||
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
||||||
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
|
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
|
||||||
@@ -116,6 +121,7 @@ function RouteCardInner({
|
|||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onToggleChannelEnabled,
|
onToggleChannelEnabled,
|
||||||
onChannelDragEnd,
|
onChannelDragEnd,
|
||||||
|
onSplitPriorityBucket,
|
||||||
missingTokenSiteItems,
|
missingTokenSiteItems,
|
||||||
missingTokenGroupItems,
|
missingTokenGroupItems,
|
||||||
onCreateTokenForMissing,
|
onCreateTokenForMissing,
|
||||||
@@ -159,25 +165,8 @@ function RouteCardInner({
|
|||||||
(routeDecision?.candidates || []).map((c) => [c.channelId, c]),
|
(routeDecision?.candidates || []).map((c) => [c.channelId, c]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const channelGroups = (() => {
|
const priorityBuckets = buildPriorityBuckets(channels || []);
|
||||||
if (!channels || channels.length === 0) return [];
|
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
|
||||||
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,
|
|
||||||
}));
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Collapsed card
|
// Collapsed card
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
@@ -400,11 +389,11 @@ function RouteCardInner({
|
|||||||
|
|
||||||
{explicitGroupRoute ? (
|
{explicitGroupRoute ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
{tr('该群组会将多个来源模型聚合为一个对外模型名;当前策略以群组设置为准,来源模型会尽量跟随同步,但已单独自定义或被其他群组复用的来源模型不会被覆盖。')}
|
{tr('该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。')}
|
||||||
</div>
|
</div>
|
||||||
) : !exactRoute ? (
|
) : !exactRoute ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
|
{tr('通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -487,96 +476,54 @@ function RouteCardInner({
|
|||||||
) : channels && channels.length > 0 ? (
|
) : channels && channels.length > 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
|
||||||
|
<SortableContext items={bucketEditorItems.map((item) => item.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{channelGroups.map((group) => {
|
{priorityBuckets.map((bucket, bucketIndex) => {
|
||||||
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
|
const sourceModelCount = new Set(
|
||||||
const supportsCollapse = !exactRoute && !!group.sourceModel;
|
bucket.channels
|
||||||
const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
|
.map((channel) => (channel.sourceModel || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
).size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${route.id}-${group.sourceModel || '__ungrouped__'}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{group.sourceModel ? (
|
<div
|
||||||
supportsCollapse ? (
|
className="route-priority-bucket-header"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleSourceGroup(groupKey)}
|
|
||||||
aria-expanded={isGroupExpanded}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
alignItems: 'center',
|
||||||
padding: '4px 6px', border: '1px dashed var(--color-border)',
|
gap: 8,
|
||||||
borderRadius: 'var(--radius-sm)', background: 'transparent',
|
flexWrap: 'wrap',
|
||||||
|
padding: '4px 2px',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||||
<span>来源模型</span>
|
{`P${bucketIndex}`}
|
||||||
<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>
|
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-text-muted)' }}>
|
<span>{`${bucket.channels.length} ${tr('通道')}`}</span>
|
||||||
{isGroupExpanded ? '收起' : '展开'}
|
{!exactRoute && sourceModelCount > 0 ? (
|
||||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||||
style={{ transform: isGroupExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }}
|
{`${sourceModelCount} ${tr('来源模型')}`}
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path d="m5 7 5 6 5-6" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</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}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnimatedCollapseSection open={isGroupExpanded}>
|
{bucket.channels.map((channel, channelIndex) => {
|
||||||
{explicitGroupRoute ? (
|
|
||||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
{group.channels.map((channel) => {
|
|
||||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||||
return (
|
return (
|
||||||
|
<div key={channel.id} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<SortableChannelRow
|
<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}
|
channel={channel}
|
||||||
|
displayPriority={bucketIndex}
|
||||||
decisionCandidate={decisionMap.get(channel.id)}
|
decisionCandidate={decisionMap.get(channel.id)}
|
||||||
isExactRoute={exactRoute}
|
isExactRoute={exactRoute}
|
||||||
loadingDecision={loadingDecision}
|
loadingDecision={loadingDecision}
|
||||||
isSavingPriority={savingPriority}
|
isSavingPriority={savingPriority}
|
||||||
|
readOnly={readOnlyRoute}
|
||||||
|
channelManagementDisabled={channelManagementDisabled}
|
||||||
mobile={compact}
|
mobile={compact}
|
||||||
tokenOptions={tokenOptions}
|
tokenOptions={tokenOptions}
|
||||||
activeTokenId={activeTokenId}
|
activeTokenId={activeTokenId}
|
||||||
@@ -585,22 +532,48 @@ function RouteCardInner({
|
|||||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||||
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
||||||
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
|
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>
|
</SortableContext>
|
||||||
)}
|
|
||||||
</AnimatedCollapseSection>
|
|
||||||
{!isGroupExpanded && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
|
|
||||||
已收起,点击展开查看通道
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } fro
|
|||||||
|
|
||||||
export function SortableChannelRow({
|
export function SortableChannelRow({
|
||||||
channel,
|
channel,
|
||||||
|
displayPriority,
|
||||||
decisionCandidate,
|
decisionCandidate,
|
||||||
isExactRoute,
|
isExactRoute,
|
||||||
loadingDecision,
|
loadingDecision,
|
||||||
isSavingPriority,
|
isSavingPriority,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
channelManagementDisabled = false,
|
||||||
mobile = false,
|
mobile = false,
|
||||||
tokenOptions,
|
tokenOptions,
|
||||||
activeTokenId,
|
activeTokenId,
|
||||||
@@ -41,13 +43,16 @@ export function SortableChannelRow({
|
|||||||
disabled: isSavingPriority || readOnly,
|
disabled: isSavingPriority || readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolvedPriority = displayPriority ?? channel.priority ?? 0;
|
||||||
|
const managementLocked = readOnly || channelManagementDisabled;
|
||||||
|
|
||||||
const rowStyle: CSSProperties = {
|
const rowStyle: CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
|
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
|
||||||
zIndex: isDragging ? 10 : 1,
|
zIndex: isDragging ? 10 : 1,
|
||||||
display: 'grid',
|
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',
|
alignItems: mobile ? 'stretch' : 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: mobile ? '10px 12px' : '8px 12px',
|
padding: mobile ? '10px 12px' : '8px 12px',
|
||||||
@@ -93,8 +98,8 @@ export function SortableChannelRow({
|
|||||||
opacity: readOnly ? 0.65 : 1,
|
opacity: readOnly ? 0.65 : 1,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
|
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
|
||||||
aria-label="拖拽调整优先级"
|
aria-label="拖拽调整优先级桶"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||||
<circle cx="3" cy="2" r="1" />
|
<circle cx="3" cy="2" r="1" />
|
||||||
@@ -114,10 +119,10 @@ export function SortableChannelRow({
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
...getPriorityTagStyle(channel.priority ?? 0),
|
...getPriorityTagStyle(resolvedPriority),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
P{channel.priority ?? 0}
|
P{resolvedPriority}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
|
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
|
||||||
@@ -218,7 +223,7 @@ export function SortableChannelRow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
{!managementLocked && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-link"
|
className="btn btn-link"
|
||||||
@@ -230,7 +235,7 @@ export function SortableChannelRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && mobileDetailsOpen && (
|
{!managementLocked && mobileDetailsOpen && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
@@ -317,8 +322,8 @@ export function SortableChannelRow({
|
|||||||
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
|
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
|
||||||
opacity: readOnly ? 0.65 : 1,
|
opacity: readOnly ? 0.65 : 1,
|
||||||
}}
|
}}
|
||||||
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
|
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
|
||||||
aria-label="拖拽调整优先级"
|
aria-label="拖拽调整优先级桶"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||||
<circle cx="3" cy="2" r="1" />
|
<circle cx="3" cy="2" r="1" />
|
||||||
@@ -336,10 +341,10 @@ export function SortableChannelRow({
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
...getPriorityTagStyle(channel.priority ?? 0),
|
...getPriorityTagStyle(resolvedPriority),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
P{channel.priority ?? 0}
|
P{resolvedPriority}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
@@ -444,7 +449,7 @@ export function SortableChannelRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly ? (
|
{!managementLocked ? (
|
||||||
mobile ? (
|
mobile ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
|
||||||
<div style={{ 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 = {
|
export type RouteChannel = {
|
||||||
id: number;
|
id: number;
|
||||||
|
routeId?: number;
|
||||||
accountId: number;
|
accountId: number;
|
||||||
tokenId: number | null;
|
tokenId: number | null;
|
||||||
sourceModel?: string | null;
|
sourceModel?: string | null;
|
||||||
@@ -124,11 +125,13 @@ export type MissingTokenGroupRouteSiteActionItem = {
|
|||||||
|
|
||||||
export type SortableChannelRowProps = {
|
export type SortableChannelRowProps = {
|
||||||
channel: RouteChannel;
|
channel: RouteChannel;
|
||||||
|
displayPriority?: number;
|
||||||
decisionCandidate?: RouteDecisionCandidate;
|
decisionCandidate?: RouteDecisionCandidate;
|
||||||
isExactRoute: boolean;
|
isExactRoute: boolean;
|
||||||
loadingDecision: boolean;
|
loadingDecision: boolean;
|
||||||
isSavingPriority: boolean;
|
isSavingPriority: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
channelManagementDisabled?: boolean;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
tokenOptions: RouteTokenOption[];
|
tokenOptions: RouteTokenOption[];
|
||||||
activeTokenId: number;
|
activeTokenId: number;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
|||||||
getRouteWideDecisionsBatch: vi.fn(),
|
getRouteWideDecisionsBatch: vi.fn(),
|
||||||
updateRoute: vi.fn(),
|
updateRoute: vi.fn(),
|
||||||
addRoute: vi.fn(),
|
addRoute: vi.fn(),
|
||||||
|
batchUpdateChannels: vi.fn(),
|
||||||
},
|
},
|
||||||
getBrandMock: vi.fn(),
|
getBrandMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -94,15 +95,18 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
getBrandMock.mockReset();
|
getBrandMock.mockReset();
|
||||||
getBrandMock.mockReturnValue(null);
|
getBrandMock.mockReturnValue(null);
|
||||||
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||||
apiMock.getRouteChannels.mockResolvedValue([]);
|
apiMock.getRouteChannels.mockResolvedValue([]);
|
||||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||||
apiMock.updateRoute.mockResolvedValue({});
|
apiMock.updateRoute.mockResolvedValue({});
|
||||||
apiMock.addRoute.mockResolvedValue({});
|
apiMock.addRoute.mockResolvedValue({});
|
||||||
|
apiMock.batchUpdateChannels.mockResolvedValue({ success: true, channels: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
vi.clearAllMocks();
|
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 = [
|
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,
|
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||||
successCount: 0, failCount: 0,
|
successCount: 0, failCount: 0,
|
||||||
account: { username: 'user_a' }, site: { name: 'site-a' },
|
account: { username: 'user_a' }, site: { name: 'site-a' },
|
||||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
|
id: 12, routeId: 1, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
|
||||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
priority: 1, weight: 1, enabled: true, manualOverride: false,
|
||||||
successCount: 0, failCount: 0,
|
successCount: 0, failCount: 0,
|
||||||
account: { username: 'user_b' }, site: { name: 'site-b' },
|
account: { username: 'user_b' }, site: { name: 'site-b' },
|
||||||
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
|
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
|
||||||
@@ -209,37 +213,66 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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);
|
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-5');
|
||||||
expect(expandedText).toContain('claude-opus-4-6');
|
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 {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands a source-model group after user click', async () => {
|
it('writes explicit-group priority edits back to source channels and confirms shared-source impact', async () => {
|
||||||
const channels = [
|
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,
|
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||||
successCount: 0, failCount: 0,
|
successCount: 0, failCount: 0,
|
||||||
account: { username: 'user_a' }, site: { name: 'site-a' },
|
account: { username: 'user_a' }, site: { name: 'site-a' },
|
||||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
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',
|
id: 102, routeId: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-sonnet-4-5',
|
||||||
displayIcon: null, modelMapping: null, enabled: true,
|
priority: 1, weight: 1, enabled: true, manualOverride: false,
|
||||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
|
successCount: 0, failCount: 0,
|
||||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
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;
|
let root!: WebTestRenderer;
|
||||||
try {
|
try {
|
||||||
@@ -254,24 +287,121 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
// Expand the card first
|
|
||||||
const expandBtn = root.root.find((node) =>
|
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 () => {
|
await act(async () => {
|
||||||
expandBtn.props.onClick();
|
expandBtn.props.onClick();
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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 () => {
|
await act(async () => {
|
||||||
toggleButton.props.onClick();
|
await dragContext.props.onDragEnd({
|
||||||
|
active: { id: 102 },
|
||||||
|
over: { id: 101 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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 {
|
} finally {
|
||||||
root?.unmount();
|
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([
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null,
|
id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null,
|
||||||
@@ -1435,18 +1565,13 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const expandedText = collectText(root.root);
|
const expandedText = collectText(root.root);
|
||||||
expect(expandedText).toContain('P0');
|
expect(expandedText).toContain('P0');
|
||||||
expect(expandedText).toContain('当前生效:token-a');
|
expect(expandedText).toContain('当前生效:token-a');
|
||||||
expect(expandedText).toContain('选中概率');
|
expect(expandedText).toContain('选中概率');
|
||||||
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true);
|
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级桶').props.disabled).toBe(false);
|
||||||
expect(expandedText).not.toContain('保存');
|
expect(root.root.findAll((node) => node.type === 'button' && collectText(node).trim() === '保存')).toHaveLength(0);
|
||||||
expect(expandedText).not.toContain('移除');
|
expect(root.root.findAll((node) => node.type === 'button' && collectText(node).trim() === '移除')).toHaveLength(0);
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user