diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index 961b060b..4f625eb1 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -11,7 +11,7 @@ import { } from "@ant-design/icons"; import { Dropdown } from "antd"; -import { WorkflowNodeType, newNode } from "@/domain/workflow"; +import { WorkflowNodeType, hasCloneNode, newNode } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; @@ -22,7 +22,9 @@ export type AddNodeProps = SharedNodeProps; const AddNode = ({ node, disabled }: AddNodeProps) => { const { t } = useTranslation(); - const { addNode } = useWorkflowStore(useZustandShallowSelector(["addNode"])); + const { addNode, workflow } = useWorkflowStore(useZustandShallowSelector(["addNode", "workflow"])); + + const cloning = hasCloneNode(workflow.draft!); const dropdownMenus = useMemo(() => { return [ @@ -55,16 +57,29 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { }); }, [node.id, disabled, node.type]); + const renderButton = () => { + const buttonClassName = + "relative z-[1] flex size-5 items-center justify-center rounded-full " + + (cloning ? "bg-stone-300 cursor-not-allowed" : "bg-stone-400 cursor-pointer hover:bg-stone-500"); + + return ( +
+ +
+ ); + }; + return (
- -
- -
-
+ {cloning ? ( + <>{renderButton()} + ) : ( + + {renderButton()} + + )}
); }; export default memo(AddNode); - diff --git a/ui/src/components/workflow/node/ApplyNode.tsx b/ui/src/components/workflow/node/ApplyNode.tsx index c250fd89..320c92c5 100644 --- a/ui/src/components/workflow/node/ApplyNode.tsx +++ b/ui/src/components/workflow/node/ApplyNode.tsx @@ -69,7 +69,7 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => { return ( <> - setDrawerOpen(true)}> + {setDrawerOpen(true)}}> {wrappedEl} diff --git a/ui/src/components/workflow/node/BranchNode.tsx b/ui/src/components/workflow/node/BranchNode.tsx index f8a755d0..bb48ed87 100644 --- a/ui/src/components/workflow/node/BranchNode.tsx +++ b/ui/src/components/workflow/node/BranchNode.tsx @@ -2,7 +2,7 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; import { Button, theme } from "antd"; -import { type WorkflowNode } from "@/domain/workflow"; +import { hasCloneNode, type WorkflowNode } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; @@ -15,7 +15,8 @@ export type BrandNodeProps = SharedNodeProps; const BranchNode = ({ node, disabled }: BrandNodeProps) => { const { t } = useTranslation(); - const { addBranch } = useWorkflowStore(useZustandShallowSelector(["addBranch"])); + const { addBranch, workflow } = useWorkflowStore(useZustandShallowSelector(["addBranch", "workflow"])); + const cloning = hasCloneNode(workflow.draft!); const { token: themeToken } = theme.useToken(); @@ -46,6 +47,9 @@ const BranchNode = ({ node, disabled }: BrandNodeProps) => { shape="round" variant="outlined" onClick={() => { + if (cloning) { + return; + } addBranch(node.id); }} > diff --git a/ui/src/components/workflow/node/CloneNode.tsx b/ui/src/components/workflow/node/CloneNode.tsx index 2e63be62..ebdd7740 100644 --- a/ui/src/components/workflow/node/CloneNode.tsx +++ b/ui/src/components/workflow/node/CloneNode.tsx @@ -1,13 +1,15 @@ import { Card } from "antd"; import { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; +import { useTranslation } from "react-i18next"; export type UploadNodeProps = SharedNodeProps; const CloneNode = ({ node, disabled }: SharedNodeProps) => { + const { t } = useTranslation(); return ( <> -
选择节点复制到此处
+
{t("workflow_node.clone.description")}
@@ -15,4 +17,3 @@ const CloneNode = ({ node, disabled }: SharedNodeProps) => { }; export default CloneNode; - diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 56639692..5cadff79 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -4,6 +4,9 @@ import { Button, Card, Popover } from "antd"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; +import { hasCloneNode } from "@/domain/workflow"; export type ConditionNodeProps = SharedNodeProps & { branchId: string; @@ -12,6 +15,8 @@ export type ConditionNodeProps = SharedNodeProps & { const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeProps) => { // TODO: 条件分支 + const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow"])); + const cloning = hasCloneNode(workflow.draft!); return ( <> @@ -29,6 +34,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP /> } placement="rightTop" + trigger={cloning ? [] : ["hover"]} >
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:
  1. ${SUBJECT}: The subject of notification.
  2. ${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": "
支持的变量:
  1. ${SUBJECT}:通知主题。
  2. ${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); }, })); -