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,
|
||||
getModelPatternError,
|
||||
} 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 RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js';
|
||||
import ManualRoutePanel from './token-routes/ManualRoutePanel.js';
|
||||
@@ -1004,7 +1004,18 @@ export default function TokenRoutes() {
|
||||
if (!route) return;
|
||||
|
||||
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 previous = channels.find((item) => item.id === channel.id);
|
||||
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 channels = channelsByRouteId[routeId] || [];
|
||||
const channel = channels.find((c) => c.id === channelId);
|
||||
@@ -1760,7 +1703,6 @@ export default function TokenRoutes() {
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
onSplitPriorityBucket={handleSplitPriorityBucket}
|
||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
@@ -1801,7 +1743,6 @@ export default function TokenRoutes() {
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
onSplitPriorityBucket={handleSplitPriorityBucket}
|
||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import RouteCard from './RouteCard.js';
|
||||
import type { RouteSummaryRow } from './types.js';
|
||||
import type { RouteChannel, RouteSummaryRow } from './types.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
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', () => {
|
||||
it('truncates the collapsed regex badge while keeping the group name primary', () => {
|
||||
const root = create(
|
||||
@@ -58,7 +77,6 @@ describe('RouteCard', () => {
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
onSplitPriorityBucket={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
@@ -116,7 +134,6 @@ describe('RouteCard', () => {
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
onSplitPriorityBucket={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
@@ -136,4 +153,65 @@ describe('RouteCard', () => {
|
||||
button.props.onClick();
|
||||
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 {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
@@ -28,7 +32,6 @@ 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,
|
||||
@@ -40,9 +43,13 @@ import {
|
||||
resolveRouteIcon,
|
||||
} from './utils.js';
|
||||
import {
|
||||
buildPriorityBucketEditorItems,
|
||||
buildPriorityBuckets,
|
||||
} from './priorityBuckets.js';
|
||||
import {
|
||||
buildPriorityRailSections,
|
||||
createPriorityRailNewLayerId,
|
||||
isPriorityRailNewLayerId,
|
||||
} from './priorityRail.js';
|
||||
|
||||
type RouteCardProps = {
|
||||
route: RouteSummaryRow;
|
||||
@@ -73,7 +80,6 @@ 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[];
|
||||
@@ -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({
|
||||
route,
|
||||
brand,
|
||||
@@ -125,7 +224,6 @@ function RouteCardInner({
|
||||
onDeleteChannel,
|
||||
onToggleChannelEnabled,
|
||||
onChannelDragEnd,
|
||||
onSplitPriorityBucket,
|
||||
missingTokenSiteItems,
|
||||
missingTokenGroupItems,
|
||||
onCreateTokenForMissing,
|
||||
@@ -170,7 +268,41 @@ function RouteCardInner({
|
||||
);
|
||||
|
||||
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 = () => {
|
||||
if (readOnlyRoute) return null;
|
||||
return (
|
||||
@@ -489,18 +621,43 @@ function RouteCardInner({
|
||||
</div>
|
||||
) : channels && channels.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
|
||||
<SortableContext items={bucketEditorItems.map((item) => item.id)} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
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) => {
|
||||
const sourceModelCount = new Set(
|
||||
bucket.channels
|
||||
.map((channel) => (channel.sourceModel || '').trim())
|
||||
.filter(Boolean),
|
||||
).size;
|
||||
const railSection = priorityRailSections[bucketIndex];
|
||||
const railLabel = `P${bucketIndex} · ${bucket.channels.length}`;
|
||||
const mobileRailLabel = `${railLabel} ${tr('通道')}`;
|
||||
const hoveredExistingLayer = activeDragChannelId != null && hoveredBucketIndex === bucketIndex;
|
||||
const hoveredCrossLayer = hoveredExistingLayer && activeDragBucketIndex !== bucketIndex;
|
||||
const hoveredNewLayer = activeDragChannelId != null && hoveredNewLayerBucketIndex === bucketIndex;
|
||||
|
||||
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
|
||||
style={{
|
||||
display: compact ? 'flex' : 'grid',
|
||||
flexDirection: compact ? 'column' : undefined,
|
||||
gridTemplateColumns: compact ? undefined : '86px minmax(0, 1fr)',
|
||||
gap: compact ? 8 : 12,
|
||||
alignItems: compact ? 'stretch' : 'stretch',
|
||||
}}
|
||||
>
|
||||
{compact ? (
|
||||
<div
|
||||
className="route-priority-bucket-header"
|
||||
style={{
|
||||
@@ -508,30 +665,73 @@ function RouteCardInner({
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
padding: '4px 2px',
|
||||
padding: '2px 2px 0',
|
||||
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
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{mobileRailLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 86,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 72,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${hoveredExistingLayer ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: hoveredExistingLayer
|
||||
? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg))'
|
||||
: 'var(--color-bg)',
|
||||
color: hoveredExistingLayer ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
transition: 'all 0.16s ease',
|
||||
}}
|
||||
>
|
||||
{railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel}
|
||||
</div>
|
||||
{hoveredCrossLayer ? (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--color-primary)', textAlign: 'center' }}>
|
||||
{`放到 P${bucketIndex}`}
|
||||
</div>
|
||||
) : null}
|
||||
{bucketIndex < priorityBuckets.length - 1 ? (
|
||||
<div style={{ width: 1, flex: 1, marginTop: 8, background: 'var(--color-border)' }} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bucket.channels.map((channel, channelIndex) => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
|
||||
{bucket.channels.map((channel) => {
|
||||
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
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
displayPriority={bucketIndex}
|
||||
showPriorityBadge={compact}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
@@ -548,39 +748,15 @@ function RouteCardInner({
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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}
|
||||
{!compact && activeDragChannelId != null && !readOnlyRoute ? (
|
||||
<PriorityRailNewLayerRow
|
||||
id={createPriorityRailNewLayerId(bucket.priority)}
|
||||
highlighted={hoveredNewLayer}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -588,6 +764,14 @@ function RouteCardInner({
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragChannel && !compact ? (
|
||||
<PriorityDragPreview
|
||||
channel={activeDragChannel}
|
||||
displayPriority={Math.max(0, activeDragBucketIndex)}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } fro
|
||||
export function SortableChannelRow({
|
||||
channel,
|
||||
displayPriority,
|
||||
showPriorityBadge = true,
|
||||
decisionCandidate,
|
||||
isExactRoute,
|
||||
loadingDecision,
|
||||
@@ -113,6 +114,7 @@ export function SortableChannelRow({
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{showPriorityBadge ? (
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
@@ -124,6 +126,7 @@ export function SortableChannelRow({
|
||||
>
|
||||
P{resolvedPriority}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
|
||||
{channel.account?.username || `account-${channel.accountId}`}
|
||||
@@ -335,6 +338,7 @@ export function SortableChannelRow({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showPriorityBadge ? (
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
@@ -346,6 +350,7 @@ export function SortableChannelRow({
|
||||
>
|
||||
P{resolvedPriority}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
{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 = {
|
||||
channel: RouteChannel;
|
||||
displayPriority?: number;
|
||||
showPriorityBadge?: boolean;
|
||||
decisionCandidate?: RouteDecisionCandidate;
|
||||
isExactRoute: boolean;
|
||||
loadingDecision: boolean;
|
||||
@@ -152,3 +153,21 @@ export type GroupRouteItem = {
|
||||
channelCount: 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);
|
||||
expect(expandedText).toContain('路由策略');
|
||||
expect(expandedText).toContain('权重随机');
|
||||
expect(expandedText).toContain('P0 · 1 通道');
|
||||
expect(expandedText).toContain('user_a');
|
||||
expect(expandedText).toContain('token-a');
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user