diff --git a/ui/src/components/workflow/node/_SharedNode.tsx b/ui/src/components/workflow/node/_SharedNode.tsx
index 510a0154..e744b5e6 100644
--- a/ui/src/components/workflow/node/_SharedNode.tsx
+++ b/ui/src/components/workflow/node/_SharedNode.tsx
@@ -1,17 +1,18 @@
-import { memo, useRef } from "react";
+import { memo, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
CloseCircleOutlined as CloseCircleOutlinedIcon,
EllipsisOutlined as EllipsisOutlinedIcon,
FormOutlined as FormOutlinedIcon,
MoreOutlined as MoreOutlinedIcon,
+ CopyOutlined as CopyOutlinedIcon,
} from "@ant-design/icons";
import { useControllableValue } from "ahooks";
import { Button, Card, Drawer, Dropdown, Input, type InputRef, Modal, Popover, Space } from "antd";
import { produce } from "immer";
import { isEqual } from "radash";
-import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
+import { hasCloneNode, ifCanBeCloned, type WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
@@ -177,6 +178,10 @@ type SharedNodeBlockProps = SharedNodeProps & {
};
const SharedNodeBlock = ({ children, node, disabled, onClick }: SharedNodeBlockProps) => {
+ const { workflow, cloneNode } = useWorkflowStore(useZustandShallowSelector(["workflow", "cloneNode"]));
+ const cloning = hasCloneNode(workflow.draft!);
+ const canBeCloned = ifCanBeCloned(node);
+
const handleNodeClick = (e: React.MouseEvent) => {
onClick?.(e);
};
@@ -189,8 +194,20 @@ const SharedNodeBlock = ({ children, node, disabled, onClick }: SharedNodeBlockP
arrow={false}
content={
} variant="text" />} />}
placement="rightTop"
+ trigger={cloning ? [] : ["hover"]}
>
+ {cloning && canBeCloned && (
+ {
+ e.stopPropagation();
+ cloneNode(node);
+ }}
+ >
+
+
+ )}
{
const { t } = useTranslation();
+ const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow"]));
+ const cloning = hasCloneNode(workflow.draft!);
+
const [modalApi, ModelContextHolder] = Modal.useModal();
const [open, setOpen] = useControllableValue(props, {
@@ -244,15 +264,28 @@ const SharedNodeConfigDrawer = ({
trigger: "onOpenChange",
});
+ useEffect(() => {
+ if (open && cloning) {
+ safeSetOpen(false);
+ }
+ }, [open, cloning]);
+
+ const safeSetOpen = (value: boolean) => {
+ if (value && cloning) {
+ return;
+ }
+ setOpen(value);
+ };
+
const handleConfirmClick = async () => {
await onConfirm();
- setOpen(false);
+ safeSetOpen(false);
};
const handleCancelClick = () => {
if (pending) return;
- setOpen(false);
+ safeSetOpen(false);
};
const handleClose = () => {
@@ -275,7 +308,7 @@ const SharedNodeConfigDrawer = ({
resolve(void 0);
}
- promise.then(() => setOpen(false));
+ promise.then(() => safeSetOpen(false));
};
return (
@@ -308,7 +341,7 @@ const SharedNodeConfigDrawer = ({
}
loading={loading}
maskClosable={!pending}
- open={open}
+ open={open && !cloning}
title={{node.name}
}
width={720}
onClose={handleClose}
@@ -326,3 +359,4 @@ export default {
Block: memo(SharedNodeBlock),
ConfigDrawer: memo(SharedNodeConfigDrawer),
};
+
diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts
index a7306041..395584b2 100644
--- a/ui/src/domain/workflow.ts
+++ b/ui/src/domain/workflow.ts
@@ -42,6 +42,13 @@ export enum WorkflowNodeType {
Clone = "clone",
}
+const workflowNodeTypesCanBeCloned: Set = new Set([
+ WorkflowNodeType.Apply,
+ WorkflowNodeType.Upload,
+ WorkflowNodeType.Deploy,
+ WorkflowNodeType.Notify,
+]);
+
const workflowNodeTypeDefaultNames: Map = new Map([
[WorkflowNodeType.Start, i18n.t("workflow_node.start.label")],
[WorkflowNodeType.End, i18n.t("workflow_node.end.label")],
@@ -549,3 +556,42 @@ export const removeCloneNode = (node: WorkflowNode): WorkflowNode => {
});
};
+export const cloneNode = (node: WorkflowNode, srcNode: WorkflowNode): WorkflowNode => {
+ // 1.先深度克隆一下 srcNode
+ // 2.打到 clone 节点
+ // 3.替换为深度克隆过的 srcNode
+
+ return produce(node, (draft) => {
+ let current = draft as typeof draft | undefined;
+
+ while (current) {
+ if (current.next?.type === WorkflowNodeType.Clone) {
+ const clonedSrcNode = produce(srcNode, (draft) => {
+ draft.id = nanoid();
+ return draft;
+ });
+ clonedSrcNode.next = current.next?.next;
+ current.next = clonedSrcNode;
+ break;
+ }
+
+ if (isBranchLike(current)) {
+ current.branches ??= [];
+ current.branches = current.branches.map((branch) => cloneNode(branch, srcNode));
+ }
+
+ current = current.next as WorkflowNode;
+ }
+
+ return draft;
+ });
+};
+
+export const ifCanBeCloned = (node: WorkflowNode): boolean => {
+ if (workflowNodeTypesCanBeCloned.has(node.type)) {
+ return true;
+ }
+
+ return false;
+};
+
diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json
index 80237287..e9acd1b3 100644
--- a/ui/src/i18n/locales/en/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json
@@ -833,6 +833,10 @@
"workflow_node.notify.form.webhook_data.guide": "Supported variables:
- ${SUBJECT}: The subject of notification.
- ${MESSAGE}: The message of notification.
Please visit the authorization management page for addtional notes.",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string",
+ "workflow_node.clone.label": "Clone node",
+ "workflow_node.clone.description": "Select a node to clone here",
+ "workflow_node.clone.alert": "Select a node to copy to the target location",
+
"workflow_node.end.label": "End",
"workflow_node.branch.label": "Parallel branch",
diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
index faf40816..3dffe943 100644
--- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
@@ -832,6 +832,10 @@
"workflow_node.notify.form.webhook_data.guide": "支持的变量:
- ${SUBJECT}:通知主题。
- ${MESSAGE}:通知内容。
其他注意事项请前往授权管理页面查看。",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
+ "workflow_node.clone.label": "复制节点",
+ "workflow_node.clone.description":"选择节点复制到此处",
+ "workflow_node.clone.alert": "选择要复制的节点,复制到目标位置",
+
"workflow_node.end.label": "结束",
"workflow_node.branch.label": "并行分支",
diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx
index 12c40249..0e475ddb 100644
--- a/ui/src/pages/workflows/WorkflowDetail.tsx
+++ b/ui/src/pages/workflows/WorkflowDetail.tsx
@@ -9,6 +9,7 @@ import {
EllipsisOutlined as EllipsisOutlinedIcon,
HistoryOutlined as HistoryOutlinedIcon,
UndoOutlined as UndoOutlinedIcon,
+ CloseOutlined as CloseOutlinedIcon,
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { Alert, Button, Card, Dropdown, Form, Input, Modal, Space, Tabs, Typography, message, notification } from "antd";
@@ -84,7 +85,7 @@ const WorkflowDetail = () => {
const hasReleased = !!workflow.content;
const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content);
setAllowDiscard(!isPendingOrRunning && hasReleased && hasChanges);
- setAllowRelease(!isPendingOrRunning && hasChanges);
+ setAllowRelease(!isPendingOrRunning && hasChanges && !cloning);
setAllowRun(hasReleased);
}, [workflow.content, workflow.draft, workflow.hasDraft, isPendingOrRunning]);
@@ -316,17 +317,18 @@ const WorkflowDetail = () => {
}
onClick={() => {
cancelClone();
}}
>
- 取消
+ {t("common.button.cancel")}
}
/>
@@ -420,4 +422,3 @@ const WorkflowBaseInfoModal = ({ trigger }: { trigger?: React.ReactNode }) => {
};
export default WorkflowDetail;
-
diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts
index a8d0a783..f1895da0 100644
--- a/ui/src/stores/workflow/index.ts
+++ b/ui/src/stores/workflow/index.ts
@@ -7,6 +7,7 @@ import {
type WorkflowNodeConfigForStart,
addBranch,
addNode,
+ cloneNode,
getOutputBeforeNodeId,
removeBranch,
removeCloneNode,
@@ -30,6 +31,7 @@ export type WorkflowState = {
updateNode: (node: WorkflowNode) => void;
removeNode: (nodeId: string) => void;
cancelClone: () => void;
+ cloneNode: (node: WorkflowNode) => void;
addBranch: (branchId: string) => void;
removeBranch: (branchId: string, index: number) => void;
@@ -226,6 +228,26 @@ export const useWorkflowStore = create((set, get) => ({
});
},
+ cloneNode: async (node: WorkflowNode) => {
+ if (!get().initialized) throw "Workflow not initialized yet";
+
+ const root = cloneNode(get().workflow.draft!, node);
+ const resp = await saveWorkflow({
+ id: get().workflow.id!,
+ draft: root,
+ hasDraft: true,
+ });
+
+ set((state: WorkflowState) => {
+ return {
+ workflow: produce(state.workflow, (draft) => {
+ draft.draft = resp.draft;
+ draft.hasDraft = resp.hasDraft;
+ }),
+ };
+ });
+ },
+
addBranch: async (branchId: string) => {
if (!get().initialized) throw "Workflow not initialized yet";
@@ -270,4 +292,3 @@ export const useWorkflowStore = create((set, get) => ({
return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, type);
},
}));
-