From cc8ac220a57d5d13166a9c9bc11b9824c28a83fa Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:27:45 +0800 Subject: [PATCH] =?UTF-8?q?perf(schedule):=20=E5=87=8F=E5=B0=91=E9=87=8D?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E4=B8=8E=E5=86=97=E4=BD=99=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../app/features/schedules/RunHistory.tsx | 7 +- .../app/features/schedules/SchedulePage.tsx | 234 +++++++++++------- .../schedules/hooks/useCanvasNodeDrag.ts | 48 ++-- frontend/app/styles/schedule.css | 8 +- 4 files changed, 173 insertions(+), 124 deletions(-) diff --git a/frontend/app/features/schedules/RunHistory.tsx b/frontend/app/features/schedules/RunHistory.tsx index ce9d8c5..e784213 100644 --- a/frontend/app/features/schedules/RunHistory.tsx +++ b/frontend/app/features/schedules/RunHistory.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { type ScheduleNode, - type ScheduleRunSummary, } from "../../services/api"; import { useApi } from "../../context/AuthContext"; @@ -18,19 +17,17 @@ import { } from "./utils"; export function RunHistory({ - runs, nodes, - loading, disabled, onRefresh, }: { - runs: ScheduleRunSummary[]; nodes: ScheduleNode[]; - loading: boolean; disabled: boolean; onRefresh: () => void; }) { const api = useApi(); + const runs = useSchedulesStore((s) => s.runs); + const loading = useSchedulesStore((s) => s.runsLoading); const expandedRunId = useSchedulesStore((s) => s.expandedRunId); const runDetails = useSchedulesStore((s) => s.runDetails); const detailLoadingId = useSchedulesStore((s) => s.detailLoadingId); diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index ec823a4..1392b76 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -1,6 +1,7 @@ import { type DragEvent, type FormEvent, + memo, type MouseEvent as ReactMouseEvent, useEffect, useMemo, @@ -51,6 +52,113 @@ const NODE_WIDTH = 218; const NODE_HEIGHT = 104; 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 ( + 0 + ? "请先保存节点位置" + : "立即执行当前已保存的稳定版本 DAG" + } + onClick={() => useSchedulesStore.getState().runNow()} + > + 立即运行 + + ); +} + +/** + * 把右键菜单打开逻辑提到模块级,使 ScheduleEdge 这类 memo 化的子组件 + * 也能直接调用,避免通过 props 传入每渲染都新建的箭头函数。 + */ +function openScheduleContextMenu( + event: ReactMouseEvent, + 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 ( + { + event.stopPropagation(); + useSchedulesStore.getState().setSelectedEdgeId(edge.edge_id); + useSchedulesStore.getState().setSelectedNodeId(null); + }} + onContextMenu={(event) => openScheduleContextMenu(event, { + kind: "edge", + edge, + })} + > + + + + ); +}); + export default function SchedulePage({ onNotify, onConnectionChange, @@ -89,19 +197,15 @@ export default function SchedulePage({ const newScheduleName = useSchedulesStore((s) => s.newScheduleName); const setNewScheduleName = useSchedulesStore((s) => s.setNewScheduleName); const contextMenu = useSchedulesStore((s) => s.contextMenu); - const setContextMenu = useSchedulesStore((s) => s.setContextMenu); const scheduleForm = useSchedulesStore((s) => s.scheduleForm); const setScheduleForm = useSchedulesStore((s) => s.setScheduleForm); const nodeForm = useSchedulesStore((s) => s.nodeForm); const setNodeForm = useSchedulesStore((s) => s.setNodeForm); const cronResult = useSchedulesStore((s) => s.cronResult); const setCronResult = useSchedulesStore((s) => s.setCronResult); - const runs = useSchedulesStore((s) => s.runs); const setRuns = useSchedulesStore((s) => s.setRuns); - const runsLoading = useSchedulesStore((s) => s.runsLoading); const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading); const canvasRef = useRef(null); - const positionDraftCount = useSchedulesStore((state) => state.positionDraftCount); const selectedNode = schedule?.nodes.find( (item) => item.node_id === selectedNodeId, @@ -120,21 +224,6 @@ export default function SchedulePage({ useContextMenuDismiss(); - const openContextMenu = ( - event: ReactMouseEvent, - 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(() => { if (!workspaceId || !api) return; setSchedules([]); @@ -161,18 +250,29 @@ export default function SchedulePage({ useEffect(() => { const scheduleId = schedule?.schedule_id; if (!scheduleId || !api) return; - 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; - const timer = window.setInterval(() => { + let timer: number | undefined; + let cancelled = false; + const tick = (): void => { + if (cancelled) return; void useSchedulesStore.getState().refreshRuns(scheduleId); - }, hasActiveRun ? 1500 : 3000); - return () => window.clearInterval(timer); - }, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, runs, api]); + // 每次 tick 重新读最新 runs,不再把 runs 放进依赖数组,避免每次 setRuns + // 都触发 effect cleanup + 重建 setInterval。 + 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. const handleCreateScheduleSubmit = (event: FormEvent): void => { @@ -220,12 +320,9 @@ export default function SchedulePage({ 图形化调度 {schedule - ? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${ - positionDraftCount > 0 - ? ` · ${positionDraftCount} 个节点位置待保存` - : "" - }` + ? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}` : "创建调度后开始编排"} + @@ -247,24 +344,7 @@ export default function SchedulePage({ > 校验 DAG - 0 - ? "请先保存节点位置" - : "立即执行当前已保存的稳定版本 DAG" - } - onClick={() => useSchedulesStore.getState().runNow()} - > - 立即运行 - + useSchedulesStore.getState().chooseSchedule(id)} - onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })} + onContextMenu={(event, item) => openScheduleContextMenu(event as ReactMouseEvent, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })} onKeywordChange={setScheduleKeyword} /> void useSchedulesStore.getState().addArtifactAt(artifact, x, y)} onDoubleClick={(artifact) => void useSchedulesStore.getState().addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)} - onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent, { kind: "artifact", artifact })} + onContextMenu={(event, artifact) => openScheduleContextMenu(event as ReactMouseEvent, { kind: "artifact", artifact })} onKeywordChange={setArtifactKeyword} /> @@ -344,38 +424,14 @@ export default function SchedulePage({ - {schedule.edges.map((edge) => { - const source = schedule.nodes.find( - (node) => node.node_id === edge.source_node_id, - ); - const target = schedule.nodes.find( - (node) => node.node_id === edge.target_node_id, - ); - if (!source || !target) return null; - const path = edgePath(source, target); - return ( - { - event.stopPropagation(); - setSelectedEdgeId(edge.edge_id); - setSelectedNodeId(null); - }} - onContextMenu={(event) => openContextMenu(event, { - kind: "edge", - edge, - })} - > - - - - ); - })} + {schedule.edges.map((edge) => ( + + ))} )} @@ -395,7 +451,7 @@ export default function SchedulePage({ onPointerMove={moveDrag} onPointerUp={finishDrag} onPointerCancel={finishDrag} - onContextMenu={(event) => openContextMenu(event, { + onContextMenu={(event) => openScheduleContextMenu(event, { kind: "node", node, })} @@ -515,9 +571,7 @@ export default function SchedulePage({ )} { if (schedule) void useSchedulesStore.getState().refreshRuns(schedule.schedule_id, true); diff --git a/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts b/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts index dc35032..5dc6a6d 100644 --- a/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts +++ b/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts @@ -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): 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, ), }; }); diff --git a/frontend/app/styles/schedule.css b/frontend/app/styles/schedule.css index 844d69a..7bf2661 100644 --- a/frontend/app/styles/schedule.css +++ b/frontend/app/styles/schedule.css @@ -294,6 +294,10 @@ background: #fff; cursor: grab; user-select: none; + transition: + transform .15s ease, + box-shadow .15s ease, + border-color .15s ease; } .artifact-card:hover { @@ -431,7 +435,6 @@ fill: none; stroke: #4e92db; stroke-width: 2.2; - transition: stroke .15s, stroke-width .15s; } .schedule-edge-hit { @@ -1083,7 +1086,6 @@ border: 0; background: rgb(10 27 45 / 46%); cursor: default; - backdrop-filter: blur(2px); } .schedule-run-result-modal { @@ -1101,7 +1103,6 @@ box-shadow: 0 24px 80px rgb(8 27 48 / 30%); grid-template-rows: auto minmax(0, 1fr); transform: translate(-50%, -50%); - animation: modal-in .18s ease-out; } .schedule-run-result-modal__header { @@ -1501,7 +1502,6 @@ border: 2px solid #b9d4ef; border-top-color: #1978d4; border-radius: 50%; - animation: spin .7s linear infinite; } @media (max-width: 1500px) {