perf(schedule): 减少重渲染与冗余动画
- useCanvasNodeDrag.moveDrag 改 ref 直改 DOM,松手才写 store,避免拖拽期间反复重渲染 SchedulePage - runs 轮询改 setTimeout 链式 + 移除 runs 依赖,避免每 1.5s 重建 interval - SchedulePage 拆 PositionDraftBadge / RunNowButton,删除冗余的 runs/runsLoading/positionDraftCount 订阅 - 拆 ScheduleEdge 组件 + React.memo + useMemo,切换 edge 选中时不再重算所有 edgePath - 删除 schedule.css 中引用未定义 keyframes 的 modal-in/spin 死动画 - 删除 .schedule-edge-line transition、.schedule-run-result-backdrop backdrop-filter;补 .artifact-card transition Refs: kaneo #19-#24
This commit is contained in:
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type ScheduleNode,
|
type ScheduleNode,
|
||||||
type ScheduleRunSummary,
|
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
|
||||||
import { useApi } from "../../context/AuthContext";
|
import { useApi } from "../../context/AuthContext";
|
||||||
@@ -18,19 +17,17 @@ import {
|
|||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
export function RunHistory({
|
export function RunHistory({
|
||||||
runs,
|
|
||||||
nodes,
|
nodes,
|
||||||
loading,
|
|
||||||
disabled,
|
disabled,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
runs: ScheduleRunSummary[];
|
|
||||||
nodes: ScheduleNode[];
|
nodes: ScheduleNode[];
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const runs = useSchedulesStore((s) => s.runs);
|
||||||
|
const loading = useSchedulesStore((s) => s.runsLoading);
|
||||||
const expandedRunId = useSchedulesStore((s) => s.expandedRunId);
|
const expandedRunId = useSchedulesStore((s) => s.expandedRunId);
|
||||||
const runDetails = useSchedulesStore((s) => s.runDetails);
|
const runDetails = useSchedulesStore((s) => s.runDetails);
|
||||||
const detailLoadingId = useSchedulesStore((s) => s.detailLoadingId);
|
const detailLoadingId = useSchedulesStore((s) => s.detailLoadingId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
|
memo,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -51,6 +52,113 @@ const NODE_WIDTH = 218;
|
|||||||
const NODE_HEIGHT = 104;
|
const NODE_HEIGHT = 104;
|
||||||
const ARTIFACT_MIME = "application/x-model-platform-version";
|
const ARTIFACT_MIME = "application/x-model-platform-version";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶部状态文案里 “N 个节点位置待保存” 那段抽出来,
|
||||||
|
* 让它独立订阅 positionDraftCount,避免每次 setPositionDraftCount 都让
|
||||||
|
* SchedulePage 整树重渲染。
|
||||||
|
*/
|
||||||
|
function PositionDraftBadge() {
|
||||||
|
const count = useSchedulesStore((s) => s.positionDraftCount);
|
||||||
|
if (count <= 0) return null;
|
||||||
|
return <> · {count} 个节点位置待保存</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* “立即运行” 按钮的 disabled / title 都依赖 positionDraftCount,
|
||||||
|
* 抽成子组件独立订阅该字段。
|
||||||
|
*/
|
||||||
|
function RunNowButton({
|
||||||
|
schedule,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
schedule: import("../../services/api").Schedule | null;
|
||||||
|
busy: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const positionDraftCount = useSchedulesStore((s) => s.positionDraftCount);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="schedule-action--run"
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!schedule
|
||||||
|
|| Boolean(busy)
|
||||||
|
|| !schedule.dag_validation.valid
|
||||||
|
|| schedule.nodes.length === 0
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
positionDraftCount > 0
|
||||||
|
? "请先保存节点位置"
|
||||||
|
: "立即执行当前已保存的稳定版本 DAG"
|
||||||
|
}
|
||||||
|
onClick={() => useSchedulesStore.getState().runNow()}
|
||||||
|
>
|
||||||
|
<Icon name="play" size={14} />立即运行
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把右键菜单打开逻辑提到模块级,使 ScheduleEdge 这类 memo 化的子组件
|
||||||
|
* 也能直接调用,避免通过 props 传入每渲染都新建的箭头函数。
|
||||||
|
*/
|
||||||
|
function openScheduleContextMenu(
|
||||||
|
event: ReactMouseEvent<HTMLElement | SVGElement>,
|
||||||
|
target: ScheduleContextMenuTarget,
|
||||||
|
): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const menuWidth = 176;
|
||||||
|
const menuHeight = target.kind === "schedule" ? 132 : 48;
|
||||||
|
useSchedulesStore.getState().setContextMenu({
|
||||||
|
...target,
|
||||||
|
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
|
||||||
|
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
|
||||||
|
} as ScheduleContextMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条调度连线。React.memo 包裹后,selectedEdgeId / nodes 引用未变就不
|
||||||
|
* 重渲染,避免切换选中状态时所有边重算 edgePath。
|
||||||
|
*/
|
||||||
|
const ScheduleEdge = memo(function ScheduleEdge({
|
||||||
|
edge,
|
||||||
|
nodes,
|
||||||
|
selectedEdgeId,
|
||||||
|
}: {
|
||||||
|
edge: ScheduleEdge;
|
||||||
|
nodes: ScheduleNode[];
|
||||||
|
selectedEdgeId: string | null;
|
||||||
|
}) {
|
||||||
|
const path = useMemo(() => {
|
||||||
|
const source = nodes.find((node) => node.node_id === edge.source_node_id);
|
||||||
|
const target = nodes.find((node) => node.node_id === edge.target_node_id);
|
||||||
|
if (!source || !target) return null;
|
||||||
|
return edgePath(source, target);
|
||||||
|
}, [nodes, edge.source_node_id, edge.target_node_id]);
|
||||||
|
if (!path) return null;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
className={selectedEdgeId === edge.edge_id ? "is-selected" : ""}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
useSchedulesStore.getState().setSelectedEdgeId(edge.edge_id);
|
||||||
|
useSchedulesStore.getState().setSelectedNodeId(null);
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => openScheduleContextMenu(event, {
|
||||||
|
kind: "edge",
|
||||||
|
edge,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<path className="schedule-edge-hit" d={path} />
|
||||||
|
<path
|
||||||
|
className="schedule-edge-line"
|
||||||
|
d={path}
|
||||||
|
markerEnd="url(#schedule-arrow)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default function SchedulePage({
|
export default function SchedulePage({
|
||||||
onNotify,
|
onNotify,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
@@ -89,19 +197,15 @@ export default function SchedulePage({
|
|||||||
const newScheduleName = useSchedulesStore((s) => s.newScheduleName);
|
const newScheduleName = useSchedulesStore((s) => s.newScheduleName);
|
||||||
const setNewScheduleName = useSchedulesStore((s) => s.setNewScheduleName);
|
const setNewScheduleName = useSchedulesStore((s) => s.setNewScheduleName);
|
||||||
const contextMenu = useSchedulesStore((s) => s.contextMenu);
|
const contextMenu = useSchedulesStore((s) => s.contextMenu);
|
||||||
const setContextMenu = useSchedulesStore((s) => s.setContextMenu);
|
|
||||||
const scheduleForm = useSchedulesStore((s) => s.scheduleForm);
|
const scheduleForm = useSchedulesStore((s) => s.scheduleForm);
|
||||||
const setScheduleForm = useSchedulesStore((s) => s.setScheduleForm);
|
const setScheduleForm = useSchedulesStore((s) => s.setScheduleForm);
|
||||||
const nodeForm = useSchedulesStore((s) => s.nodeForm);
|
const nodeForm = useSchedulesStore((s) => s.nodeForm);
|
||||||
const setNodeForm = useSchedulesStore((s) => s.setNodeForm);
|
const setNodeForm = useSchedulesStore((s) => s.setNodeForm);
|
||||||
const cronResult = useSchedulesStore((s) => s.cronResult);
|
const cronResult = useSchedulesStore((s) => s.cronResult);
|
||||||
const setCronResult = useSchedulesStore((s) => s.setCronResult);
|
const setCronResult = useSchedulesStore((s) => s.setCronResult);
|
||||||
const runs = useSchedulesStore((s) => s.runs);
|
|
||||||
const setRuns = useSchedulesStore((s) => s.setRuns);
|
const setRuns = useSchedulesStore((s) => s.setRuns);
|
||||||
const runsLoading = useSchedulesStore((s) => s.runsLoading);
|
|
||||||
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
|
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
const positionDraftCount = useSchedulesStore((state) => state.positionDraftCount);
|
|
||||||
|
|
||||||
const selectedNode = schedule?.nodes.find(
|
const selectedNode = schedule?.nodes.find(
|
||||||
(item) => item.node_id === selectedNodeId,
|
(item) => item.node_id === selectedNodeId,
|
||||||
@@ -120,21 +224,6 @@ export default function SchedulePage({
|
|||||||
|
|
||||||
useContextMenuDismiss();
|
useContextMenuDismiss();
|
||||||
|
|
||||||
const openContextMenu = (
|
|
||||||
event: ReactMouseEvent<HTMLElement | SVGElement>,
|
|
||||||
target: ScheduleContextMenuTarget,
|
|
||||||
): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const menuWidth = 176;
|
|
||||||
const menuHeight = target.kind === "schedule" ? 132 : 48;
|
|
||||||
setContextMenu({
|
|
||||||
...target,
|
|
||||||
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
|
|
||||||
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
|
|
||||||
} as ScheduleContextMenu);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId || !api) return;
|
if (!workspaceId || !api) return;
|
||||||
setSchedules([]);
|
setSchedules([]);
|
||||||
@@ -161,18 +250,29 @@ export default function SchedulePage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduleId = schedule?.schedule_id;
|
const scheduleId = schedule?.schedule_id;
|
||||||
if (!scheduleId || !api) return;
|
if (!scheduleId || !api) return;
|
||||||
const hasActiveRun = runs.some(
|
let timer: number | undefined;
|
||||||
(item) => item.run_status === "queued" || item.run_status === "running",
|
let cancelled = false;
|
||||||
);
|
const tick = (): void => {
|
||||||
const isEnabledCron = schedule.trigger_type === "cron" && schedule.enabled;
|
if (cancelled) return;
|
||||||
// Cron 会由后端在未来某个整分钟创建新记录。即使当前没有运行中的
|
|
||||||
// 记录,也要持续刷新,才能让新一轮运行自动出现在右侧列表中。
|
|
||||||
if (!hasActiveRun && !isEnabledCron) return;
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
void useSchedulesStore.getState().refreshRuns(scheduleId);
|
void useSchedulesStore.getState().refreshRuns(scheduleId);
|
||||||
}, hasActiveRun ? 1500 : 3000);
|
// 每次 tick 重新读最新 runs,不再把 runs 放进依赖数组,避免每次 setRuns
|
||||||
return () => window.clearInterval(timer);
|
// 都触发 effect cleanup + 重建 setInterval。
|
||||||
}, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, runs, api]);
|
const runs = useSchedulesStore.getState().runs;
|
||||||
|
const hasActiveRun = runs.some(
|
||||||
|
(item) => item.run_status === "queued" || item.run_status === "running",
|
||||||
|
);
|
||||||
|
const isEnabledCron = schedule.trigger_type === "cron" && schedule.enabled;
|
||||||
|
// Cron 会由后端在未来某个整分钟创建新记录。即使当前没有运行中的
|
||||||
|
// 记录,也要持续刷新,才能让新一轮运行自动出现在右侧列表中。
|
||||||
|
if (!hasActiveRun && !isEnabledCron) return;
|
||||||
|
timer = window.setTimeout(tick, hasActiveRun ? 1500 : 3000);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, api]);
|
||||||
|
|
||||||
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
|
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
|
||||||
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
@@ -220,12 +320,9 @@ export default function SchedulePage({
|
|||||||
<strong>图形化调度</strong>
|
<strong>图形化调度</strong>
|
||||||
<span>
|
<span>
|
||||||
{schedule
|
{schedule
|
||||||
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${
|
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}`
|
||||||
positionDraftCount > 0
|
|
||||||
? ` · ${positionDraftCount} 个节点位置待保存`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
: "创建调度后开始编排"}
|
: "创建调度后开始编排"}
|
||||||
|
<PositionDraftBadge />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="schedule-toolbar__actions">
|
<div className="schedule-toolbar__actions">
|
||||||
@@ -247,24 +344,7 @@ export default function SchedulePage({
|
|||||||
>
|
>
|
||||||
校验 DAG
|
校验 DAG
|
||||||
</button>
|
</button>
|
||||||
<button
|
<RunNowButton schedule={schedule} busy={busy} />
|
||||||
className="schedule-action--run"
|
|
||||||
type="button"
|
|
||||||
disabled={
|
|
||||||
!schedule
|
|
||||||
|| Boolean(busy)
|
|
||||||
|| !schedule.dag_validation.valid
|
|
||||||
|| schedule.nodes.length === 0
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
positionDraftCount > 0
|
|
||||||
? "请先保存节点位置"
|
|
||||||
: "立即执行当前已保存的稳定版本 DAG"
|
|
||||||
}
|
|
||||||
onClick={() => useSchedulesStore.getState().runNow()}
|
|
||||||
>
|
|
||||||
<Icon name="play" size={14} />立即运行
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="schedule-action--danger"
|
className="schedule-action--danger"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -284,7 +364,7 @@ export default function SchedulePage({
|
|||||||
keyword={scheduleKeyword}
|
keyword={scheduleKeyword}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSelect={(id) => useSchedulesStore.getState().chooseSchedule(id)}
|
onSelect={(id) => useSchedulesStore.getState().chooseSchedule(id)}
|
||||||
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
onContextMenu={(event, item) => openScheduleContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
||||||
onKeywordChange={setScheduleKeyword}
|
onKeywordChange={setScheduleKeyword}
|
||||||
/>
|
/>
|
||||||
<ArtifactList
|
<ArtifactList
|
||||||
@@ -292,7 +372,7 @@ export default function SchedulePage({
|
|||||||
keyword={artifactKeyword}
|
keyword={artifactKeyword}
|
||||||
onDrop={(artifact, x, y) => void useSchedulesStore.getState().addArtifactAt(artifact, x, y)}
|
onDrop={(artifact, x, y) => void useSchedulesStore.getState().addArtifactAt(artifact, x, y)}
|
||||||
onDoubleClick={(artifact) => void useSchedulesStore.getState().addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
onDoubleClick={(artifact) => void useSchedulesStore.getState().addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
||||||
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
onContextMenu={(event, artifact) => openScheduleContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
||||||
onKeywordChange={setArtifactKeyword}
|
onKeywordChange={setArtifactKeyword}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -344,38 +424,14 @@ export default function SchedulePage({
|
|||||||
<path d="M0,0 L8,4 L0,8 Z" fill="#4e92db" />
|
<path d="M0,0 L8,4 L0,8 Z" fill="#4e92db" />
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
{schedule.edges.map((edge) => {
|
{schedule.edges.map((edge) => (
|
||||||
const source = schedule.nodes.find(
|
<ScheduleEdge
|
||||||
(node) => node.node_id === edge.source_node_id,
|
key={edge.edge_id}
|
||||||
);
|
edge={edge}
|
||||||
const target = schedule.nodes.find(
|
nodes={schedule.nodes}
|
||||||
(node) => node.node_id === edge.target_node_id,
|
selectedEdgeId={selectedEdgeId}
|
||||||
);
|
/>
|
||||||
if (!source || !target) return null;
|
))}
|
||||||
const path = edgePath(source, target);
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
className={selectedEdgeId === edge.edge_id ? "is-selected" : ""}
|
|
||||||
key={edge.edge_id}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setSelectedEdgeId(edge.edge_id);
|
|
||||||
setSelectedNodeId(null);
|
|
||||||
}}
|
|
||||||
onContextMenu={(event) => openContextMenu(event, {
|
|
||||||
kind: "edge",
|
|
||||||
edge,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<path className="schedule-edge-hit" d={path} />
|
|
||||||
<path
|
|
||||||
className="schedule-edge-line"
|
|
||||||
d={path}
|
|
||||||
markerEnd="url(#schedule-arrow)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -395,7 +451,7 @@ export default function SchedulePage({
|
|||||||
onPointerMove={moveDrag}
|
onPointerMove={moveDrag}
|
||||||
onPointerUp={finishDrag}
|
onPointerUp={finishDrag}
|
||||||
onPointerCancel={finishDrag}
|
onPointerCancel={finishDrag}
|
||||||
onContextMenu={(event) => openContextMenu(event, {
|
onContextMenu={(event) => openScheduleContextMenu(event, {
|
||||||
kind: "node",
|
kind: "node",
|
||||||
node,
|
node,
|
||||||
})}
|
})}
|
||||||
@@ -515,9 +571,7 @@ export default function SchedulePage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RunHistory
|
<RunHistory
|
||||||
runs={runs}
|
|
||||||
nodes={schedule?.nodes ?? []}
|
nodes={schedule?.nodes ?? []}
|
||||||
loading={runsLoading}
|
|
||||||
disabled={!schedule || Boolean(busy)}
|
disabled={!schedule || Boolean(busy)}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
if (schedule) void useSchedulesStore.getState().refreshRuns(schedule.schedule_id, true);
|
if (schedule) void useSchedulesStore.getState().refreshRuns(schedule.schedule_id, true);
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ type DragState = {
|
|||||||
startClientY: number;
|
startClientY: number;
|
||||||
originX: number;
|
originX: number;
|
||||||
originY: number;
|
originY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
moved: boolean;
|
moved: boolean;
|
||||||
|
nodeEl: HTMLDivElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布节点拖拽编排。内部持有瞬时 dragRef,避免每次拖拽都触发 store 重渲染。
|
* 画布节点拖拽编排。内部持有瞬时 dragRef + 节点 DOM 引用,避免每次拖拽都触发 store
|
||||||
* 拖拽过程中直接改 schedule.nodes 的 position;松手时把 rounded 后的位置写到 store 的
|
* 重渲染:pointermove 期间直接改节点的 inline left/top(只动 DOM,不动 store);
|
||||||
* setPositionDraft 模块级 Map,刷新不丢。
|
* 松手时把 rounded 后的位置一次性写入 store 的 setPositionDraft 模块级 Map(草稿
|
||||||
|
* 持久化)+ setSchedule(让 React 知道最终位置),刷新不丢。
|
||||||
*/
|
*/
|
||||||
export function useCanvasNodeDrag({
|
export function useCanvasNodeDrag({
|
||||||
busy,
|
busy,
|
||||||
@@ -48,7 +52,10 @@ export function useCanvasNodeDrag({
|
|||||||
startClientY: event.clientY,
|
startClientY: event.clientY,
|
||||||
originX: node.position_x,
|
originX: node.position_x,
|
||||||
originY: node.position_y,
|
originY: node.position_y,
|
||||||
|
currentX: node.position_x,
|
||||||
|
currentY: node.position_y,
|
||||||
moved: false,
|
moved: false,
|
||||||
|
nodeEl: event.currentTarget,
|
||||||
};
|
};
|
||||||
setSelectedNodeId(node.node_id);
|
setSelectedNodeId(node.node_id);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
@@ -60,21 +67,13 @@ export function useCanvasNodeDrag({
|
|||||||
const deltaX = event.clientX - drag.startClientX;
|
const deltaX = event.clientX - drag.startClientX;
|
||||||
const deltaY = event.clientY - drag.startClientY;
|
const deltaY = event.clientY - drag.startClientY;
|
||||||
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
|
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
|
||||||
setSchedule((current) => {
|
const x = Math.max(10, drag.originX + deltaX);
|
||||||
if (!current) return current;
|
const y = Math.max(10, drag.originY + deltaY);
|
||||||
return {
|
drag.currentX = x;
|
||||||
...current,
|
drag.currentY = y;
|
||||||
nodes: current.nodes.map((item) =>
|
// 只写 DOM,不动 store;拖拽期间 SchedulePage 不会因此重渲染。
|
||||||
item.node_id === drag.nodeId
|
drag.nodeEl.style.left = `${x}px`;
|
||||||
? {
|
drag.nodeEl.style.top = `${y}px`;
|
||||||
...item,
|
|
||||||
position_x: Math.max(10, drag.originX + deltaX),
|
|
||||||
position_y: Math.max(10, drag.originY + deltaY),
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||||
@@ -82,20 +81,19 @@ export function useCanvasNodeDrag({
|
|||||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
if (!drag.moved) return;
|
if (!drag.moved) return;
|
||||||
const current = useSchedulesStore.getState().schedule;
|
|
||||||
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
|
|
||||||
if (!current || !node) return;
|
|
||||||
const position = {
|
const position = {
|
||||||
position_x: Math.round(node.position_x),
|
position_x: Math.round(drag.currentX),
|
||||||
position_y: Math.round(node.position_y),
|
position_y: Math.round(drag.currentY),
|
||||||
};
|
};
|
||||||
useSchedulesStore.getState().setPositionDraft(node.node_id, position);
|
// 先记草稿(持久化),再把最终位置同步进 store 让节点 div 的 inline style 与
|
||||||
|
// schedule.nodes 一致(下次重渲染/保存时不会出现位置回弹)。
|
||||||
|
useSchedulesStore.getState().setPositionDraft(drag.nodeId, position);
|
||||||
setSchedule((value) => {
|
setSchedule((value) => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
nodes: value.nodes.map((item) =>
|
nodes: value.nodes.map((item) =>
|
||||||
item.node_id === node.node_id ? { ...item, ...position } : item,
|
item.node_id === drag.nodeId ? { ...item, ...position } : item,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -294,6 +294,10 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition:
|
||||||
|
transform .15s ease,
|
||||||
|
box-shadow .15s ease,
|
||||||
|
border-color .15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-card:hover {
|
.artifact-card:hover {
|
||||||
@@ -431,7 +435,6 @@
|
|||||||
fill: none;
|
fill: none;
|
||||||
stroke: #4e92db;
|
stroke: #4e92db;
|
||||||
stroke-width: 2.2;
|
stroke-width: 2.2;
|
||||||
transition: stroke .15s, stroke-width .15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-edge-hit {
|
.schedule-edge-hit {
|
||||||
@@ -1083,7 +1086,6 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: rgb(10 27 45 / 46%);
|
background: rgb(10 27 45 / 46%);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-result-modal {
|
.schedule-run-result-modal {
|
||||||
@@ -1101,7 +1103,6 @@
|
|||||||
box-shadow: 0 24px 80px rgb(8 27 48 / 30%);
|
box-shadow: 0 24px 80px rgb(8 27 48 / 30%);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
animation: modal-in .18s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-result-modal__header {
|
.schedule-run-result-modal__header {
|
||||||
@@ -1501,7 +1502,6 @@
|
|||||||
border: 2px solid #b9d4ef;
|
border: 2px solid #b9d4ef;
|
||||||
border-top-color: #1978d4;
|
border-top-color: #1978d4;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin .7s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1500px) {
|
@media (max-width: 1500px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user