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

* feat: add route priority bucket editor

* fix: address route editor review comments
This commit is contained in:
Cita
2026-03-31 21:23:02 +08:00
committed by GitHub
parent baa11d7787
commit e74e11375c
12 changed files with 763 additions and 192 deletions
@@ -56,6 +56,7 @@ describe('RouteCard', () => {
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
onSplitPriorityBucket={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
+106 -133
View File
@@ -28,6 +28,7 @@ import type {
} from './types.js';
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import { SortableChannelRow } from './SortableChannelRow.js';
import { SortableBucketSeparator } from './SortableBucketSeparator.js';
import {
getRouteRoutingStrategyLabel,
normalizeRouteRoutingStrategyValue,
@@ -37,8 +38,11 @@ import {
isExplicitGroupRoute,
resolveRouteTitle,
resolveRouteIcon,
buildSourceGroupKey,
} from './utils.js';
import {
buildPriorityBucketEditorItems,
buildPriorityBuckets,
} from './priorityBuckets.js';
type RouteCardProps = {
route: RouteSummaryRow;
@@ -67,6 +71,7 @@ type RouteCardProps = {
onDeleteChannel: (channelId: number, routeId: number) => void;
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
onSplitPriorityBucket: (routeId: number, channelId: number) => void;
// Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
@@ -116,6 +121,7 @@ function RouteCardInner({
onDeleteChannel,
onToggleChannelEnabled,
onChannelDragEnd,
onSplitPriorityBucket,
missingTokenSiteItems,
missingTokenGroupItems,
onCreateTokenForMissing,
@@ -159,25 +165,8 @@ function RouteCardInner({
(routeDecision?.candidates || []).map((c) => [c.channelId, c]),
);
const channelGroups = (() => {
if (!channels || channels.length === 0) return [];
const groups = new Map<string, RouteChannel[]>();
for (const channel of channels) {
const key = (channel.sourceModel || '').trim() || '__ungrouped__';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(channel);
}
return Array.from(groups.entries())
.sort((a, b) => {
if (a[0] === '__ungrouped__') return 1;
if (b[0] === '__ungrouped__') return -1;
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
})
.map(([sourceModel, chans]) => ({
sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel,
channels: chans,
}));
})();
const priorityBuckets = buildPriorityBuckets(channels || []);
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
// Collapsed card
if (!expanded) {
@@ -400,11 +389,11 @@ function RouteCardInner({
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;当前策略以群组设置为准,来源模型会尽量跟随同步,但已单独自定义或被其他群组复用的来源模型不会被覆盖。')}
{tr('该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。')}
</div>
) : !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
{tr('通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。')}
</div>
) : null}
@@ -487,120 +476,104 @@ function RouteCardInner({
) : channels && channels.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{channelGroups.map((group) => {
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
const supportsCollapse = !exactRoute && !!group.sourceModel;
const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
<SortableContext items={bucketEditorItems.map((item) => item.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{priorityBuckets.map((bucket, bucketIndex) => {
const sourceModelCount = new Set(
bucket.channels
.map((channel) => (channel.sourceModel || '').trim())
.filter(Boolean),
).size;
return (
<div key={`${route.id}-${group.sourceModel || '__ungrouped__'}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{group.sourceModel ? (
supportsCollapse ? (
<button
type="button"
onClick={() => onToggleSourceGroup(groupKey)}
aria-expanded={isGroupExpanded}
className="btn btn-ghost"
style={{
fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '4px 6px', border: '1px dashed var(--color-border)',
borderRadius: 'var(--radius-sm)', background: 'transparent',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span>来源模型</span>
<code style={{ fontSize: 11, border: '1px solid var(--color-border)', borderRadius: 6, padding: '2px 6px', background: 'var(--color-bg)' }}>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
return (
<div key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div
className="route-priority-bucket-header"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '4px 2px',
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{`P${bucketIndex}`}
</span>
<span>{`${bucket.channels.length} ${tr('通道')}`}</span>
{!exactRoute && sourceModelCount > 0 ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{`${sourceModelCount} ${tr('来源模型')}`}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-text-muted)' }}>
{isGroupExpanded ? '收起' : '展开'}
<svg width="12" height="12" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: isGroupExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</span>
</button>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 2 }}>
<span>来源模型</span>
<code style={{ fontSize: 11, border: '1px solid var(--color-border)', borderRadius: 6, padding: '2px 6px', background: 'var(--color-bg)' }}>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</div>
)
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
{explicitGroupRoute ? (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={!!savingPriority}
readOnly
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
/>
);
})}
</SortableContext>
) : (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
/>
);
})}
</SortableContext>
)}
</AnimatedCollapseSection>
{!isGroupExpanded && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
已收起,点击展开查看通道
) : null}
</div>
)}
</div>
);
})}
</div>
{bucket.channels.map((channel, channelIndex) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<div key={channel.id} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<SortableChannelRow
channel={channel}
displayPriority={bucketIndex}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
readOnly={readOnlyRoute}
channelManagementDisabled={channelManagementDisabled}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
onSiteBlockModel={channelManagementDisabled ? undefined : () => onSiteBlockModel(channel.id, route.id)}
/>
{!readOnlyRoute && channelIndex < bucket.channels.length - 1 ? (
<button
type="button"
className="btn btn-ghost"
onClick={() => onSplitPriorityBucket(route.id, channel.id)}
disabled={savingPriority}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
border: '1px dashed var(--color-border)',
borderRadius: 999,
background: 'transparent',
color: 'var(--color-text-muted)',
padding: '2px 10px',
fontSize: 11,
alignSelf: 'center',
}}
>
{`拆分为 P${bucketIndex + 1}`}
</button>
) : null}
</div>
);
})}
{bucketIndex < priorityBuckets.length - 1 ? (
<SortableBucketSeparator
id={String(bucketEditorItems.find((item) => item.kind === 'separator' && item.id === `priority-separator:${bucketIndex}`)?.id || `priority-separator:${bucketIndex}`)}
beforePriority={bucketIndex}
afterPriority={bucketIndex + 1}
isSavingPriority={savingPriority}
/>
) : null}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
) : (
@@ -0,0 +1,74 @@
import { type CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type SortableBucketSeparatorProps = {
id: string;
beforePriority: number;
afterPriority: number;
isSavingPriority: boolean;
};
export function SortableBucketSeparator({
id,
beforePriority,
afterPriority,
isSavingPriority,
}: SortableBucketSeparatorProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id,
disabled: isSavingPriority,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '4px 2px',
color: 'var(--color-text-muted)',
};
return (
<div ref={setNodeRef} style={style}>
<div style={{ flex: 1, height: 1, background: 'var(--color-border)' }} />
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority}
className="btn btn-ghost"
aria-label={`拖拽调整 P${beforePriority} / P${afterPriority} 分界线`}
data-tooltip={`拖拽调整 P${beforePriority} / P${afterPriority} 分界线`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
border: '1px solid var(--color-border)',
borderRadius: 999,
background: 'var(--color-bg)',
padding: '2px 10px',
fontSize: 11,
color: 'var(--color-text-muted)',
cursor: isSavingPriority ? 'not-allowed' : 'grab',
}}
>
<span>{`P${beforePriority}`}</span>
<span style={{ fontSize: 10 }}>||</span>
<span>{`P${afterPriority}`}</span>
</button>
<div style={{ flex: 1, height: 1, background: 'var(--color-border)' }} />
</div>
);
}
@@ -13,11 +13,13 @@ import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } fro
export function SortableChannelRow({
channel,
displayPriority,
decisionCandidate,
isExactRoute,
loadingDecision,
isSavingPriority,
readOnly = false,
channelManagementDisabled = false,
mobile = false,
tokenOptions,
activeTokenId,
@@ -41,13 +43,16 @@ export function SortableChannelRow({
disabled: isSavingPriority || readOnly,
});
const resolvedPriority = displayPriority ?? channel.priority ?? 0;
const managementLocked = readOnly || channelManagementDisabled;
const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : 1,
display: 'grid',
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center',
gap: 8,
padding: mobile ? '10px 12px' : '8px 12px',
@@ -93,8 +98,8 @@ export function SortableChannelRow({
opacity: readOnly ? 0.65 : 1,
marginTop: 2,
}}
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
aria-label="拖拽调整优先级"
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
@@ -114,10 +119,10 @@ export function SortableChannelRow({
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(channel.priority ?? 0),
...getPriorityTagStyle(resolvedPriority),
}}
>
P{channel.priority ?? 0}
P{resolvedPriority}
</span>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
@@ -218,7 +223,7 @@ export function SortableChannelRow({
</span>
</div>
{!readOnly && (
{!managementLocked && (
<button
type="button"
className="btn btn-link"
@@ -230,7 +235,7 @@ export function SortableChannelRow({
)}
</div>
{!readOnly && mobileDetailsOpen && (
{!managementLocked && mobileDetailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ width: '100%' }}>
<ModernSelect
@@ -317,8 +322,8 @@ export function SortableChannelRow({
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
}}
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
aria-label="拖拽调整优先级"
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
@@ -336,10 +341,10 @@ export function SortableChannelRow({
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(channel.priority ?? 0),
...getPriorityTagStyle(resolvedPriority),
}}
>
P{channel.priority ?? 0}
P{resolvedPriority}
</span>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
@@ -444,7 +449,7 @@ export function SortableChannelRow({
</div>
</div>
{!readOnly ? (
{!managementLocked ? (
mobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
<div style={{ width: '100%' }}>
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import type { RouteChannel } from './types.js';
import {
applyPriorityBucketDrag,
buildPriorityBuckets,
createPriorityBucketSeparatorId,
splitPriorityBucketAfterChannel,
} from './priorityBuckets.js';
function buildChannel(id: number, priority: number): RouteChannel {
return {
id,
routeId: 1,
accountId: 100 + id,
tokenId: 200 + id,
sourceModel: id % 2 === 0 ? 'model-b' : 'model-a',
priority,
weight: 10,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
cooldownUntil: null,
account: { username: `user-${id}` },
site: { id: 300 + id, name: `site-${id}`, platform: 'new-api' },
token: { id: 200 + id, name: `token-${id}`, accountId: 100 + id, enabled: true, isDefault: true },
};
}
describe('priority bucket helpers', () => {
it('groups duplicate priorities into the same bucket', () => {
const buckets = buildPriorityBuckets([
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 2),
buildChannel(4, 2),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0]).toMatchObject({ priority: 0 });
expect(buckets[0].channels.map((channel) => channel.id)).toEqual([1, 2]);
expect(buckets[1]).toMatchObject({ priority: 2 });
expect(buckets[1].channels.map((channel) => channel.id)).toEqual([3, 4]);
});
it('moves a channel across a separator and preserves duplicate priorities', () => {
const reordered = applyPriorityBucketDrag(
[
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 1),
buildChannel(4, 2),
],
3,
1,
);
expect(reordered.map((channel) => ({ id: channel.id, priority: channel.priority }))).toEqual([
{ id: 1, priority: 0 },
{ id: 2, priority: 0 },
{ id: 3, priority: 0 },
{ id: 4, priority: 1 },
]);
});
it('moves a separator within adjacent buckets and dense-renormalizes priorities', () => {
const reordered = applyPriorityBucketDrag(
[
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 1),
buildChannel(4, 2),
buildChannel(5, 2),
],
createPriorityBucketSeparatorId(0),
3,
);
expect(reordered.map((channel) => ({ id: channel.id, priority: channel.priority }))).toEqual([
{ id: 1, priority: 0 },
{ id: 2, priority: 0 },
{ id: 3, priority: 0 },
{ id: 4, priority: 1 },
{ id: 5, priority: 1 },
]);
});
it('can split a single shared-priority bucket into a new next bucket', () => {
const reordered = splitPriorityBucketAfterChannel(
[
buildChannel(1, 0),
buildChannel(2, 0),
buildChannel(3, 0),
],
1,
);
expect(reordered.map((channel) => ({ id: channel.id, priority: channel.priority }))).toEqual([
{ id: 1, priority: 0 },
{ id: 2, priority: 1 },
{ id: 3, priority: 1 },
]);
});
});
@@ -0,0 +1,158 @@
import type { RouteChannel } from './types.js';
import { normalizeChannels } from './utils.js';
export const PRIORITY_BUCKET_SEPARATOR_PREFIX = 'priority-separator:';
export type PriorityBucket = {
priority: number;
channels: RouteChannel[];
};
type PriorityBucketEditorChannelItem = {
id: number;
kind: 'channel';
channel: RouteChannel;
};
type PriorityBucketEditorSeparatorItem = {
id: string;
kind: 'separator';
};
export type PriorityBucketEditorItem = PriorityBucketEditorChannelItem | PriorityBucketEditorSeparatorItem;
export function createPriorityBucketSeparatorId(index: number): string {
return `${PRIORITY_BUCKET_SEPARATOR_PREFIX}${index}`;
}
export function isPriorityBucketSeparatorId(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(PRIORITY_BUCKET_SEPARATOR_PREFIX);
}
export function buildPriorityBuckets(channels: RouteChannel[]): PriorityBucket[] {
const grouped = new Map<number, RouteChannel[]>();
for (const channel of normalizeChannels(channels || [])) {
const priority = channel.priority ?? 0;
if (!grouped.has(priority)) grouped.set(priority, []);
grouped.get(priority)!.push(channel);
}
return Array.from(grouped.entries()).map(([priority, bucketChannels]) => ({
priority,
channels: bucketChannels,
}));
}
export function buildPriorityBucketEditorItems(channels: RouteChannel[]): PriorityBucketEditorItem[] {
const buckets = buildPriorityBuckets(channels);
const items: PriorityBucketEditorItem[] = [];
buckets.forEach((bucket, index) => {
for (const channel of bucket.channels) {
items.push({ id: channel.id, kind: 'channel', channel });
}
if (index < buckets.length - 1) {
items.push({ id: createPriorityBucketSeparatorId(index), kind: 'separator' });
}
});
return items;
}
export function splitPriorityBucketAfterChannel(
channels: RouteChannel[],
channelId: number,
): RouteChannel[] {
const normalized = normalizeChannels(channels || []);
if (normalized.length <= 1) return normalized;
const items = buildPriorityBucketEditorItems(normalized);
const channelIndex = items.findIndex((item) => item.kind === 'channel' && item.id === channelId);
if (channelIndex < 0) return normalized;
const nextItem = items[channelIndex + 1];
if (!nextItem || nextItem.kind === 'separator') {
return normalized;
}
const next = [...items];
next.splice(channelIndex + 1, 0, {
id: `${PRIORITY_BUCKET_SEPARATOR_PREFIX}split:${channelId}`,
kind: 'separator',
});
return denseRenormalizeChannels(next);
}
function moveItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
const next = [...items];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}
function denseRenormalizeChannels(items: PriorityBucketEditorItem[]): RouteChannel[] {
let rawBucketIndex = 0;
let nextPriority = 0;
const rawToDense = new Map<number, number>();
const reordered: RouteChannel[] = [];
for (const item of items) {
if (item.kind === 'separator') {
rawBucketIndex += 1;
continue;
}
if (!rawToDense.has(rawBucketIndex)) {
rawToDense.set(rawBucketIndex, nextPriority);
nextPriority += 1;
}
reordered.push({
...item.channel,
priority: rawToDense.get(rawBucketIndex)!,
});
}
return normalizeChannels(reordered);
}
export function applyPriorityBucketDrag(
channels: RouteChannel[],
activeId: string | number,
overId: string | number,
): RouteChannel[] {
const normalized = normalizeChannels(channels || []);
if (normalized.length === 0 || activeId === overId) return normalized;
const items = buildPriorityBucketEditorItems(normalized);
const activeIndex = items.findIndex((item) => item.id === activeId);
const overIndex = items.findIndex((item) => item.id === overId);
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) {
return normalized;
}
const activeItem = items[activeIndex];
if (activeItem.kind === 'separator') {
const targetItem = items[overIndex];
if (targetItem.kind !== 'channel') {
return normalized;
}
let previousSeparatorIndex = -1;
for (let index = activeIndex - 1; index >= 0; index -= 1) {
if (items[index]?.kind === 'separator') {
previousSeparatorIndex = index;
break;
}
}
let nextSeparatorIndex = items.length;
for (let index = activeIndex + 1; index < items.length; index += 1) {
if (items[index]?.kind === 'separator') {
nextSeparatorIndex = index;
break;
}
}
if (overIndex <= previousSeparatorIndex || overIndex >= nextSeparatorIndex) {
return normalized;
}
}
return denseRenormalizeChannels(moveItem(items, activeIndex, overIndex));
}
+3
View File
@@ -16,6 +16,7 @@ export type RouteChannelDraft = {
export type RouteChannel = {
id: number;
routeId?: number;
accountId: number;
tokenId: number | null;
sourceModel?: string | null;
@@ -124,11 +125,13 @@ export type MissingTokenGroupRouteSiteActionItem = {
export type SortableChannelRowProps = {
channel: RouteChannel;
displayPriority?: number;
decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean;
loadingDecision: boolean;
isSavingPriority: boolean;
readOnly?: boolean;
channelManagementDisabled?: boolean;
mobile?: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;