diff --git a/frontend/src/components/workflow/GenericNode.tsx b/frontend/src/components/workflow/GenericNode.tsx index 7bca604..2ee64eb 100644 --- a/frontend/src/components/workflow/GenericNode.tsx +++ b/frontend/src/components/workflow/GenericNode.tsx @@ -2,11 +2,11 @@ /** * 通用节点渲染器。所有节点类型共用一个组件,规格来自 NodeSpecsContext - * (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 删除」按钮。 + * (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 复制 / 删除」按钮。 */ import { Handle, Position, type NodeProps } from "@xyflow/react"; -import { MessageSquareText, Pencil, Trash2 } from "lucide-react"; +import { Copy, MessageSquareText, Pencil, Trash2 } from "lucide-react"; import { useContext } from "react"; import { cn } from "@/lib/utils"; @@ -130,6 +130,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) { {type !== "start" && ( - + <> + + + )} diff --git a/frontend/src/components/workflow/WorkflowCanvas.tsx b/frontend/src/components/workflow/WorkflowCanvas.tsx index 4f3c030..66b6950 100644 --- a/frontend/src/components/workflow/WorkflowCanvas.tsx +++ b/frontend/src/components/workflow/WorkflowCanvas.tsx @@ -328,6 +328,50 @@ export function WorkflowCanvas({ [editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges], ); + const duplicateNode = useCallback( + (id: string) => { + const source = nodes.find((node) => node.id === id); + if (!source || source.type === "start") return; + + const spec = specsByType[source.type as string]; + if (!spec) return; + const limit = spec.constraints.maxInstances; + if ( + limit !== undefined && + nodes.filter((node) => node.type === source.type).length >= limit + ) { + return; + } + + nodeSeq += 1; + const duplicateId = `${source.type}-${Date.now()}-${nodeSeq}`; + const sourceData = source.data as WorkflowNodeData; + const duplicateData = structuredClone(sourceData); + duplicateData.name = `${sourceData.name || spec.displayName}(副本)`; + + setNodes((currentNodes) => [ + ...currentNodes.map((node) => ({ ...node, selected: false })), + { + id: duplicateId, + type: source.type, + position: { + x: source.position.x + 40, + y: source.position.y + 40, + }, + data: duplicateData, + selected: true, + }, + ]); + onEditingNodeIdChange(null); + }, + [ + nodes, + onEditingNodeIdChange, + setNodes, + specsByType, + ], + ); + const handleNodesChange = useCallback( (changes: NodeChange[]) => { const startIds = new Set( @@ -431,10 +475,12 @@ export function WorkflowCanvas({ onEditingEdgeIdChange(null); onEditingNodeIdChange(id); }, + duplicate: duplicateNode, remove: deleteNode, }), [ deleteNode, + duplicateNode, onDebugOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, diff --git a/frontend/src/components/workflow/context.ts b/frontend/src/components/workflow/context.ts index 6263d44..466fc9b 100644 --- a/frontend/src/components/workflow/context.ts +++ b/frontend/src/components/workflow/context.ts @@ -2,7 +2,7 @@ /** * 画布内共享上下文。GenericNode / ConditionEdge 由 ReactFlow 渲染,拿不到外层 - * 回调,统一通过这些 context 取节点规格与编辑/删除动作。 + * 回调,统一通过这些 context 取节点规格与编辑/复制/删除动作。 */ import { createContext } from "react"; @@ -17,9 +17,14 @@ export type ElementActions = { remove: (id: string) => void; }; -const noop: ElementActions = { edit: () => {}, remove: () => {} }; +export type NodeActions = ElementActions & { + duplicate: (id: string) => void; +}; -export const NodeActionContext = createContext(noop); +const noop: ElementActions = { edit: () => {}, remove: () => {} }; +const noopNodeActions: NodeActions = { ...noop, duplicate: () => {} }; + +export const NodeActionContext = createContext(noopNodeActions); export const EdgeActionContext = createContext(noop); /** 调试通话中当前激活的节点 id(画布据此高亮),无激活为 null。 */