import { type DragEvent, type FormEvent, memo, type MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { type Schedule, type ScheduleArtifact, type ScheduleEdge, type ScheduleNode, } from "../../services/api"; import { useApi, useAuth } from "../../context/AuthContext"; import Icon from "../../components/common/Icon"; import { NodeInspector } from "./NodeInspector"; import { RunHistory } from "./RunHistory"; import { ScheduleInspector } from "./ScheduleInspector"; import { ScheduleList } from "./ScheduleList"; import { ArtifactList } from "./ArtifactList"; import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader"; import { useCanvasNodeDrag } from "./hooks/useCanvasNodeDrag"; import { useContextMenuDismiss } from "./hooks/useContextMenuDismiss"; import { EMPTY_NODE_FORM, type ScheduleContextMenu, } from "./state/schedulesStore"; import { useSchedulesStore } from "./state/schedulesStore"; import { edgePath, nodeToForm, scheduleToForm } from "./utils"; import "../../styles/schedule.css"; type Notice = { tone: "success" | "error" | "info"; message: string; }; type ScheduleContextMenuTarget = | { kind: "schedule-list" } | { kind: "schedule"; schedule: Schedule } | { kind: "artifact"; artifact: ScheduleArtifact } | { kind: "node"; node: ScheduleNode } | { kind: "edge"; edge: ScheduleEdge }; const CANVAS_WIDTH = 1400; const CANVAS_HEIGHT = 860; 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, }: { onNotify: (notice: Notice) => void; onConnectionChange: (online: boolean) => void; }) { const { currentWorkspace } = useAuth(); const workspaceId = currentWorkspace?.workspace_id; const api = useApi(); // 核心数据 state 迁 store(其余 form/dialog/selected/runs 仍用 useState) const schedules = useSchedulesStore((s) => s.schedules); const artifacts = useSchedulesStore((s) => s.artifacts); const schedule = useSchedulesStore((s) => s.schedule); const loading = useSchedulesStore((s) => s.loading); const busy = useSchedulesStore((s) => s.busy); const setSchedules = useSchedulesStore((s) => s.setSchedules); const setArtifacts = useSchedulesStore((s) => s.setArtifacts); const setSchedule = useSchedulesStore((s) => s.setSchedule); const setLoading = useSchedulesStore((s) => s.setLoading); const setBusy = useSchedulesStore((s) => s.setBusy); const selectedNodeId = useSchedulesStore((s) => s.selectedNodeId); const setSelectedNodeId = useSchedulesStore((s) => s.setSelectedNodeId); const selectedEdgeId = useSchedulesStore((s) => s.selectedEdgeId); const setSelectedEdgeId = useSchedulesStore((s) => s.setSelectedEdgeId); const linkSourceId = useSchedulesStore((s) => s.linkSourceId); const setLinkSourceId = useSchedulesStore((s) => s.setLinkSourceId); const scheduleKeyword = useSchedulesStore((s) => s.scheduleKeyword); const setScheduleKeyword = useSchedulesStore((s) => s.setScheduleKeyword); const artifactKeyword = useSchedulesStore((s) => s.artifactKeyword); const setArtifactKeyword = useSchedulesStore((s) => s.setArtifactKeyword); const createDialogOpen = useSchedulesStore((s) => s.createDialogOpen); const setCreateDialogOpen = useSchedulesStore((s) => s.setCreateDialogOpen); const newScheduleName = useSchedulesStore((s) => s.newScheduleName); const setNewScheduleName = useSchedulesStore((s) => s.setNewScheduleName); const contextMenu = useSchedulesStore((s) => s.contextMenu); 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 setRuns = useSchedulesStore((s) => s.setRuns); const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading); const canvasRef = useRef(null); const selectedNode = schedule?.nodes.find( (item) => item.node_id === selectedNodeId, ) ?? null; const selectedEdge = schedule?.edges.find( (item) => item.edge_id === selectedEdgeId, ) ?? null; useEffect(() => { if (schedule) setScheduleForm(scheduleToForm(schedule)); }, [schedule?.schedule_id, schedule?.workflow_version]); useEffect(() => { setNodeForm(selectedNode ? nodeToForm(selectedNode) : EMPTY_NODE_FORM); }, [selectedNode?.node_id, selectedNode?.updated_at]); useContextMenuDismiss(); useEffect(() => { if (!workspaceId || !api) return; setSchedules([]); setArtifacts([]); setSchedule(null); setRuns([]); setRunsLoading(false); void useSchedulesStore.getState().loadInitial(); }, [workspaceId, api, setSchedules, setArtifacts, setSchedule]); useEffect(() => { const scheduleId = schedule?.schedule_id; if (!scheduleId || !api) { setRuns([]); setRunsLoading(false); return; } let timer: number | undefined; let cancelled = false; const tick = async (showLoading = false): Promise => { if (cancelled) return; // 必须等待请求完成再判断状态;旧实现会在首个请求返回前看到空数组, // 因而错误地停止轮询,导致运行记录必须手工刷新才出现。 await useSchedulesStore.getState().refreshRuns(scheduleId, showLoading); if (cancelled || useSchedulesStore.getState().schedule?.schedule_id !== scheduleId) { return; } const runs = useSchedulesStore.getState().runs; const hasActiveRun = runs.some( (item) => item.run_status === "queued" || item.run_status === "running", ); // 页面停留期间持续刷新:运行中更快,空闲时较慢,既能自动显示 Cron // 新记录,也不会因频繁请求影响其他调度页面操作。 timer = window.setTimeout(() => { void tick(); }, hasActiveRun ? 1500 : 5000); }; void tick(true); return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; }, [schedule?.schedule_id, api, setRuns, setRunsLoading]); // Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX. const handleCreateScheduleSubmit = (event: FormEvent): void => { event.preventDefault(); void useSchedulesStore.getState().addSchedule(newScheduleName); }; const onCanvasDrop = (event: DragEvent): void => { event.preventDefault(); const versionsId = event.dataTransfer.getData(ARTIFACT_MIME) || event.dataTransfer.getData("text/plain"); const artifact = artifacts.find((item) => item.versions_id === versionsId); if (!artifact || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const positionX = event.clientX - rect.left + canvasRef.current.scrollLeft - NODE_WIDTH / 2; const positionY = event.clientY - rect.top + canvasRef.current.scrollTop - NODE_HEIGHT / 2; void useSchedulesStore.getState().addArtifactAt(artifact, positionX, positionY); }; const { startDrag, moveDrag, finishDrag } = useCanvasNodeDrag({ busy, linkSourceId }); const filteredSchedules = useMemo(() => { const keyword = scheduleKeyword.trim().toLowerCase(); return keyword ? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword)) : schedules; }, [schedules, scheduleKeyword]); const filteredArtifacts = useMemo(() => { const keyword = artifactKeyword.trim().toLowerCase(); return keyword ? artifacts.filter((item) => ( item.script_name.toLowerCase().includes(keyword) || item.version_label.toLowerCase().includes(keyword) )) : artifacts; }, [artifacts, artifactKeyword]); // 稳定传给 ArtifactList 的 3 个回调,让 memo 真正生效 —— 否则父级 // (SchedulePage) 任何 store 字段变化都会让卡片列表重新绑定拖拽事件, // 在拖动/快速 hover 时放大体感卡顿。 const handleArtifactDrop = useCallback( (artifact: ScheduleArtifact, x: number, y: number) => { useSchedulesStore.getState().addArtifactAt(artifact, x, y); }, [], ); const handleArtifactDoubleClick = useCallback( (artifact: ScheduleArtifact) => { useSchedulesStore.getState().addArtifactAt( artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130, ); }, [schedule], ); const handleArtifactContextMenu = useCallback( (event: ReactMouseEvent, artifact: ScheduleArtifact) => { openScheduleContextMenu( event as ReactMouseEvent, { kind: "artifact", artifact }, ); }, [], ); return ( 图形化调度 {schedule ? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}` : "创建调度后开始编排"} useSchedulesStore.getState().openCreateDialog()}> 新建 useSchedulesStore.getState().saveSchedule()} > 保存配置 useSchedulesStore.getState().checkDag()} > 校验 DAG useSchedulesStore.getState().removeSchedule(schedule ?? undefined)} > 删除 setLinkSourceId(null)} /> { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }} onDrop={onCanvasDrop} onClick={(event) => { // 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget; // 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。 const target = event.target as Element; if ( target.closest(".schedule-node") || target.matches(".schedule-edge-line, .schedule-edge-hit") ) return; setSelectedNodeId(null); setSelectedEdgeId(null); }} > {schedule && ( {schedule.edges.map((edge) => ( ))} )} {schedule?.nodes.map((node) => ( startDrag(event, node)} onPointerMove={moveDrag} onPointerUp={finishDrag} onPointerCancel={finishDrag} onContextMenu={(event) => openScheduleContextMenu(event, { kind: "node", node, })} > event.stopPropagation()} onClick={(event) => { event.stopPropagation(); void useSchedulesStore.getState().connectTo(node.node_id); }} /> {node.node_name} event.stopPropagation()} onClick={(event) => { event.stopPropagation(); setSelectedNodeId(node.node_id); setSelectedEdgeId(null); }} > {node.version.script_name} event.stopPropagation()} onClick={(event) => { event.stopPropagation(); setLinkSourceId( useSchedulesStore.getState().linkSourceId === node.node_id ? null : node.node_id, ); setSelectedNodeId(node.node_id); setSelectedEdgeId(null); }} /> ))} {!schedule ? ( 还没有选中调度方案 点击"新建"创建一个调度,然后拖入稳定版本脚本。 useSchedulesStore.getState().openCreateDialog()}> 新建调度 ) : schedule.nodes.length === 0 ? ( 从稳定版本开始编排 把左侧脚本卡片拖到这里,或双击卡片快速加入。 ) : null} {contextMenu && ( event.stopPropagation()} > {contextMenu.kind === "schedule-list" && ( useSchedulesStore.getState().openCreateDialog()} > 新建调度方案 )} {contextMenu.kind === "schedule" && ( <> useSchedulesStore.getState().openCreateDialog()} > 新建调度方案 useSchedulesStore.getState().renameSchedule(contextMenu.schedule)} > 改名 useSchedulesStore.getState().removeSchedule(contextMenu.schedule)} > 删除调度方案 > )} {contextMenu.kind === "artifact" && ( useSchedulesStore.getState().removeArtifact(contextMenu.artifact)} > 移出调度列表 )} {contextMenu.kind === "node" && ( useSchedulesStore.getState().removeNode(contextMenu.node)} > 删除画布节点 )} {contextMenu.kind === "edge" && ( useSchedulesStore.getState().removeEdge(contextMenu.edge)} > 删除画布连线 )} )} {createDialogOpen && ( SCHEDULE 新建调度方案 setCreateDialogOpen(false)} > 调度方案名称 setNewScheduleName(event.target.value)} onFocus={(event) => event.currentTarget.select()} /> setCreateDialogOpen(false)} > 取消 {busy === "create-schedule" ? : } {busy === "create-schedule" ? "正在创建…" : "创建调度"} )} {busy && ( {busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"} )} ); }
{node.version.script_name}
点击"新建"创建一个调度,然后拖入稳定版本脚本。
把左侧脚本卡片拖到这里,或双击卡片快速加入。