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