import { Button } from "antd"; import { IconButton } from "@/components/ui/base/buttons"; import { EmptyState } from "@/components/ui/product/empty-state"; import { Maximize, Power, PowerOff, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { WorkflowFieldMappingEditor } from "@/components/workflow-field-mapping-editor"; import type { WorkflowFieldMapping, WorkflowGraphPreview } from "@/stores/use-config-store"; import { Select } from "@/components/ui/base/select"; type WorkflowNode = { id: string; title: string; classType: string; category: string; inputs: Record; }; type GraphNode = WorkflowNode & { x: number; y: number }; type GraphEdge = { from: string; to: string }; type WorkflowGraph = { nodes: GraphNode[]; edges: GraphEdge[]; width: number; height: number }; type WorkflowGraphEditorProps = { workflowJson?: Record; workflowGraph?: WorkflowGraphPreview; fields: WorkflowFieldMapping[]; onChange: (fields: WorkflowFieldMapping[]) => void; disabled?: boolean; emptyDescription?: string; }; const nodeWidth = 156; const nodeHeight = 58; const columnGap = 58; const rowGap = 20; const graphPadding = 24; const allEnabledFieldsNodeId = "__all_enabled_fields__"; export function WorkflowGraphEditor({ workflowJson, workflowGraph, fields, onChange, disabled = false, emptyDescription = "尚未读取到工作流拓扑" }: WorkflowGraphEditorProps) { const graph = useMemo(() => buildWorkflowGraph(workflowJson, workflowGraph), [workflowGraph, workflowJson]); const [selectedNodeId, setSelectedNodeId] = useState(""); const [scale, setScale] = useState(1); const viewportRef = useRef(null); const panRef = useRef<{ pointerId: number; x: number; y: number; left: number; top: number } | null>(null); const nodesWithFields = useMemo(() => new Set(fields.map((field) => String(field.nodeId))), [fields]); const enabledFields = useMemo(() => fields.filter((field) => field.enabled !== false), [fields]); const controllableFields = useMemo(() => fields.filter((field) => field.safeToOverride !== false), [fields]); const enabledControllableCount = useMemo(() => controllableFields.filter((field) => field.enabled !== false).length, [controllableFields]); const selectedFields = selectedNodeId === allEnabledFieldsNodeId ? enabledFields : selectedNodeId ? fields.filter((field) => String(field.nodeId) === selectedNodeId) : fields; const selectedNode = graph.nodes.find((node) => node.id === selectedNodeId); useEffect(() => { const firstMapped = graph.nodes.find((node) => nodesWithFields.has(node.id)); setSelectedNodeId((current) => (current === allEnabledFieldsNodeId || graph.nodes.some((node) => node.id === current) ? current : firstMapped?.id || graph.nodes[0]?.id || "")); }, [graph.nodes, nodesWithFields]); const updateSelectedFields = (nextSelected: WorkflowFieldMapping[]) => { const replacements = new Map(nextSelected.map((field) => [fieldKey(field), field])); onChange(fields.map((field) => replacements.get(fieldKey(field)) || field)); }; const updateAllFields = (enabled: boolean) => { onChange(fields.map((field) => (field.safeToOverride === false ? field : { ...field, enabled }))); }; const fitGraph = useCallback(() => { const viewport = viewportRef.current; if (!viewport || !graph.width || !graph.height) return; const next = Math.max(0.3, Math.min(1.6, Math.min((viewport.clientWidth - 32) / graph.width, (viewport.clientHeight - 32) / graph.height))); setScale(next); requestAnimationFrame(() => { viewport.scrollLeft = Math.max(0, (graph.width * next - viewport.clientWidth) / 2); viewport.scrollTop = Math.max(0, (graph.height * next - viewport.clientHeight) / 2); }); }, [graph.height, graph.width]); useEffect(() => { if (!graph.nodes.length) return; // 工作流切换后自动适配一次;用户后续缩放不应被普通重渲染重置。 const frame = requestAnimationFrame(fitGraph); return () => cancelAnimationFrame(frame); }, [fitGraph, graph.nodes.length]); const zoom = (direction: number) => setScale((current) => Math.max(0.3, Math.min(2.4, current * (direction > 0 ? 1.2 : 1 / 1.2)))); const handleViewportWheel = useCallback( (event: WheelEvent) => { if (!graph.nodes.length) return; event.preventDefault(); const viewport = viewportRef.current; if (!viewport) return; const rect = viewport.getBoundingClientRect(); const pointerX = event.clientX - rect.left; const pointerY = event.clientY - rect.top; const contentX = viewport.scrollLeft + pointerX; const contentY = viewport.scrollTop + pointerY; const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12; setScale((current) => { const next = Math.max(0.3, Math.min(2.4, current * factor)); if (next === current) return current; const ratio = next / current; requestAnimationFrame(() => { if (viewportRef.current !== viewport) return; viewport.scrollLeft = contentX * ratio - pointerX; viewport.scrollTop = contentY * ratio - pointerY; }); return next; }); }, [graph.nodes.length], ); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; // React 的 wheel 事件在部分浏览器中可能被注册为 passive,使用原生监听确保能阻止外层页面滚动。 viewport.addEventListener("wheel", handleViewportWheel, { passive: false }); return () => viewport.removeEventListener("wheel", handleViewportWheel); }, [handleViewportWheel]); const startPan = (event: ReactPointerEvent) => { if (event.button !== 0 || (event.target as Element).closest("[data-workflow-node]")) return; const viewport = viewportRef.current; if (!viewport) return; panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop }; event.currentTarget.setPointerCapture(event.pointerId); }; const movePan = (event: ReactPointerEvent) => { const pan = panRef.current; const viewport = viewportRef.current; if (!pan || !viewport || pan.pointerId !== event.pointerId) return; viewport.scrollLeft = pan.left - (event.clientX - pan.x); viewport.scrollTop = pan.top - (event.clientY - pan.y); }; return (