feat(workflow): add node duplication

This commit is contained in:
Xin Wang
2026-08-03 07:32:43 +08:00
parent 9daa46ed4d
commit c241e815cc
3 changed files with 83 additions and 16 deletions

View File

@@ -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) {
<button
type="button"
title="编辑节点"
aria-label="编辑节点"
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
@@ -139,17 +140,32 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
<Pencil size={13} />
</button>
{type !== "start" && (
<button
type="button"
title="删除节点"
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
actions.remove(id);
}}
>
<Trash2 size={13} />
</button>
<>
<button
type="button"
title="复制节点"
aria-label="复制节点"
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
actions.duplicate(id);
}}
>
<Copy size={13} />
</button>
<button
type="button"
title="删除节点"
aria-label="删除节点"
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
actions.remove(id);
}}
>
<Trash2 size={13} />
</button>
</>
)}
</div>

View File

@@ -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,

View File

@@ -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<ElementActions>(noop);
const noop: ElementActions = { edit: () => {}, remove: () => {} };
const noopNodeActions: NodeActions = { ...noop, duplicate: () => {} };
export const NodeActionContext = createContext<NodeActions>(noopNodeActions);
export const EdgeActionContext = createContext<ElementActions>(noop);
/** 调试通话中当前激活的节点 id(画布据此高亮),无激活为 null。 */