[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:
Cita
2026-04-01 18:12:42 +08:00
committed by GitHub
parent 21d92e1887
commit 6e21f911ea
8 changed files with 608 additions and 184 deletions
+13 -72
View File
@@ -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}
+81 -3
View File
@@ -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');
});
}); });
+271 -87
View File
@@ -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 },
]);
});
});
+119
View File
@@ -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
)),
);
}
+19
View File
@@ -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 {