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:
tao.chen
2026-08-20 11:27:45 +08:00
parent 4acfbb162f
commit cc8ac220a5
4 changed files with 173 additions and 124 deletions
@@ -9,13 +9,17 @@ type DragState = {
startClientY: number;
originX: number;
originY: number;
currentX: number;
currentY: number;
moved: boolean;
nodeEl: HTMLDivElement;
};
/**
* 画布节点拖拽编排。内部持有瞬时 dragRef,避免每次拖拽都触发 store 重渲染。
* 拖拽过程中直接改 schedule.nodes 的 position;松手时把 rounded 后的位置写到 store
* setPositionDraft 模块级 Map,刷新不丢。
* 画布节点拖拽编排。内部持有瞬时 dragRef + 节点 DOM 引用,避免每次拖拽都触发 store
* 重渲染:pointermove 期间直接改节点的 inline left/top(只动 DOM,不动 store);
* 松手时把 rounded 后的位置一次性写入 store 的 setPositionDraft 模块级 Map(草稿
* 持久化)+ setSchedule(让 React 知道最终位置),刷新不丢。
*/
export function useCanvasNodeDrag({
busy,
@@ -48,7 +52,10 @@ export function useCanvasNodeDrag({
startClientY: event.clientY,
originX: node.position_x,
originY: node.position_y,
currentX: node.position_x,
currentY: node.position_y,
moved: false,
nodeEl: event.currentTarget,
};
setSelectedNodeId(node.node_id);
setSelectedEdgeId(null);
@@ -60,21 +67,13 @@ export function useCanvasNodeDrag({
const deltaX = event.clientX - drag.startClientX;
const deltaY = event.clientY - drag.startClientY;
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
setSchedule((current) => {
if (!current) return current;
return {
...current,
nodes: current.nodes.map((item) =>
item.node_id === drag.nodeId
? {
...item,
position_x: Math.max(10, drag.originX + deltaX),
position_y: Math.max(10, drag.originY + deltaY),
}
: item,
),
};
});
const x = Math.max(10, drag.originX + deltaX);
const y = Math.max(10, drag.originY + deltaY);
drag.currentX = x;
drag.currentY = y;
// 只写 DOM,不动 store;拖拽期间 SchedulePage 不会因此重渲染。
drag.nodeEl.style.left = `${x}px`;
drag.nodeEl.style.top = `${y}px`;
};
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
@@ -82,20 +81,19 @@ export function useCanvasNodeDrag({
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
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 = {
position_x: Math.round(node.position_x),
position_y: Math.round(node.position_y),
position_x: Math.round(drag.currentX),
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) => {
if (!value) return value;
return {
...value,
nodes: value.nodes.map((item) =>
item.node_id === node.node_id ? { ...item, ...position } : item,
item.node_id === drag.nodeId ? { ...item, ...position } : item,
),
};
});