[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
This commit is contained in:
Cita
2026-04-05 17:33:53 +08:00
committed by GitHub
parent b3e987a2f5
commit ff43fce9a7
2 changed files with 387 additions and 26 deletions
+161 -1
View File
@@ -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 { act, create, type ReactTestInstance } from 'react-test-renderer';
import { SortableContext } from '@dnd-kit/sortable'; 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<typeof import('@dnd-kit/sortable')>('@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 RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js'; import type { RouteChannel, RouteSummaryRow } from './types.js';
import { getRouteRoutingStrategyDescription } from './routingStrategy.js'; import { getRouteRoutingStrategyDescription } from './routingStrategy.js';
@@ -13,6 +34,10 @@ function collectText(node: ReactTestInstance): string {
}).join(''); }).join('');
} }
afterEach(() => {
sortableState.activeId = null;
});
const LONG_REGEX_PATTERN = 're:(?:.*|.*/)(minimax-m2.1)$'; const LONG_REGEX_PATTERN = 're:(?:.*|.*/)(minimax-m2.1)$';
function buildRoute(overrides: Partial<RouteSummaryRow> = {}): RouteSummaryRow { function buildRoute(overrides: Partial<RouteSummaryRow> = {}): RouteSummaryRow {
@@ -390,6 +415,141 @@ describe('RouteCard', () => {
expect(sortableContext.props.strategy).toBe(translateOnlyRectSortingStrategy); expect(sortableContext.props.strategy).toBe(translateOnlyRectSortingStrategy);
}); });
it('shows a new-layer drop target while dragging inside compact desktop detail panels', () => {
const renderCard = () => (
<RouteCard
route={buildRoute()}
brand={null}
expanded
compact
detailPanel
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 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 0 }),
]}
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 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(
<RouteCard
route={buildRoute()}
brand={null}
expanded
compact
detailPanel
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 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 0 }),
buildChannel({ id: 21, accountId: 103, tokenId: 1003, priority: 1 }),
]}
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 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', () => { it('renders desktop channel rows in sortable shell order within a single sortable list', () => {
const root = create( const root = create(
<RouteCard <RouteCard
+226 -25
View File
@@ -1,6 +1,8 @@
import { Fragment, memo, useState, type CSSProperties } from 'react'; import { Fragment, memo, useState, type CSSProperties, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { import {
DndContext, DndContext,
DragOverlay,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
closestCenter, closestCenter,
@@ -101,16 +103,71 @@ type RouteCardProps = {
function PriorityRailNewLayerRow({ function PriorityRailNewLayerRow({
id, id,
highlighted, highlighted,
compact = false,
}: { }: {
id: string; id: string;
highlighted: boolean; highlighted: boolean;
compact?: boolean;
}) { }) {
const { setNodeRef, isOver } = useDroppable({ id }); const { setNodeRef, isOver } = useDroppable({ id });
const active = highlighted || isOver; const active = highlighted || isOver;
if (compact) {
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
data-testid="route-priority-new-layer-target"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minHeight: 34,
padding: '0 2px',
}}
>
<div
style={{
flex: 1,
borderTop: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
opacity: active ? 1 : 0.7,
transition: 'border-color 0.16s ease, opacity 0.16s ease',
}}
/>
<div
style={{
minWidth: 84,
padding: '5px 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-card))'
: 'color-mix(in srgb, var(--color-bg-card) 96%, white 4%)',
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 11,
fontWeight: 600,
textAlign: 'center',
lineHeight: 1.2,
transition: 'border-color 0.16s ease, background 0.16s ease, color 0.16s ease',
}}
>
{tr('放到新档位')}
</div>
<div
style={{
flex: 1,
borderTop: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
opacity: active ? 1 : 0.7,
transition: 'border-color 0.16s ease, opacity 0.16s ease',
}}
/>
</div>
);
}
return (
<div
ref={setNodeRef}
data-testid="route-priority-new-layer-target"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '86px minmax(0, 1fr)', gridTemplateColumns: '86px minmax(0, 1fr)',
@@ -149,6 +206,132 @@ function PriorityRailNewLayerRow({
); );
} }
function PriorityBucketHeader({
label,
testId,
}: {
label: string;
testId?: string;
}) {
return (
<div
data-testid={testId}
className="route-priority-bucket-header"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
padding: '0 2px',
fontSize: 11,
color: 'var(--color-text-secondary)',
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-secondary)',
}}
>
{label}
</span>
</div>
);
}
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 (
<div
data-testid="route-channel-drag-overlay"
style={{
width: resolvedWidth,
height: '100%',
maxWidth: 'calc(100vw - 32px)',
boxSizing: 'border-box',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 10,
alignItems: 'center',
padding: '10px 12px',
borderRadius: 16,
border: '1px solid color-mix(in srgb, var(--color-info) 36%, var(--color-border-light))',
background: 'color-mix(in srgb, var(--color-bg-card) 80%, var(--color-info) 20%)',
boxShadow: '0 18px 34px rgba(15, 23, 42, 0.14)',
color: 'var(--color-text-primary)',
pointerEvents: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...buildPriorityRailNodeStyle(displayPriority, true),
}}
>
P{displayPriority}
</span>
<span style={{ fontWeight: 600, minWidth: 0 }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
当前生效:{effectiveTokenName}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
{channel.manualOverride ? (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
手动配置
</span>
) : null}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>
成功/失败 <span style={{ color: 'var(--color-success)', fontWeight: 600 }}>{channel.successCount || 0}</span>
<span style={{ color: 'var(--color-text-muted)', margin: '0 2px' }}>/</span>
<span style={{ color: 'var(--color-danger)', fontWeight: 600 }}>{channel.failCount || 0}</span>
</div>
</div>
);
}
function renderDragOverlayNode(node: ReactNode) {
if (typeof document === 'undefined' || !document.body) {
return node;
}
return createPortal(node, document.body);
}
type SortableChannelShellProps = { type SortableChannelShellProps = {
channel: RouteChannel; channel: RouteChannel;
bucketIndex: number; bucketIndex: number;
@@ -175,6 +358,8 @@ type SortableChannelShellProps = {
railLabel: string; railLabel: string;
mobileRailLabel: string; mobileRailLabel: string;
railNodeStyle: CSSProperties; railNodeStyle: CSSProperties;
showCompactRailHeader: boolean;
useDragOverlay: boolean;
}; };
function SortableChannelShell({ function SortableChannelShell({
@@ -203,6 +388,8 @@ function SortableChannelShell({
railLabel, railLabel,
mobileRailLabel, mobileRailLabel,
railNodeStyle, railNodeStyle,
showCompactRailHeader,
useDragOverlay,
}: SortableChannelShellProps) { }: SortableChannelShellProps) {
const { const {
attributes, attributes,
@@ -236,6 +423,7 @@ function SortableChannelShell({
data-testid="route-channel-shell" data-testid="route-channel-shell"
data-channel-id={channel.id} data-channel-id={channel.id}
style={{ style={{
visibility: useDragOverlay && isDragging ? 'hidden' : undefined,
transform: CSS.Translate.toString(translatedTransform), transform: CSS.Translate.toString(translatedTransform),
transition: shellTransition || undefined, transition: shellTransition || undefined,
zIndex: isDragging ? 10 : undefined, zIndex: isDragging ? 10 : undefined,
@@ -247,29 +435,8 @@ function SortableChannelShell({
alignItems: 'stretch', alignItems: 'stretch',
}} }}
> >
{compact && channelIndex === 0 ? ( {compact && showCompactRailHeader ? (
<div <PriorityBucketHeader label={mobileRailLabel} />
className="route-priority-bucket-header"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
padding: '0 2px',
fontSize: 11,
color: 'var(--color-text-secondary)',
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-secondary)',
}}
>
{mobileRailLabel}
</span>
</div>
) : null} ) : null}
{!compact ? ( {!compact ? (
@@ -429,20 +596,30 @@ function RouteCardInner({
const priorityBuckets = buildPriorityBuckets(channels || []); const priorityBuckets = buildPriorityBuckets(channels || []);
const priorityRailSections = buildPriorityRailSections(channels || []); const priorityRailSections = buildPriorityRailSections(channels || []);
const [activeDragChannelId, setActiveDragChannelId] = useState<number | null>(null); const [activeDragChannelId, setActiveDragChannelId] = useState<number | null>(null);
const [activeDragRowWidth, setActiveDragRowWidth] = useState<number | null>(null);
const useDragOverlay = compact && detailPanel;
const clearDragState = () => { const clearDragState = () => {
setActiveDragChannelId(null); setActiveDragChannelId(null);
setActiveDragRowWidth(null);
}; };
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
const nextId = Number(event.active.id); const nextId = Number(event.active.id);
setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null); setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null);
setActiveDragRowWidth(event.active.rect?.current?.initial?.width ?? null);
}; };
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
onChannelDragEnd(route.id, event); onChannelDragEnd(route.id, event);
clearDragState(); 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 = () => { const renderClearCooldownButton = () => {
if (readOnlyRoute) return null; if (readOnlyRoute) return null;
return ( return (
@@ -949,10 +1126,20 @@ function RouteCardInner({
const railLabel = `P${bucketIndex} · ${bucket.channels.length}`; const railLabel = `P${bucketIndex} · ${bucket.channels.length}`;
const mobileRailLabel = `${railLabel} ${tr('通道')}`; const mobileRailLabel = `${railLabel} ${tr('通道')}`;
const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, false); const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, false);
const showNewLayerTarget = !compact && activeDragChannelId != null && !readOnlyRoute; const showStandaloneCompactRailHeader = compact && detailPanel;
const showNewLayerTarget = activeDragChannelId != null
&& !readOnlyRoute
&& (!compact || detailPanel);
return ( return (
<Fragment key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`}> <Fragment key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`}>
{showStandaloneCompactRailHeader ? (
<PriorityBucketHeader
label={mobileRailLabel}
testId="route-priority-bucket-header"
/>
) : null}
{bucket.channels.map((channel, channelIndex) => { {bucket.channels.map((channel, channelIndex) => {
return ( return (
<SortableChannelShell <SortableChannelShell
@@ -982,6 +1169,8 @@ function RouteCardInner({
railLabel={railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel} railLabel={railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel}
mobileRailLabel={mobileRailLabel} mobileRailLabel={mobileRailLabel}
railNodeStyle={railNodeStyle} railNodeStyle={railNodeStyle}
showCompactRailHeader={!showStandaloneCompactRailHeader && channelIndex === 0}
useDragOverlay={useDragOverlay}
/> />
); );
})} })}
@@ -990,6 +1179,7 @@ function RouteCardInner({
<PriorityRailNewLayerRow <PriorityRailNewLayerRow
id={createPriorityRailNewLayerId(bucket.priority)} id={createPriorityRailNewLayerId(bucket.priority)}
highlighted={false} highlighted={false}
compact={compact}
/> />
) : null} ) : null}
</Fragment> </Fragment>
@@ -997,6 +1187,17 @@ function RouteCardInner({
})} })}
</div> </div>
</SortableContext> </SortableContext>
{useDragOverlay ? renderDragOverlayNode(
<DragOverlay>
{activeDragChannel ? (
<PriorityDragPreview
channel={activeDragChannel}
displayPriority={Math.max(0, activeDragBucketIndex)}
width={activeDragRowWidth}
/>
) : null}
</DragOverlay>,
) : null}
</DndContext> </DndContext>
</div> </div>
) : ( ) : (