improve node clone

This commit is contained in:
Yoan.liu 2025-05-23 12:12:01 +08:00
parent c713d4705e
commit dd0d477484
11 changed files with 161 additions and 25 deletions

View File

@ -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 (
<div className={buttonClassName}>
<PlusOutlinedIcon className="text-white" />
</div>
);
};
return (
<div className="relative py-6 before:absolute before:left-1/2 before:top-0 before:h-full before:w-[2px] before:-translate-x-1/2 before:bg-stone-200 before:content-['']">
<Dropdown menu={{ items: dropdownMenus }} trigger={["click"]}>
<div className="relative z-[1] flex size-5 cursor-pointer items-center justify-center rounded-full bg-stone-400 hover:bg-stone-500">
<PlusOutlinedIcon className="text-white" />
</div>
</Dropdown>
{cloning ? (
<>{renderButton()}</>
) : (
<Dropdown menu={{ items: dropdownMenus }} trigger={["click"]} disabled={disabled}>
{renderButton()}
</Dropdown>
)}
</div>
);
};
export default memo(AddNode);

View File

@ -69,7 +69,7 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => {
return (
<>
<SharedNode.Block node={node} disabled={disabled} onClick={() => setDrawerOpen(true)}>
<SharedNode.Block node={node} disabled={disabled} onClick={() => {setDrawerOpen(true)}}>
{wrappedEl}
</SharedNode.Block>

View File

@ -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);
}}
>

View File

@ -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 (
<>
<Card className="relative z-[1] w-[256px] shadow-md" styles={{ body: { padding: 0 } }} hoverable>
<div className="flex h-[64px] flex-col items-center justify-center truncate px-4 py-2"></div>
<div className="flex h-[64px] flex-col items-center justify-center truncate px-4 py-2">{t("workflow_node.clone.description")}</div>
</Card>
<AddNode node={node} disabled={disabled} />
</>
@ -15,4 +17,3 @@ const CloneNode = ({ node, disabled }: SharedNodeProps) => {
};
export default CloneNode;

View File

@ -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"]}
>
<Card className="relative z-[1] mt-10 w-[256px] shadow-md" styles={{ body: { padding: 0 } }} hoverable>
<div className="flex h-[48px] flex-col items-center justify-center truncate px-4 py-2">

View File

@ -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={<SharedNodeMenu node={node} disabled={disabled} trigger={<Button color="primary" icon={<MoreOutlinedIcon />} variant="text" />} />}
placement="rightTop"
trigger={cloning ? [] : ["hover"]}
>
<Card className="relative w-[256px] overflow-hidden shadow-md" styles={{ body: { padding: 0 } }} hoverable>
{cloning && canBeCloned && (
<div
className="absolute left-2 top-2 z-10 flex items-center justify-center rounded-full bg-white/90 p-1 text-primary shadow-sm"
onClick={(e) => {
e.stopPropagation();
cloneNode(node);
}}
>
<CopyOutlinedIcon className="text-sm text-gray-500 hover:text-primary" />
</div>
)}
<div className="bg-primary flex h-[48px] flex-col items-center justify-center truncate px-4 py-2 text-white">
<SharedNodeTitle
className="focus:bg-background focus:text-foreground overflow-hidden outline-none focus:rounded-sm"
@ -236,6 +253,9 @@ const SharedNodeConfigDrawer = ({
}: SharedNodeEditDrawerProps) => {
const { t } = useTranslation();
const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow"]));
const cloning = hasCloneNode(workflow.draft!);
const [modalApi, ModelContextHolder] = Modal.useModal();
const [open, setOpen] = useControllableValue<boolean>(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={<div className="max-w-[480px] truncate">{node.name}</div>}
width={720}
onClose={handleClose}
@ -326,3 +359,4 @@ export default {
Block: memo(SharedNodeBlock),
ConfigDrawer: memo(SharedNodeConfigDrawer),
};

View File

@ -42,6 +42,13 @@ export enum WorkflowNodeType {
Clone = "clone",
}
const workflowNodeTypesCanBeCloned: Set<WorkflowNodeType> = new Set([
WorkflowNodeType.Apply,
WorkflowNodeType.Upload,
WorkflowNodeType.Deploy,
WorkflowNodeType.Notify,
]);
const workflowNodeTypeDefaultNames: Map<WorkflowNodeType, string> = 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;
};

View File

@ -833,6 +833,10 @@
"workflow_node.notify.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol></details><br>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",

View File

@ -832,6 +832,10 @@
"workflow_node.notify.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol></details><br>其他注意事项请前往授权管理页面查看。",
"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": "并行分支",

View File

@ -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 = () => {
<Alert
className="shadow-lg animate-fadeIn"
showIcon
message="选择要复制的节点,复制到目标位置"
message={t("workflow_node.clone.alert")}
type="info"
action={
<Button
size="small"
type="text"
type="default"
icon={<CloseOutlinedIcon />}
onClick={() => {
cancelClone();
}}
>
{t("common.button.cancel")}
</Button>
}
/>
@ -420,4 +422,3 @@ const WorkflowBaseInfoModal = ({ trigger }: { trigger?: React.ReactNode }) => {
};
export default WorkflowDetail;

View File

@ -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<WorkflowState>((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<WorkflowState>((set, get) => ({
return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, type);
},
}));