feat(workflow): add node duplication
This commit is contained in:
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用节点渲染器。所有节点类型共用一个组件,规格来自 NodeSpecsContext
|
* 通用节点渲染器。所有节点类型共用一个组件,规格来自 NodeSpecsContext
|
||||||
* (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 删除」按钮。
|
* (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 复制 / 删除」按钮。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
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 { useContext } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -130,6 +130,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="编辑节点"
|
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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -139,9 +140,23 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
<Pencil size={13} />
|
<Pencil size={13} />
|
||||||
</button>
|
</button>
|
||||||
{type !== "start" && (
|
{type !== "start" && (
|
||||||
|
<>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="删除节点"
|
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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -150,6 +165,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
>
|
>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,50 @@ export function WorkflowCanvas({
|
|||||||
[editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
|
[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(
|
const handleNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
const startIds = new Set(
|
const startIds = new Set(
|
||||||
@@ -431,10 +475,12 @@ export function WorkflowCanvas({
|
|||||||
onEditingEdgeIdChange(null);
|
onEditingEdgeIdChange(null);
|
||||||
onEditingNodeIdChange(id);
|
onEditingNodeIdChange(id);
|
||||||
},
|
},
|
||||||
|
duplicate: duplicateNode,
|
||||||
remove: deleteNode,
|
remove: deleteNode,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
deleteNode,
|
deleteNode,
|
||||||
|
duplicateNode,
|
||||||
onDebugOpenChange,
|
onDebugOpenChange,
|
||||||
onEditingEdgeIdChange,
|
onEditingEdgeIdChange,
|
||||||
onEditingNodeIdChange,
|
onEditingNodeIdChange,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布内共享上下文。GenericNode / ConditionEdge 由 ReactFlow 渲染,拿不到外层
|
* 画布内共享上下文。GenericNode / ConditionEdge 由 ReactFlow 渲染,拿不到外层
|
||||||
* 回调,统一通过这些 context 取节点规格与编辑/删除动作。
|
* 回调,统一通过这些 context 取节点规格与编辑/复制/删除动作。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
@@ -17,9 +17,14 @@ export type ElementActions = {
|
|||||||
remove: (id: string) => void;
|
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);
|
export const EdgeActionContext = createContext<ElementActions>(noop);
|
||||||
|
|
||||||
/** 调试通话中当前激活的节点 id(画布据此高亮),无激活为 null。 */
|
/** 调试通话中当前激活的节点 id(画布据此高亮),无激活为 null。 */
|
||||||
|
|||||||
Reference in New Issue
Block a user