From ff43fce9a72102a4d85f6cac6cc3dad5199ec557 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 5 Apr 2026 17:33:53 +0800 Subject: [PATCH] [codex] fix route drag overlay alignment (#419) * fix route drag overlay alignment * fix(web): address route drag review comments * fix route card review follow-up --- src/web/pages/token-routes/RouteCard.test.tsx | 162 ++++++++++- src/web/pages/token-routes/RouteCard.tsx | 251 ++++++++++++++++-- 2 files changed, 387 insertions(+), 26 deletions(-) diff --git a/src/web/pages/token-routes/RouteCard.test.tsx b/src/web/pages/token-routes/RouteCard.test.tsx index 0e4352e..8afa8a4 100644 --- a/src/web/pages/token-routes/RouteCard.test.tsx +++ b/src/web/pages/token-routes/RouteCard.test.tsx @@ -1,6 +1,27 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { SortableContext } from '@dnd-kit/sortable'; + +const sortableState = vi.hoisted(() => ({ + activeId: null as number | string | null, +})); + +vi.mock('@dnd-kit/sortable', async () => { + const actual = await vi.importActual('@dnd-kit/sortable'); + return { + ...actual, + useSortable: ({ id }: { id: number | string }) => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + setActivatorNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: sortableState.activeId === id, + }), + }; +}); + import RouteCard from './RouteCard.js'; import type { RouteChannel, RouteSummaryRow } from './types.js'; import { getRouteRoutingStrategyDescription } from './routingStrategy.js'; @@ -13,6 +34,10 @@ function collectText(node: ReactTestInstance): string { }).join(''); } +afterEach(() => { + sortableState.activeId = null; +}); + const LONG_REGEX_PATTERN = 're:(?:.*|.*/)(minimax-m2.1)$'; function buildRoute(overrides: Partial = {}): RouteSummaryRow { @@ -390,6 +415,141 @@ describe('RouteCard', () => { expect(sortableContext.props.strategy).toBe(translateOnlyRectSortingStrategy); }); + it('shows a new-layer drop target while dragging inside compact desktop detail panels', () => { + const renderCard = () => ( + + ); + const root = create(renderCard()); + + const dndContext = root.root.find((node) => ( + typeof node.props.onDragStart === 'function' + && typeof node.props.onDragEnd === 'function' + && typeof node.props.onDragCancel === 'function' + )); + + act(() => { + sortableState.activeId = 12; + dndContext.props.onDragStart?.({ + active: { id: 12 }, + }); + root.update(renderCard()); + }); + + expect(collectText(root.root)).toContain('放到新档位'); + const shells = root.root.findAll((node) => ( + node.type === 'div' + && node.props['data-testid'] === 'route-channel-shell' + )); + const activeShell = shells.find((node) => node.props['data-channel-id'] === 12); + expect(activeShell).toBeDefined(); + expect(activeShell?.props.style.visibility).toBe('hidden'); + + const newLayerTarget = root.root.find((node) => ( + node.type === 'div' + && node.props['data-testid'] === 'route-priority-new-layer-target' + )); + expect(newLayerTarget.props.style.display).toBe('flex'); + expect(newLayerTarget.props.style.minHeight).toBe(34); + }); + + it('keeps compact desktop detail bucket headers outside draggable channel shells', () => { + const root = create( + , + ); + + const bucketHeaders = root.root.findAll((node) => ( + node.type === 'div' + && node.props['data-testid'] === 'route-priority-bucket-header' + )); + const shells = root.root.findAll((node) => ( + node.type === 'div' + && node.props['data-testid'] === 'route-channel-shell' + )); + + expect(bucketHeaders.map((node) => collectText(node))).toEqual([ + 'P0 · 2 通道', + 'P1 · 1 通道', + ]); + expect(shells).toHaveLength(3); + expect(collectText(shells[0]!)).not.toContain('P0 · 2 通道'); + expect(collectText(shells[2]!)).not.toContain('P1 · 1 通道'); + }); + it('renders desktop channel rows in sortable shell order within a single sortable list', () => { const root = create( +
+
+ {tr('放到新档位')} +
+
+
+ ); + } + return (
+ + {label} + +
+ ); +} + +function PriorityDragPreview({ + channel, + displayPriority, + width, +}: { + channel: RouteChannel; + displayPriority: number; + width?: number | null; +}) { + const resolvedWidth = Number.isFinite(width ?? Number.NaN) ? width ?? undefined : undefined; + const effectiveTokenName = channel.token?.name || `account-${channel.accountId}`; + + return ( +
+
+ + P{displayPriority} + + + {channel.account?.username || `account-${channel.accountId}`} + + + {channel.site?.name || 'unknown'} + + + 当前生效:{effectiveTokenName} + + {channel.sourceModel ? ( + + {channel.sourceModel} + + ) : null} + {channel.manualOverride ? ( + + 手动配置 + + ) : null} +
+
+ 成功/失败 {channel.successCount || 0} + / + {channel.failCount || 0} +
+
+ ); +} + +function renderDragOverlayNode(node: ReactNode) { + if (typeof document === 'undefined' || !document.body) { + return node; + } + return createPortal(node, document.body); +} + type SortableChannelShellProps = { channel: RouteChannel; bucketIndex: number; @@ -175,6 +358,8 @@ type SortableChannelShellProps = { railLabel: string; mobileRailLabel: string; railNodeStyle: CSSProperties; + showCompactRailHeader: boolean; + useDragOverlay: boolean; }; function SortableChannelShell({ @@ -203,6 +388,8 @@ function SortableChannelShell({ railLabel, mobileRailLabel, railNodeStyle, + showCompactRailHeader, + useDragOverlay, }: SortableChannelShellProps) { const { attributes, @@ -236,6 +423,7 @@ function SortableChannelShell({ data-testid="route-channel-shell" data-channel-id={channel.id} style={{ + visibility: useDragOverlay && isDragging ? 'hidden' : undefined, transform: CSS.Translate.toString(translatedTransform), transition: shellTransition || undefined, zIndex: isDragging ? 10 : undefined, @@ -247,29 +435,8 @@ function SortableChannelShell({ alignItems: 'stretch', }} > - {compact && channelIndex === 0 ? ( -
- - {mobileRailLabel} - -
+ {compact && showCompactRailHeader ? ( + ) : null} {!compact ? ( @@ -429,20 +596,30 @@ function RouteCardInner({ const priorityBuckets = buildPriorityBuckets(channels || []); const priorityRailSections = buildPriorityRailSections(channels || []); const [activeDragChannelId, setActiveDragChannelId] = useState(null); + const [activeDragRowWidth, setActiveDragRowWidth] = useState(null); + const useDragOverlay = compact && detailPanel; const clearDragState = () => { setActiveDragChannelId(null); + setActiveDragRowWidth(null); }; const handleDragStart = (event: DragStartEvent) => { const nextId = Number(event.active.id); setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null); + setActiveDragRowWidth(event.active.rect?.current?.initial?.width ?? null); }; const handleDragEnd = (event: DragEndEvent) => { onChannelDragEnd(route.id, event); clearDragState(); }; + 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 renderClearCooldownButton = () => { if (readOnlyRoute) return null; return ( @@ -949,10 +1126,20 @@ function RouteCardInner({ const railLabel = `P${bucketIndex} · ${bucket.channels.length}`; const mobileRailLabel = `${railLabel} ${tr('通道')}`; const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, false); - const showNewLayerTarget = !compact && activeDragChannelId != null && !readOnlyRoute; + const showStandaloneCompactRailHeader = compact && detailPanel; + const showNewLayerTarget = activeDragChannelId != null + && !readOnlyRoute + && (!compact || detailPanel); return ( + {showStandaloneCompactRailHeader ? ( + + ) : null} + {bucket.channels.map((channel, channelIndex) => { return ( ); })} @@ -990,6 +1179,7 @@ function RouteCardInner({ ) : null} @@ -997,6 +1187,17 @@ function RouteCardInner({ })}
+ {useDragOverlay ? renderDragOverlayNode( + + {activeDragChannel ? ( + + ) : null} + , + ) : null} ) : (