forked from boos/BoosAPI
[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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user