forked from boos/BoosAPI
[codex] add route priority left rail (#371)
* feat: add route priority left rail * fix: drop stale route card test prop
This commit is contained in:
@@ -57,7 +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 { applyPriorityRailDrop, isPriorityRailNewLayerId } from './token-routes/priorityRail.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';
|
||||||
@@ -1004,7 +1004,18 @@ export default function TokenRoutes() {
|
|||||||
if (!route) return;
|
if (!route) return;
|
||||||
|
|
||||||
const channels = channelsByRouteId[routeId] || [];
|
const channels = channelsByRouteId[routeId] || [];
|
||||||
const reordered = applyPriorityBucketDrag(channels, active.id, over.id);
|
const activeChannel = channels.find((channel) => channel.id === Number(active.id));
|
||||||
|
if (!activeChannel) return;
|
||||||
|
|
||||||
|
const overIsNewLayer = isPriorityRailNewLayerId(over.id);
|
||||||
|
const targetChannel = overIsNewLayer
|
||||||
|
? null
|
||||||
|
: channels.find((channel) => channel.id === Number(over.id));
|
||||||
|
|
||||||
|
if (!overIsNewLayer && !targetChannel) return;
|
||||||
|
if (!overIsNewLayer && (targetChannel?.priority ?? 0) === (activeChannel.priority ?? 0)) return;
|
||||||
|
|
||||||
|
const reordered = applyPriorityRailDrop(channels, Number(active.id), over.id);
|
||||||
const changedChannels = reordered.filter((channel) => {
|
const changedChannels = reordered.filter((channel) => {
|
||||||
const previous = channels.find((item) => item.id === channel.id);
|
const previous = channels.find((item) => item.id === channel.id);
|
||||||
return (previous?.priority ?? 0) !== channel.priority;
|
return (previous?.priority ?? 0) !== channel.priority;
|
||||||
@@ -1066,74 +1077,6 @@ 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);
|
||||||
@@ -1760,7 +1703,6 @@ 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}
|
||||||
@@ -1801,7 +1743,6 @@ 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}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
import RouteCard from './RouteCard.js';
|
import RouteCard from './RouteCard.js';
|
||||||
import type { RouteSummaryRow } from './types.js';
|
import type { RouteChannel, RouteSummaryRow } from './types.js';
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
function collectText(node: ReactTestInstance): string {
|
||||||
return (node.children || []).map((child) => {
|
return (node.children || []).map((child) => {
|
||||||
@@ -30,6 +30,25 @@ function buildRoute(overrides: Partial<RouteSummaryRow> = {}): RouteSummaryRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
|
||||||
|
return {
|
||||||
|
id: 11,
|
||||||
|
accountId: 101,
|
||||||
|
tokenId: 1001,
|
||||||
|
sourceModel: 'gpt-4o-mini',
|
||||||
|
priority: 0,
|
||||||
|
weight: 1,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
successCount: 0,
|
||||||
|
failCount: 0,
|
||||||
|
account: { username: 'user_a' },
|
||||||
|
site: { id: 1, name: 'site-a', platform: 'openai' },
|
||||||
|
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('RouteCard', () => {
|
describe('RouteCard', () => {
|
||||||
it('truncates the collapsed regex badge while keeping the group name primary', () => {
|
it('truncates the collapsed regex badge while keeping the group name primary', () => {
|
||||||
const root = create(
|
const root = create(
|
||||||
@@ -58,7 +77,6 @@ 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()}
|
||||||
@@ -116,7 +134,6 @@ 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()}
|
||||||
@@ -136,4 +153,65 @@ describe('RouteCard', () => {
|
|||||||
button.props.onClick();
|
button.props.onClick();
|
||||||
expect(onClearCooldown).toHaveBeenCalledTimes(1);
|
expect(onClearCooldown).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders desktop priority rail summaries for multiple channel layers', () => {
|
||||||
|
const root = create(
|
||||||
|
<RouteCard
|
||||||
|
route={buildRoute()}
|
||||||
|
brand={null}
|
||||||
|
expanded
|
||||||
|
onToggleExpand={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onToggleEnabled={vi.fn()}
|
||||||
|
onClearCooldown={vi.fn()}
|
||||||
|
clearingCooldown={false}
|
||||||
|
onRoutingStrategyChange={vi.fn()}
|
||||||
|
updatingRoutingStrategy={false}
|
||||||
|
channels={[
|
||||||
|
buildChannel({
|
||||||
|
id: 11,
|
||||||
|
priority: 0,
|
||||||
|
account: { username: 'user_a' },
|
||||||
|
site: { id: 1, name: 'site-a', platform: 'openai' },
|
||||||
|
}),
|
||||||
|
buildChannel({
|
||||||
|
id: 12,
|
||||||
|
accountId: 102,
|
||||||
|
tokenId: 1002,
|
||||||
|
priority: 1,
|
||||||
|
sourceModel: 'gpt-4.1',
|
||||||
|
account: { username: 'user_b' },
|
||||||
|
site: { id: 2, name: 'site-b', platform: 'openai' },
|
||||||
|
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: false },
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
loadingChannels={false}
|
||||||
|
routeDecision={null}
|
||||||
|
loadingDecision={false}
|
||||||
|
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||||
|
channelTokenDraft={{}}
|
||||||
|
updatingChannel={{}}
|
||||||
|
savingPriority={false}
|
||||||
|
onTokenDraftChange={vi.fn()}
|
||||||
|
onSaveToken={vi.fn()}
|
||||||
|
onDeleteChannel={vi.fn()}
|
||||||
|
onToggleChannelEnabled={vi.fn()}
|
||||||
|
onChannelDragEnd={vi.fn()}
|
||||||
|
missingTokenSiteItems={[]}
|
||||||
|
missingTokenGroupItems={[]}
|
||||||
|
onCreateTokenForMissing={vi.fn()}
|
||||||
|
onAddChannel={vi.fn()}
|
||||||
|
onSiteBlockModel={vi.fn()}
|
||||||
|
expandedSourceGroupMap={{}}
|
||||||
|
onToggleSourceGroup={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('P0 · 1');
|
||||||
|
expect(text).toContain('P1 · 1');
|
||||||
|
expect(text).toContain('user_a');
|
||||||
|
expect(text).toContain('user_b');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { memo, useState, type ReactNode } from 'react';
|
import { memo, useState, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type DragOverEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
useDroppable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
@@ -28,7 +32,6 @@ 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,
|
||||||
@@ -40,9 +43,13 @@ import {
|
|||||||
resolveRouteIcon,
|
resolveRouteIcon,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import {
|
import {
|
||||||
buildPriorityBucketEditorItems,
|
|
||||||
buildPriorityBuckets,
|
buildPriorityBuckets,
|
||||||
} from './priorityBuckets.js';
|
} from './priorityBuckets.js';
|
||||||
|
import {
|
||||||
|
buildPriorityRailSections,
|
||||||
|
createPriorityRailNewLayerId,
|
||||||
|
isPriorityRailNewLayerId,
|
||||||
|
} from './priorityRail.js';
|
||||||
|
|
||||||
type RouteCardProps = {
|
type RouteCardProps = {
|
||||||
route: RouteSummaryRow;
|
route: RouteSummaryRow;
|
||||||
@@ -73,7 +80,6 @@ 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[];
|
||||||
@@ -99,6 +105,99 @@ function AnimatedCollapseSection({ open, children }: { open: boolean; children:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PriorityRailNewLayerRow({
|
||||||
|
id,
|
||||||
|
highlighted,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
highlighted: boolean;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
const active = highlighted || isOver;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '86px minmax(0, 1fr)',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 72,
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||||
|
background: active
|
||||||
|
? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg))'
|
||||||
|
: 'transparent',
|
||||||
|
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
transition: 'all 0.16s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr('放到新档位')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 0,
|
||||||
|
borderTop: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||||
|
opacity: active ? 1 : 0.75,
|
||||||
|
transition: 'all 0.16s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityDragPreview({
|
||||||
|
channel,
|
||||||
|
displayPriority,
|
||||||
|
}: {
|
||||||
|
channel: RouteChannel;
|
||||||
|
displayPriority: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`P${displayPriority}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
{channel.account?.username || `account-${channel.accountId}`}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||||
|
{channel.site?.name || 'unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RouteCardInner({
|
function RouteCardInner({
|
||||||
route,
|
route,
|
||||||
brand,
|
brand,
|
||||||
@@ -125,7 +224,6 @@ function RouteCardInner({
|
|||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onToggleChannelEnabled,
|
onToggleChannelEnabled,
|
||||||
onChannelDragEnd,
|
onChannelDragEnd,
|
||||||
onSplitPriorityBucket,
|
|
||||||
missingTokenSiteItems,
|
missingTokenSiteItems,
|
||||||
missingTokenGroupItems,
|
missingTokenGroupItems,
|
||||||
onCreateTokenForMissing,
|
onCreateTokenForMissing,
|
||||||
@@ -170,7 +268,41 @@ function RouteCardInner({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const priorityBuckets = buildPriorityBuckets(channels || []);
|
const priorityBuckets = buildPriorityBuckets(channels || []);
|
||||||
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
|
const priorityRailSections = buildPriorityRailSections(channels || []);
|
||||||
|
const [activeDragChannelId, setActiveDragChannelId] = useState<number | null>(null);
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | number | null>(null);
|
||||||
|
const activeDragChannel = activeDragChannelId == null
|
||||||
|
? null
|
||||||
|
: (channels || []).find((channel) => channel.id === activeDragChannelId) || null;
|
||||||
|
const activeDragBucketIndex = activeDragChannel == null
|
||||||
|
? -1
|
||||||
|
: priorityBuckets.findIndex((bucket) => bucket.channels.some((channel) => channel.id === activeDragChannel.id));
|
||||||
|
const hoveredBucketIndex = typeof dragOverId === 'number'
|
||||||
|
? priorityBuckets.findIndex((bucket) => bucket.channels.some((channel) => channel.id === dragOverId))
|
||||||
|
: -1;
|
||||||
|
const hoveredNewLayerBucketIndex = typeof dragOverId === 'string' && isPriorityRailNewLayerId(dragOverId)
|
||||||
|
? priorityBuckets.findIndex((bucket) => createPriorityRailNewLayerId(bucket.priority) === dragOverId)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const clearDragState = () => {
|
||||||
|
setActiveDragChannelId(null);
|
||||||
|
setDragOverId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const nextId = Number(event.active.id);
|
||||||
|
setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null);
|
||||||
|
setDragOverId(event.active.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
|
setDragOverId(event.over?.id ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
onChannelDragEnd(route.id, event);
|
||||||
|
clearDragState();
|
||||||
|
};
|
||||||
const renderClearCooldownButton = () => {
|
const renderClearCooldownButton = () => {
|
||||||
if (readOnlyRoute) return null;
|
if (readOnlyRoute) return null;
|
||||||
return (
|
return (
|
||||||
@@ -489,98 +621,142 @@ function RouteCardInner({
|
|||||||
</div>
|
</div>
|
||||||
) : 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
|
||||||
<SortableContext items={bucketEditorItems.map((item) => item.id)} strategy={verticalListSortingStrategy}>
|
sensors={sensors}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragCancel={clearDragState}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={(channels || []).map((channel) => channel.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: compact ? 10 : 12 }}>
|
||||||
{priorityBuckets.map((bucket, bucketIndex) => {
|
{priorityBuckets.map((bucket, bucketIndex) => {
|
||||||
const sourceModelCount = new Set(
|
const railSection = priorityRailSections[bucketIndex];
|
||||||
bucket.channels
|
const railLabel = `P${bucketIndex} · ${bucket.channels.length}`;
|
||||||
.map((channel) => (channel.sourceModel || '').trim())
|
const mobileRailLabel = `${railLabel} ${tr('通道')}`;
|
||||||
.filter(Boolean),
|
const hoveredExistingLayer = activeDragChannelId != null && hoveredBucketIndex === bucketIndex;
|
||||||
).size;
|
const hoveredCrossLayer = hoveredExistingLayer && activeDragBucketIndex !== bucketIndex;
|
||||||
|
const hoveredNewLayer = activeDragChannelId != null && hoveredNewLayerBucketIndex === bucketIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div
|
||||||
|
key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: compact ? 8 : 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="route-priority-bucket-header"
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: compact ? 'flex' : 'grid',
|
||||||
alignItems: 'center',
|
flexDirection: compact ? 'column' : undefined,
|
||||||
gap: 8,
|
gridTemplateColumns: compact ? undefined : '86px minmax(0, 1fr)',
|
||||||
flexWrap: 'wrap',
|
gap: compact ? 8 : 12,
|
||||||
padding: '4px 2px',
|
alignItems: compact ? 'stretch' : 'stretch',
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
{compact ? (
|
||||||
{`P${bucketIndex}`}
|
<div
|
||||||
</span>
|
className="route-priority-bucket-header"
|
||||||
<span>{`${bucket.channels.length} ${tr('通道')}`}</span>
|
style={{
|
||||||
{!exactRoute && sourceModelCount > 0 ? (
|
display: 'flex',
|
||||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
alignItems: 'center',
|
||||||
{`${sourceModelCount} ${tr('来源模型')}`}
|
gap: 8,
|
||||||
</span>
|
flexWrap: 'wrap',
|
||||||
) : null}
|
padding: '2px 2px 0',
|
||||||
</div>
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
{bucket.channels.map((channel, channelIndex) => {
|
}}
|
||||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
>
|
||||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
<span
|
||||||
return (
|
style={{
|
||||||
<div key={channel.id} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
fontSize: 12,
|
||||||
<SortableChannelRow
|
fontWeight: 600,
|
||||||
channel={channel}
|
color: 'var(--color-text-secondary)',
|
||||||
displayPriority={bucketIndex}
|
}}
|
||||||
decisionCandidate={decisionMap.get(channel.id)}
|
>
|
||||||
isExactRoute={exactRoute}
|
{mobileRailLabel}
|
||||||
loadingDecision={loadingDecision}
|
</span>
|
||||||
isSavingPriority={savingPriority}
|
</div>
|
||||||
readOnly={readOnlyRoute}
|
) : (
|
||||||
channelManagementDisabled={channelManagementDisabled}
|
<div
|
||||||
mobile={compact}
|
aria-hidden
|
||||||
tokenOptions={tokenOptions}
|
style={{
|
||||||
activeTokenId={activeTokenId}
|
width: 86,
|
||||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
flexShrink: 0,
|
||||||
onTokenDraftChange={onTokenDraftChange}
|
display: 'flex',
|
||||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
flexDirection: 'column',
|
||||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
alignItems: 'center',
|
||||||
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
paddingTop: 4,
|
||||||
onSiteBlockModel={channelManagementDisabled ? undefined : () => onSiteBlockModel(channel.id, route.id)}
|
}}
|
||||||
/>
|
>
|
||||||
{!readOnlyRoute && channelIndex < bucket.channels.length - 1 ? (
|
<div
|
||||||
<button
|
style={{
|
||||||
type="button"
|
minWidth: 72,
|
||||||
className="btn btn-ghost"
|
padding: '6px 10px',
|
||||||
onClick={() => onSplitPriorityBucket(route.id, channel.id)}
|
borderRadius: 999,
|
||||||
disabled={savingPriority}
|
border: `1px solid ${hoveredExistingLayer ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||||
style={{
|
background: hoveredExistingLayer
|
||||||
display: 'inline-flex',
|
? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg))'
|
||||||
alignItems: 'center',
|
: 'var(--color-bg)',
|
||||||
justifyContent: 'center',
|
color: hoveredExistingLayer ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||||
gap: 8,
|
fontSize: 11,
|
||||||
border: '1px dashed var(--color-border)',
|
fontWeight: 600,
|
||||||
borderRadius: 999,
|
textAlign: 'center',
|
||||||
background: 'transparent',
|
lineHeight: 1.2,
|
||||||
color: 'var(--color-text-muted)',
|
transition: 'all 0.16s ease',
|
||||||
padding: '2px 10px',
|
}}
|
||||||
fontSize: 11,
|
>
|
||||||
alignSelf: 'center',
|
{railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel}
|
||||||
}}
|
</div>
|
||||||
>
|
{hoveredCrossLayer ? (
|
||||||
{`拆分为 P${bucketIndex + 1}`}
|
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--color-primary)', textAlign: 'center' }}>
|
||||||
</button>
|
{`放到 P${bucketIndex}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{bucketIndex < priorityBuckets.length - 1 ? (
|
||||||
|
<div style={{ width: 1, flex: 1, marginTop: 8, background: 'var(--color-border)' }} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
|
|
||||||
{bucketIndex < priorityBuckets.length - 1 ? (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
|
||||||
<SortableBucketSeparator
|
{bucket.channels.map((channel) => {
|
||||||
id={String(bucketEditorItems.find((item) => item.kind === 'separator' && item.id === `priority-separator:${bucketIndex}`)?.id || `priority-separator:${bucketIndex}`)}
|
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||||
beforePriority={bucketIndex}
|
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||||
afterPriority={bucketIndex + 1}
|
return (
|
||||||
isSavingPriority={savingPriority}
|
<SortableChannelRow
|
||||||
|
key={channel.id}
|
||||||
|
channel={channel}
|
||||||
|
displayPriority={bucketIndex}
|
||||||
|
showPriorityBadge={compact}
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!compact && activeDragChannelId != null && !readOnlyRoute ? (
|
||||||
|
<PriorityRailNewLayerRow
|
||||||
|
id={createPriorityRailNewLayerId(bucket.priority)}
|
||||||
|
highlighted={hoveredNewLayer}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -588,6 +764,14 @@ function RouteCardInner({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeDragChannel && !compact ? (
|
||||||
|
<PriorityDragPreview
|
||||||
|
channel={activeDragChannel}
|
||||||
|
displayPriority={Math.max(0, activeDragBucketIndex)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } fro
|
|||||||
export function SortableChannelRow({
|
export function SortableChannelRow({
|
||||||
channel,
|
channel,
|
||||||
displayPriority,
|
displayPriority,
|
||||||
|
showPriorityBadge = true,
|
||||||
decisionCandidate,
|
decisionCandidate,
|
||||||
isExactRoute,
|
isExactRoute,
|
||||||
loadingDecision,
|
loadingDecision,
|
||||||
@@ -113,17 +114,19 @@ export function SortableChannelRow({
|
|||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span
|
{showPriorityBadge ? (
|
||||||
className="badge"
|
<span
|
||||||
style={{
|
className="badge"
|
||||||
fontSize: 10,
|
style={{
|
||||||
fontWeight: 700,
|
fontSize: 10,
|
||||||
letterSpacing: 0.1,
|
fontWeight: 700,
|
||||||
...getPriorityTagStyle(resolvedPriority),
|
letterSpacing: 0.1,
|
||||||
}}
|
...getPriorityTagStyle(resolvedPriority),
|
||||||
>
|
}}
|
||||||
P{resolvedPriority}
|
>
|
||||||
</span>
|
P{resolvedPriority}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<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 }}>
|
||||||
{channel.account?.username || `account-${channel.accountId}`}
|
{channel.account?.username || `account-${channel.accountId}`}
|
||||||
@@ -335,17 +338,19 @@ export function SortableChannelRow({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span
|
{showPriorityBadge ? (
|
||||||
className="badge"
|
<span
|
||||||
style={{
|
className="badge"
|
||||||
fontSize: 10,
|
style={{
|
||||||
fontWeight: 700,
|
fontSize: 10,
|
||||||
letterSpacing: 0.1,
|
fontWeight: 700,
|
||||||
...getPriorityTagStyle(resolvedPriority),
|
letterSpacing: 0.1,
|
||||||
}}
|
...getPriorityTagStyle(resolvedPriority),
|
||||||
>
|
}}
|
||||||
P{resolvedPriority}
|
>
|
||||||
</span>
|
P{resolvedPriority}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
{channel.account?.username || `account-${channel.accountId}`}
|
{channel.account?.username || `account-${channel.accountId}`}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
applyPriorityRailDrop,
|
||||||
|
buildPriorityRailDragTargets,
|
||||||
|
buildPriorityRailSections,
|
||||||
|
createPriorityRailNewLayerId,
|
||||||
|
} from './priorityRail.js';
|
||||||
|
|
||||||
|
describe('priorityRail helpers', () => {
|
||||||
|
it('groups channels into visible priority sections and preserves in-layer order', () => {
|
||||||
|
const sections = buildPriorityRailSections([
|
||||||
|
{ id: 11, priority: 0 },
|
||||||
|
{ id: 12, priority: 0 },
|
||||||
|
{ id: 21, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(sections).toEqual([
|
||||||
|
{ priority: 0, channelCount: 2, channelIds: [11, 12] },
|
||||||
|
{ priority: 1, channelCount: 1, channelIds: [21] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a temporary new-layer target only when drag state requests it', () => {
|
||||||
|
const sections = buildPriorityRailSections([
|
||||||
|
{ id: 11, priority: 0 },
|
||||||
|
{ id: 21, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildPriorityRailDragTargets(sections, {
|
||||||
|
activeChannelId: 11,
|
||||||
|
hoveredPriority: 1,
|
||||||
|
showNewLayerTarget: true,
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: 'existing_layer', priority: 0, highlighted: false },
|
||||||
|
{ kind: 'existing_layer', priority: 1, highlighted: true },
|
||||||
|
{ kind: 'new_layer', priority: 2, highlighted: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a channel into an existing layer when dropped onto another channel', () => {
|
||||||
|
const reordered = applyPriorityRailDrop(
|
||||||
|
[
|
||||||
|
{ id: 11, priority: 0 },
|
||||||
|
{ id: 12, priority: 0 },
|
||||||
|
{ id: 21, priority: 1 },
|
||||||
|
],
|
||||||
|
21,
|
||||||
|
11,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reordered).toEqual([
|
||||||
|
{ id: 11, priority: 0 },
|
||||||
|
{ id: 12, priority: 0 },
|
||||||
|
{ id: 21, priority: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new next layer when dropped onto a drag-only new-layer target', () => {
|
||||||
|
const reordered = applyPriorityRailDrop(
|
||||||
|
[
|
||||||
|
{ id: 11, priority: 0 },
|
||||||
|
{ id: 12, priority: 0 },
|
||||||
|
{ id: 21, priority: 1 },
|
||||||
|
],
|
||||||
|
12,
|
||||||
|
createPriorityRailNewLayerId(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reordered).toEqual([
|
||||||
|
{ id: 11, priority: 0 },
|
||||||
|
{ id: 12, priority: 1 },
|
||||||
|
{ id: 21, priority: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { PriorityRailDragTarget, PriorityRailSection } from './types.js';
|
||||||
|
|
||||||
|
type PriorityRailChannelLike = {
|
||||||
|
id: number;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildPriorityRailDragTargetsOptions = {
|
||||||
|
activeChannelId: number;
|
||||||
|
hoveredPriority: number | null;
|
||||||
|
showNewLayerTarget: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_RAIL_NEW_LAYER_PREFIX = 'priority-rail:new-layer:';
|
||||||
|
|
||||||
|
export function createPriorityRailNewLayerId(priority: number): string {
|
||||||
|
return `${PRIORITY_RAIL_NEW_LAYER_PREFIX}${priority}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPriorityRailNewLayerId(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.startsWith(PRIORITY_RAIL_NEW_LAYER_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePriorityRailNewLayerPriority(value: string): number | null {
|
||||||
|
const raw = value.slice(PRIORITY_RAIL_NEW_LAYER_PREFIX.length);
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPriorityRailSections(
|
||||||
|
channels: PriorityRailChannelLike[],
|
||||||
|
): PriorityRailSection[] {
|
||||||
|
const grouped = new Map<number, number[]>();
|
||||||
|
|
||||||
|
for (const channel of channels || []) {
|
||||||
|
const priority = Number.isFinite(channel.priority) ? channel.priority : 0;
|
||||||
|
if (!grouped.has(priority)) grouped.set(priority, []);
|
||||||
|
grouped.get(priority)!.push(channel.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([priority, channelIds]) => ({
|
||||||
|
priority,
|
||||||
|
channelCount: channelIds.length,
|
||||||
|
channelIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriorityRailChannels<T extends PriorityRailChannelLike>(channels: T[]): T[] {
|
||||||
|
return [...(channels || [])].sort((a, b) => {
|
||||||
|
const priorityA = Number.isFinite(a.priority) ? a.priority : 0;
|
||||||
|
const priorityB = Number.isFinite(b.priority) ? b.priority : 0;
|
||||||
|
if (priorityA === priorityB) return a.id - b.id;
|
||||||
|
return priorityA - priorityB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPriorityRailDragTargets(
|
||||||
|
sections: PriorityRailSection[],
|
||||||
|
options: BuildPriorityRailDragTargetsOptions,
|
||||||
|
): PriorityRailDragTarget[] {
|
||||||
|
const targets: PriorityRailDragTarget[] = sections.map((section) => ({
|
||||||
|
kind: 'existing_layer',
|
||||||
|
priority: section.priority,
|
||||||
|
highlighted: section.priority === options.hoveredPriority,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (options.showNewLayerTarget) {
|
||||||
|
const highestPriority = sections.reduce((max, section) => Math.max(max, section.priority), -1);
|
||||||
|
targets.push({
|
||||||
|
kind: 'new_layer',
|
||||||
|
priority: highestPriority + 1,
|
||||||
|
highlighted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPriorityRailDrop<T extends PriorityRailChannelLike>(
|
||||||
|
channels: T[],
|
||||||
|
activeId: number,
|
||||||
|
overId: number | string,
|
||||||
|
): T[] {
|
||||||
|
const normalized = normalizePriorityRailChannels(channels);
|
||||||
|
const activeChannel = normalized.find((channel) => channel.id === activeId);
|
||||||
|
if (!activeChannel) return normalized;
|
||||||
|
|
||||||
|
if (isPriorityRailNewLayerId(overId)) {
|
||||||
|
const afterPriority = parsePriorityRailNewLayerPriority(overId);
|
||||||
|
if (afterPriority == null) return normalized;
|
||||||
|
const targetPriority = afterPriority + 1;
|
||||||
|
|
||||||
|
return normalizePriorityRailChannels(
|
||||||
|
normalized.map((channel) => {
|
||||||
|
const priority = Number.isFinite(channel.priority) ? channel.priority : 0;
|
||||||
|
if (channel.id === activeId) return { ...channel, priority: targetPriority };
|
||||||
|
if (channel.id !== activeId && priority > afterPriority) {
|
||||||
|
return { ...channel, priority: priority + 1 };
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetChannel = normalized.find((channel) => channel.id === Number(overId));
|
||||||
|
if (!targetChannel || targetChannel.id === activeId) return normalized;
|
||||||
|
|
||||||
|
const targetPriority = Number.isFinite(targetChannel.priority) ? targetChannel.priority : 0;
|
||||||
|
|
||||||
|
return normalizePriorityRailChannels(
|
||||||
|
normalized.map((channel) => (
|
||||||
|
channel.id === activeId
|
||||||
|
? { ...channel, priority: targetPriority }
|
||||||
|
: channel
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ export type MissingTokenGroupRouteSiteActionItem = {
|
|||||||
export type SortableChannelRowProps = {
|
export type SortableChannelRowProps = {
|
||||||
channel: RouteChannel;
|
channel: RouteChannel;
|
||||||
displayPriority?: number;
|
displayPriority?: number;
|
||||||
|
showPriorityBadge?: boolean;
|
||||||
decisionCandidate?: RouteDecisionCandidate;
|
decisionCandidate?: RouteDecisionCandidate;
|
||||||
isExactRoute: boolean;
|
isExactRoute: boolean;
|
||||||
loadingDecision: boolean;
|
loadingDecision: boolean;
|
||||||
@@ -152,3 +153,21 @@ export type GroupRouteItem = {
|
|||||||
channelCount: number;
|
channelCount: number;
|
||||||
sourceRouteCount: number;
|
sourceRouteCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PriorityRailSection = {
|
||||||
|
priority: number;
|
||||||
|
channelCount: number;
|
||||||
|
channelIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriorityRailDragTarget =
|
||||||
|
| {
|
||||||
|
kind: 'existing_layer';
|
||||||
|
priority: number;
|
||||||
|
highlighted: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'new_layer';
|
||||||
|
priority: number;
|
||||||
|
highlighted: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ describe('TokenRoutes mobile layout', () => {
|
|||||||
const expandedText = collectText(root!.root);
|
const expandedText = collectText(root!.root);
|
||||||
expect(expandedText).toContain('路由策略');
|
expect(expandedText).toContain('路由策略');
|
||||||
expect(expandedText).toContain('权重随机');
|
expect(expandedText).toContain('权重随机');
|
||||||
|
expect(expandedText).toContain('P0 · 1 通道');
|
||||||
expect(expandedText).toContain('user_a');
|
expect(expandedText).toContain('user_a');
|
||||||
expect(expandedText).toContain('token-a');
|
expect(expandedText).toContain('token-a');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user