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
@@ -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);
+144 -90
View File
@@ -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,
), ),
}; };
}); });
+4 -4
View File
@@ -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) {