import { type DragEvent, type FormEvent, type MouseEvent as ReactMouseEvent, 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"; 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 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, ) ?? 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(); 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([]); setArtifacts([]); setSchedule(null); setRuns([]); setRunsLoading(true); void useSchedulesStore.getState().loadInitial(); }, [workspaceId, api, setSchedules, setArtifacts, setSchedule]); useEffect(() => { const scheduleId = schedule?.schedule_id; if (!scheduleId || !api) return; if (!runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return; const timer = window.setInterval(() => { void useSchedulesStore.getState().refreshRuns(scheduleId); }, 1500); return () => window.clearInterval(timer); }, [schedule?.schedule_id, runs, api]); // 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]); return (
图形化调度 {schedule ? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${ positionDraftCount > 0 ? ` · ${positionDraftCount} 个节点位置待保存` : "" }` : "创建调度后开始编排"}
setLinkSourceId(null)} />
{ event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }} onDrop={onCanvasDrop} onClick={(event) => { if (event.target === event.currentTarget) { setSelectedNodeId(null); setSelectedEdgeId(null); } }} >
{schedule && ( {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?.nodes.map((node) => (
startDrag(event, node)} onPointerMove={moveDrag} onPointerUp={finishDrag} onPointerCancel={finishDrag} onContextMenu={(event) => openContextMenu(event, { kind: "node", node, })} >

{node.version.script_name}

{node.node_key} {node.version.version_label}
))} {!schedule ? (
还没有选中调度方案

点击"新建"创建一个调度,然后拖入稳定版本脚本。

) : schedule.nodes.length === 0 ? (
从稳定版本开始编排

把左侧脚本卡片拖到这里,或双击卡片快速加入。

) : null}
{schedule?.dag_validation.valid ? "✓ DAG 有效" : "○ DAG 待校验"} {schedule?.nodes.length ?? 0} 个节点 {schedule?.edges.length ?? 0} 条连线 {selectedEdge && ( )}
{contextMenu && (
event.stopPropagation()} > {contextMenu.kind === "schedule-list" && ( )} {contextMenu.kind === "schedule" && ( <> )} {contextMenu.kind === "artifact" && ( )} {contextMenu.kind === "node" && ( )} {contextMenu.kind === "edge" && ( )}
)} {createDialogOpen && (
SCHEDULE

新建调度方案

)} {busy && (
{busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
)}
); }