mirror of
https://github.com/usual2970/certimate.git
synced 2025-06-07 21:19:51 +00:00
improve node clone
This commit is contained in:
parent
c713d4705e
commit
dd0d477484
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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),
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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": "并行分支",
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user