From 613b6839b85747162959958afc03673a2a2f1597 Mon Sep 17 00:00:00 2001 From: yoan <536464346@qq.com> Date: Tue, 5 Nov 2024 21:00:53 +0800 Subject: [PATCH] workflow --- ui/package-lock.json | 51 ++- ui/package.json | 4 +- ui/src/components/workflow/AddNode.tsx | 95 +++++ ui/src/components/workflow/ApplyForm.tsx | 353 ++++++++++++++++++ ui/src/components/workflow/BranchNode.tsx | 67 ++++ ui/src/components/workflow/ConditionNode.tsx | 47 +++ .../components/workflow/DeployPanelBody.tsx | 39 ++ .../workflow/DropdownMenuItemIcon.tsx | 23 ++ ui/src/components/workflow/End.tsx | 10 + ui/src/components/workflow/Node.tsx | 76 ++++ ui/src/components/workflow/NodeRender.tsx | 29 ++ ui/src/components/workflow/NodeTypesPanel.tsx | 67 ++++ ui/src/components/workflow/Panel.tsx | 23 ++ ui/src/components/workflow/PanelBody.tsx | 30 ++ ui/src/components/workflow/PanelProvider.tsx | 42 +++ ui/src/components/workflow/StartForm.tsx | 144 +++++++ .../components/workflow/WorkflowProvider.tsx | 13 + ui/src/components/workflow/types.ts | 11 + ui/src/domain/access.ts | 38 +- ui/src/domain/workflow.ts | 317 ++++++++++++++++ ui/src/pages/workflow/index.tsx | 52 +++ ui/src/providers/workflow/index.ts | 84 +++++ ui/src/router.tsx | 3 +- 23 files changed, 1597 insertions(+), 21 deletions(-) create mode 100644 ui/src/components/workflow/AddNode.tsx create mode 100644 ui/src/components/workflow/ApplyForm.tsx create mode 100644 ui/src/components/workflow/BranchNode.tsx create mode 100644 ui/src/components/workflow/ConditionNode.tsx create mode 100644 ui/src/components/workflow/DeployPanelBody.tsx create mode 100644 ui/src/components/workflow/DropdownMenuItemIcon.tsx create mode 100644 ui/src/components/workflow/End.tsx create mode 100644 ui/src/components/workflow/Node.tsx create mode 100644 ui/src/components/workflow/NodeRender.tsx create mode 100644 ui/src/components/workflow/NodeTypesPanel.tsx create mode 100644 ui/src/components/workflow/Panel.tsx create mode 100644 ui/src/components/workflow/PanelBody.tsx create mode 100644 ui/src/components/workflow/PanelProvider.tsx create mode 100644 ui/src/components/workflow/StartForm.tsx create mode 100644 ui/src/components/workflow/WorkflowProvider.tsx create mode 100644 ui/src/components/workflow/types.ts create mode 100644 ui/src/domain/workflow.ts create mode 100644 ui/src/pages/workflow/index.tsx create mode 100644 ui/src/providers/workflow/index.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index 97fbdc1e..ef7758b6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -30,6 +30,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "cron-parser": "^4.9.0", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.1", @@ -47,7 +48,8 @@ "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.1" }, "devDependencies": { "@types/fs-extra": "^11.0.4", @@ -3760,6 +3762,17 @@ "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -4967,6 +4980,14 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", @@ -6628,6 +6649,34 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.1.tgz", + "integrity": "sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/ui/package.json b/ui/package.json index 1c1768ac..78bdcda4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,6 +32,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "cron-parser": "^4.9.0", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.1", @@ -49,7 +50,8 @@ "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.1" }, "devDependencies": { "@types/fs-extra": "^11.0.4", diff --git a/ui/src/components/workflow/AddNode.tsx b/ui/src/components/workflow/AddNode.tsx new file mode 100644 index 00000000..f4623f1e --- /dev/null +++ b/ui/src/components/workflow/AddNode.tsx @@ -0,0 +1,95 @@ +import { Plus } from "lucide-react"; + +import { BrandNodeProps, NodeProps } from "./types"; + +import { newWorkflowNode, workflowNodeDropdownList, WorkflowNodeType } from "@/domain/workflow"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { useShallow } from "zustand/shallow"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import DropdownMenuItemIcon from "./DropdownMenuItemIcon"; +import Show from "../Show"; + +const selectState = (state: WorkflowState) => ({ + addNode: state.addNode, +}); + +const AddNode = ({ data }: NodeProps | BrandNodeProps) => { + const { addNode } = useWorkflowStore(useShallow(selectState)); + + const handleTypeSelected = (type: WorkflowNodeType, provider?: string) => { + const node = newWorkflowNode(type, { + providerType: provider, + }); + + addNode(node, data.id); + }; + + return ( +
+ + +
+ +
+
+ + 选择节点类型 + + {workflowNodeDropdownList.map((item) => ( + + +
{item.name}
+
+ + + {item.children?.map((subItem) => { + return ( + { + handleTypeSelected(item.type, subItem.providerType); + }} + > +
{subItem.name}
+
+ ); + })} +
+
+ + } + > + { + handleTypeSelected(item.type, item.providerType); + }} + > +
{item.name}
+
+
+ ))} +
+
+
+ ); +}; + +export default AddNode; diff --git a/ui/src/components/workflow/ApplyForm.tsx b/ui/src/components/workflow/ApplyForm.tsx new file mode 100644 index 00000000..9871b694 --- /dev/null +++ b/ui/src/components/workflow/ApplyForm.tsx @@ -0,0 +1,353 @@ +import { memo } from "react"; + +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronsUpDown, Plus, CircleHelp } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; +import AccessEditDialog from "@/components/certimate/AccessEditDialog"; +import EmailsEdit from "@/components/certimate/EmailsEdit"; +import StringList from "@/components/certimate/StringList"; + +import { accessProvidersMap } from "@/domain/access"; +import { EmailsSetting } from "@/domain/settings"; + +import { useConfigContext } from "@/providers/config"; +import { Switch } from "@/components/ui/switch"; +import { TooltipFast } from "@/components/ui/tooltip"; +import { WorkflowNode, WorkflowNodeConfig } from "@/domain/workflow"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { useShallow } from "zustand/shallow"; +import { usePanel } from "./PanelProvider"; + +type ApplyFormProps = { + data: WorkflowNode; +}; +const selectState = (state: WorkflowState) => ({ + updateNode: state.updateNode, +}); +const ApplyForm = ({ data }: ApplyFormProps) => { + const { updateNode } = useWorkflowStore(useShallow(selectState)); + + const { + config: { accesses, emails }, + } = useConfigContext(); + + const { t } = useTranslation(); + + const { hidePanel } = usePanel(); + + const formSchema = z.object({ + domain: z.string().min(1, { + message: "common.errmsg.domain_invalid", + }), + email: z.string().email("common.errmsg.email_invalid").optional(), + access: z.string().regex(/^[a-zA-Z0-9]+$/, { + message: "domain.application.form.access.placeholder", + }), + keyAlgorithm: z.string().optional(), + nameservers: z.string().optional(), + timeout: z.number().optional(), + disableFollowCNAME: z.boolean().optional(), + }); + + let config: WorkflowNodeConfig = { + domain: "", + email: "", + access: "", + keyAlgorithm: "RSA2048", + nameservers: "", + timeout: 60, + disableFollowCNAME: true, + }; + if (data) config = data.config ?? config; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + domain: config.domain as string, + email: config.email as string, + access: config.access as string, + keyAlgorithm: config.keyAlgorithm as string, + nameservers: config.nameservers as string, + timeout: config.timeout as number, + disableFollowCNAME: config.disableFollowCNAME as boolean, + }, + }); + + const onSubmit = async (config: z.infer) => { + updateNode({ ...data, config }); + hidePanel(); + }; + + return ( + <> +
+ + {/* 域名 */} + ( + + <> + { + form.setValue("domain", domain); + }} + /> + + + + )} + /> + + {/* 邮箱 */} + ( + + +
{t("domain.application.form.email.label") + " " + t("domain.application.form.email.tips")}
+ + + {t("common.add")} + + } + /> +
+ + + + + +
+ )} + /> + + {/* DNS 服务商授权 */} + ( + + +
{t("domain.application.form.access.label")}
+ + + {t("common.add")} + + } + op="add" + /> +
+ + + + + +
+ )} + /> + +
+
+ + +
+ {t("domain.application.form.advanced_settings.label")} + +
+
+ +
+ {/* 证书算法 */} + ( + + {t("domain.application.form.key_algorithm.label")} + + + )} + /> + + {/* DNS */} + ( + + { + form.setValue("nameservers", val); + }} + valueType="dns" + > + + + + )} + /> + + {/* DNS 超时时间 */} + ( + + {t("domain.application.form.timeout.label")} + + { + form.setValue("timeout", parseInt(e.target.value)); + }} + /> + + + + + )} + /> + + {/* 禁用 CNAME 跟随 */} + ( + + +
+ {t("domain.application.form.disable_follow_cname.label")} + + {t("domain.application.form.disable_follow_cname.tips")} + + {t("domain.application.form.disable_follow_cname.tips_link")} + +

+ } + > + +
+
+
+ +
+ { + form.setValue(field.name, value); + }} + /> +
+
+ +
+ )} + /> +
+
+
+
+ +
+ +
+ + + + ); +}; + +export default memo(ApplyForm); diff --git a/ui/src/components/workflow/BranchNode.tsx b/ui/src/components/workflow/BranchNode.tsx new file mode 100644 index 00000000..ca617bc2 --- /dev/null +++ b/ui/src/components/workflow/BranchNode.tsx @@ -0,0 +1,67 @@ +import { Button } from "@/components/ui/button"; +import AddNode from "./AddNode"; +import { WorkflowBranchNode, WorkflowNode } from "@/domain/workflow"; +import NodeRender from "./NodeRender"; +import { memo } from "react"; +import { BrandNodeProps } from "./types"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { useShallow } from "zustand/shallow"; + +const selectState = (state: WorkflowState) => ({ + addBranch: state.addBranch, +}); +const BranchNode = memo(({ data }: BrandNodeProps) => { + const { addBranch } = useWorkflowStore(useShallow(selectState)); + + const renderNodes = (node: WorkflowBranchNode | WorkflowNode | undefined, branchNodeId?: string, branchIndex?: number) => { + const elements: JSX.Element[] = []; + let current = node; + while (current) { + elements.push(); + current = current.next; + } + return elements; + }; + + return ( + <> +
+ + + {data.branches.map((branch, index) => ( +
+ {index == 0 && ( + <> +
+
+ + )} + {index == data.branches.length - 1 && ( + <> +
+
+ + )} + {/* 条件 1 */} +
{renderNodes(branch, data.id, index)}
+
+ ))} +
+ + + ); +}); + +export default BranchNode; diff --git a/ui/src/components/workflow/ConditionNode.tsx b/ui/src/components/workflow/ConditionNode.tsx new file mode 100644 index 00000000..f4823b33 --- /dev/null +++ b/ui/src/components/workflow/ConditionNode.tsx @@ -0,0 +1,47 @@ +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import AddNode from "./AddNode"; +import { NodeProps } from "./types"; +import { useShallow } from "zustand/shallow"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; +import { Ellipsis, Trash2 } from "lucide-react"; + +const selectState = (state: WorkflowState) => ({ + updateNode: state.updateNode, + removeBranch: state.removeBranch, +}); +const ConditionNode = ({ data, branchId, branchIndex }: NodeProps) => { + const { updateNode, removeBranch } = useWorkflowStore(useShallow(selectState)); + const handleNameBlur = (e: React.FocusEvent) => { + updateNode({ ...data, name: e.target.innerText }); + }; + return ( + <> +
+ + + + + + { + removeBranch(branchId ?? "", branchIndex ?? 0); + }} + > +
删除分支
+
+
+
+ +
+
+ {data.name} +
+
+
+ + + ); +}; + +export default ConditionNode; diff --git a/ui/src/components/workflow/DeployPanelBody.tsx b/ui/src/components/workflow/DeployPanelBody.tsx new file mode 100644 index 00000000..5b5b2ff3 --- /dev/null +++ b/ui/src/components/workflow/DeployPanelBody.tsx @@ -0,0 +1,39 @@ +import { accessProviders } from "@/domain/access"; +import { WorkflowNode } from "@/domain/workflow"; +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +type DeployPanelBodyProps = { + data: WorkflowNode; +}; +const DeployPanelBody = ({ data }: DeployPanelBodyProps) => { + const { t } = useTranslation(); + return ( + <> + {/* 默认展示服务商列表 */} +
选择服务商
+ {accessProviders + .filter((provider) => provider[3] === "apply" || provider[3] === "all") + .reduce((acc: string[][][], provider, index) => { + if (index % 2 === 0) { + acc.push([provider]); + } else { + acc[acc.length - 1].push(provider); + } + return acc; + }, []) + .map((providerRow, rowIndex) => ( +
+ {providerRow.map((provider, index) => ( +
+ {provider[1]} +
{t(provider[1])}
+
+ ))} +
+ ))} + + ); +}; + +export default memo(DeployPanelBody); diff --git a/ui/src/components/workflow/DropdownMenuItemIcon.tsx b/ui/src/components/workflow/DropdownMenuItemIcon.tsx new file mode 100644 index 00000000..95c937a0 --- /dev/null +++ b/ui/src/components/workflow/DropdownMenuItemIcon.tsx @@ -0,0 +1,23 @@ +import { WorkflowwNodeDropdwonItemIcon, WorkflowwNodeDropdwonItemIconType } from "@/domain/workflow"; +import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react"; + +const icons = new Map([ + ["NotebookPen", ], + ["CloudUpload", ], + ["GitFork", ], + ["Megaphone", ], +]); + +const DropdownMenuItemIcon = ({ type, name }: WorkflowwNodeDropdwonItemIcon) => { + const getIcon = () => { + if (type === WorkflowwNodeDropdwonItemIconType.Icon) { + return icons.get(name); + } else { + return ; + } + }; + + return getIcon(); +}; + +export default DropdownMenuItemIcon; diff --git a/ui/src/components/workflow/End.tsx b/ui/src/components/workflow/End.tsx new file mode 100644 index 00000000..a2db1d53 --- /dev/null +++ b/ui/src/components/workflow/End.tsx @@ -0,0 +1,10 @@ +const End = () => { + return ( +
+
+
流程结束
+
+ ); +}; + +export default End; diff --git a/ui/src/components/workflow/Node.tsx b/ui/src/components/workflow/Node.tsx new file mode 100644 index 00000000..d2000469 --- /dev/null +++ b/ui/src/components/workflow/Node.tsx @@ -0,0 +1,76 @@ +import { WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; +import AddNode from "./AddNode"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { useShallow } from "zustand/shallow"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; +import { Ellipsis, Trash2 } from "lucide-react"; +import { usePanel } from "./PanelProvider"; +import PanelBody from "./PanelBody"; + +type NodeProps = { + data: WorkflowNode; +}; + +const selectState = (state: WorkflowState) => ({ + updateNode: state.updateNode, + removeNode: state.removeNode, +}); +const Node = ({ data }: NodeProps) => { + const { updateNode, removeNode } = useWorkflowStore(useShallow(selectState)); + const handleNameBlur = (e: React.FocusEvent) => { + updateNode({ ...data, name: e.target.innerText }); + }; + + const { showPanel } = usePanel(); + + const handleNodeSettingClick = () => { + showPanel({ + name: data.name, + children: , + }); + }; + return ( + <> +
+ {data.type != WorkflowNodeType.Start && ( + <> + + + + + + { + removeNode(data.id); + }} + > +
删除节点
+
+
+
+ + )} + +
+
+ {data.name} +
+
+
+
+ 设置节点 +
+
+
+ + + ); +}; + +export default Node; diff --git a/ui/src/components/workflow/NodeRender.tsx b/ui/src/components/workflow/NodeRender.tsx new file mode 100644 index 00000000..6d12a078 --- /dev/null +++ b/ui/src/components/workflow/NodeRender.tsx @@ -0,0 +1,29 @@ +import { memo } from "react"; +import { WorkflowBranchNode, WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; +import Node from "./Node"; +import End from "./End"; +import BranchNode from "./BranchNode"; +import ConditionNode from "./ConditionNode"; +import { NodeProps } from "./types"; + +const NodeRender = memo(({ data, branchId, branchIndex }: NodeProps) => { + const render = () => { + switch (data.type) { + case WorkflowNodeType.Start: + case WorkflowNodeType.Apply: + case WorkflowNodeType.Deploy: + case WorkflowNodeType.Notify: + return ; + case WorkflowNodeType.End: + return ; + case WorkflowNodeType.Branch: + return ; + case WorkflowNodeType.Condition: + return ; + } + }; + + return <>{render()}; +}); + +export default NodeRender; diff --git a/ui/src/components/workflow/NodeTypesPanel.tsx b/ui/src/components/workflow/NodeTypesPanel.tsx new file mode 100644 index 00000000..dbd19351 --- /dev/null +++ b/ui/src/components/workflow/NodeTypesPanel.tsx @@ -0,0 +1,67 @@ +import { WorkflowNodeType } from "@/domain/workflow"; +import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react"; + +type NodeTypesPanelProps = { + onTypeSelected: (type: WorkflowNodeType) => void; +}; + +const NodeTypesPanel = ({ onTypeSelected }: NodeTypesPanelProps) => { + return ( + <> +
+
{ + onTypeSelected(WorkflowNodeType.Apply); + }} + > +
+ +
+ +
申请
+
+
{ + onTypeSelected(WorkflowNodeType.Deploy); + }} + > +
+ +
+ +
部署
+
+
+
+
{ + onTypeSelected(WorkflowNodeType.Branch); + }} + > +
+ +
+ +
分支
+
+
{ + onTypeSelected(WorkflowNodeType.Notify); + }} + > +
+ +
+ +
推送
+
+
+ + ); +}; + +export default NodeTypesPanel; diff --git a/ui/src/components/workflow/Panel.tsx b/ui/src/components/workflow/Panel.tsx new file mode 100644 index 00000000..bca0dbc2 --- /dev/null +++ b/ui/src/components/workflow/Panel.tsx @@ -0,0 +1,23 @@ +// components/AddNodePanel.tsx +import { Sheet, SheetContent, SheetTitle } from "../ui/sheet"; + +type AddNodePanelProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + name: string; +}; + +const Panel = ({ open, onOpenChange, children, name }: AddNodePanelProps) => { + return ( + + + {name} + +
{children}
+
+
+ ); +}; + +export default Panel; diff --git a/ui/src/components/workflow/PanelBody.tsx b/ui/src/components/workflow/PanelBody.tsx new file mode 100644 index 00000000..6945222f --- /dev/null +++ b/ui/src/components/workflow/PanelBody.tsx @@ -0,0 +1,30 @@ +import { WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; +import StartForm from "./StartForm"; +import DeployPanelBody from "./DeployPanelBody"; +import ApplyForm from "./ApplyForm"; + +type PanelBodyProps = { + data: WorkflowNode; +}; +const PanelBody = ({ data }: PanelBodyProps) => { + const getBody = () => { + switch (data.type) { + case WorkflowNodeType.Start: + return ; + case WorkflowNodeType.Apply: + return ; + case WorkflowNodeType.Notify: + return ; + case WorkflowNodeType.Branch: + return
分支节点
; + case WorkflowNodeType.Condition: + return
条件节点
; + default: + return <> ; + } + }; + + return <>{getBody()}; +}; + +export default PanelBody; diff --git a/ui/src/components/workflow/PanelProvider.tsx b/ui/src/components/workflow/PanelProvider.tsx new file mode 100644 index 00000000..763098d2 --- /dev/null +++ b/ui/src/components/workflow/PanelProvider.tsx @@ -0,0 +1,42 @@ +// contexts/DialogContext.tsx +import { createContext, useContext, useState } from "react"; +import Panel from "./Panel"; + +type PanelContentProps = { name: string; children: React.ReactNode }; + +type PanelContextType = { + open: boolean; + showPanel: ({ name, children }: PanelContentProps) => void; + hidePanel: () => void; +}; + +const PanelContext = createContext(undefined); + +export const PanelProvider = ({ children }: { children: React.ReactNode }) => { + const [open, setOpen] = useState(false); + const [panelContent, setPanelContent] = useState(null); + + const showPanel = (panelContent: PanelContentProps) => { + setOpen(true); + setPanelContent(panelContent); + }; + const hidePanel = () => { + setOpen(false); + setPanelContent(null); + }; + + return ( + + {children} + + + ); +}; + +export const usePanel = () => { + const context = useContext(PanelContext); + if (!context) { + throw new Error("useDialog must be used within DialogProvider"); + } + return context; +}; diff --git a/ui/src/components/workflow/StartForm.tsx b/ui/src/components/workflow/StartForm.tsx new file mode 100644 index 00000000..dae8ff41 --- /dev/null +++ b/ui/src/components/workflow/StartForm.tsx @@ -0,0 +1,144 @@ +import { WorkflowNode, WorkflowNodeConfig } from "@/domain/workflow"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "../ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form"; +import { Input } from "../ui/input"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { Label } from "../ui/label"; +import { useTranslation } from "react-i18next"; +import { parseExpression } from "cron-parser"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { useShallow } from "zustand/shallow"; +import { usePanel } from "./PanelProvider"; + +const formSchema = z + .object({ + executionMethod: z.string().min(1, "executionMethod is required"), + crontab: z.string(), + }) + .superRefine((data, ctx) => { + if (data.executionMethod != "auto") { + return; + } + try { + parseExpression(data.crontab); + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "crontab is invalid", + path: ["crontab"], + }); + } + }); + +type StartFormProps = { + data: WorkflowNode; +}; + +const selectState = (state: WorkflowState) => ({ + updateNode: state.updateNode, +}); +const StartForm = ({ data }: StartFormProps) => { + const { updateNode } = useWorkflowStore(useShallow(selectState)); + const { hidePanel } = usePanel(); + + const { t } = useTranslation(); + + const [method, setMethod] = React.useState("auto"); + + useEffect(() => { + if (data.config && data.config.executionMethod) { + setMethod(data.config.executionMethod as string); + } else { + setMethod("auto"); + } + }, [data]); + + let config: WorkflowNodeConfig = { + executionMethod: "auto", + crontab: "0 0 * * *", + }; + if (data) config = data.config ?? config; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + executionMethod: config.executionMethod as string, + crontab: config.crontab as string, + }, + }); + + const onSubmit = async (config: z.infer) => { + updateNode({ ...data, config: { ...config } }); + hidePanel(); + }; + + return ( + <> +
+ { + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} + className="space-y-8" + > + ( + + 执行方式 + + { + setMethod(val); + }} + className="flex space-x-3" + > +
+ + +
+
+ + +
+
+
+ + +
+ )} + /> + + ( + + )} + /> + +
+ +
+ + + + ); +}; + +export default StartForm; diff --git a/ui/src/components/workflow/WorkflowProvider.tsx b/ui/src/components/workflow/WorkflowProvider.tsx new file mode 100644 index 00000000..db4d29f0 --- /dev/null +++ b/ui/src/components/workflow/WorkflowProvider.tsx @@ -0,0 +1,13 @@ +import { ConfigProvider } from "@/providers/config"; +import React from "react"; +import { PanelProvider } from "./PanelProvider"; + +const WorkflowProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +export default WorkflowProvider; diff --git a/ui/src/components/workflow/types.ts b/ui/src/components/workflow/types.ts new file mode 100644 index 00000000..a20a7743 --- /dev/null +++ b/ui/src/components/workflow/types.ts @@ -0,0 +1,11 @@ +import { WorkflowBranchNode, WorkflowNode } from "@/domain/workflow"; + +export type NodeProps = { + data: WorkflowNode | WorkflowBranchNode; + branchId?: string; + branchIndex?: number; +}; + +export type BrandNodeProps = { + data: WorkflowBranchNode; +}; diff --git a/ui/src/domain/access.ts b/ui/src/domain/access.ts index 8f2844fd..66459c8a 100644 --- a/ui/src/domain/access.ts +++ b/ui/src/domain/access.ts @@ -10,25 +10,27 @@ type AccessProvider = { searchContent: string; }; +export const accessProviders = [ + ["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"], + ["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"], + ["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"], + ["baiducloud", "common.provider.baiducloud", "/imgs/providers/baiducloud.svg", "all", "百度智能云:百度云:baidu cloud"], + ["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛云:qiniu"], + ["dogecloud", "common.provider.dogecloud", "/imgs/providers/dogecloud.svg", "deploy", "多吉云:doge cloud"], + ["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"], + ["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"], + ["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"], + ["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply", "godaddy"], + ["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply", "powerdns:pdns"], + ["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply", "httpreq"], + ["local", "common.provider.local", "/imgs/providers/local.svg", "deploy", "local:bendi:本地"], + ["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"], + ["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"], + ["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy", "k8s:kubernetes"], +]; + export const accessProvidersMap: Map = new Map( - [ - ["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"], - ["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"], - ["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"], - ["baiducloud", "common.provider.baiducloud", "/imgs/providers/baiducloud.svg", "all", "百度智能云:百度云:baidu cloud"], - ["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛云:qiniu"], - ["dogecloud", "common.provider.dogecloud", "/imgs/providers/dogecloud.svg", "deploy", "多吉云:doge cloud"], - ["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"], - ["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"], - ["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"], - ["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply", "godaddy"], - ["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply", "powerdns:pdns"], - ["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply", "httpreq"], - ["local", "common.provider.local", "/imgs/providers/local.svg", "deploy", "local:bendi:本地"], - ["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"], - ["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"], - ["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy", "k8s:kubernetes"], - ].map(([type, name, icon, usage, searchContent]) => [type, { type, name, icon, usage: usage as AccessUsages, searchContent: searchContent }]) + accessProviders.map(([type, name, icon, usage, searchContent]) => [type, { type, name, icon, usage: usage as AccessUsages, searchContent: searchContent }]) ); export const accessTypeFormSchema = z.union( diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts new file mode 100644 index 00000000..1e3cb2fa --- /dev/null +++ b/ui/src/domain/workflow.ts @@ -0,0 +1,317 @@ +import { produce } from "immer"; +import { nanoid } from "nanoid"; +import { accessProviders } from "./access"; +import i18n from "@/i18n"; + +export enum WorkflowNodeType { + Start = "start", + End = "end", + Branch = "branch", + Condition = "condition", + Apply = "apply", + Deploy = "deploy", + Notify = "notify", + Custom = "custom", +} + +export const workflowNodeTypeDefaultName: Map = new Map([ + [WorkflowNodeType.Start, "开始"], + [WorkflowNodeType.End, "结束"], + [WorkflowNodeType.Branch, "分支"], + [WorkflowNodeType.Condition, "分支"], + [WorkflowNodeType.Apply, "申请"], + [WorkflowNodeType.Deploy, "部署"], + [WorkflowNodeType.Notify, "通知"], + [WorkflowNodeType.Custom, "自定义"], +]); + +export type WorkflowNodeConfig = Record; + +export type WorkflowNode = { + id: string; + name: string; + type: WorkflowNodeType; + + parameters?: WorkflowNodeIo[]; + config?: WorkflowNodeConfig; + configured?: boolean; + output?: WorkflowNodeIo[]; + + next?: WorkflowNode | WorkflowBranchNode; +}; + +type NewWorkflowNodeOptions = { + branchIndex?: number; + providerType?: string; +}; + +export const newWorkflowNode = (type: WorkflowNodeType, options: NewWorkflowNodeOptions): WorkflowNode | WorkflowBranchNode => { + const id = nanoid(); + const typeName = workflowNodeTypeDefaultName.get(type) || ""; + const name = options.branchIndex !== undefined ? `${typeName} ${options.branchIndex + 1}` : typeName; + + let rs: WorkflowNode | WorkflowBranchNode = { + id, + name, + type, + }; + + if (type === WorkflowNodeType.Apply || type === WorkflowNodeType.Deploy) { + rs = { + ...rs, + config: { + providerType: options.providerType, + }, + }; + } + + if (type === WorkflowNodeType.Branch) { + rs = { + ...rs, + branches: [newWorkflowNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newWorkflowNode(WorkflowNodeType.Condition, { branchIndex: 1 })], + }; + } + + return rs; +}; + +export const isWorkflowBranchNode = (node: WorkflowNode | WorkflowBranchNode): node is WorkflowBranchNode => { + return node.type === WorkflowNodeType.Branch; +}; + +export const updateNode = (node: WorkflowNode | WorkflowBranchNode, targetNode: WorkflowNode | WorkflowBranchNode) => { + return produce(node, (draft) => { + let current = draft; + while (current) { + if (current.id === targetNode.id) { + Object.assign(current, targetNode); + break; + } + if (isWorkflowBranchNode(current)) { + current.branches = current.branches.map((branch) => updateNode(branch, targetNode)); + } + current = current.next as WorkflowNode; + } + return draft; + }); +}; + +export const addNode = (node: WorkflowNode | WorkflowBranchNode, preId: string, targetNode: WorkflowNode | WorkflowBranchNode) => { + return produce(node, (draft) => { + let current = draft; + while (current) { + if (current.id === preId && !isWorkflowBranchNode(targetNode)) { + targetNode.next = current.next; + current.next = targetNode; + break; + } else if (current.id === preId && isWorkflowBranchNode(targetNode)) { + targetNode.branches[0].next = current.next; + current.next = targetNode; + break; + } + if (isWorkflowBranchNode(current)) { + current.branches = current.branches.map((branch) => addNode(branch, preId, targetNode)); + } + current = current.next as WorkflowNode; + } + return draft; + }); +}; + +export const addBranch = (node: WorkflowNode | WorkflowBranchNode, branchNodeId: string) => { + return produce(node, (draft) => { + let current = draft; + while (current) { + if (current.id === branchNodeId) { + if (!isWorkflowBranchNode(current)) { + return draft; + } + current.branches.push( + newWorkflowNode(WorkflowNodeType.Condition, { + branchIndex: current.branches.length, + }) + ); + break; + } + if (isWorkflowBranchNode(current)) { + current.branches = current.branches.map((branch) => addBranch(branch, branchNodeId)); + } + current = current.next as WorkflowNode; + } + return draft; + }); +}; + +export const removeNode = (node: WorkflowNode | WorkflowBranchNode, targetNodeId: string) => { + return produce(node, (draft) => { + let current = draft; + while (current) { + if (current.next?.id === targetNodeId) { + current.next = current.next.next; + break; + } + if (isWorkflowBranchNode(current)) { + current.branches = current.branches.map((branch) => removeNode(branch, targetNodeId)); + } + current = current.next as WorkflowNode; + } + return draft; + }); +}; + +export const removeBranch = (node: WorkflowNode | WorkflowBranchNode, branchNodeId: string, branchIndex: number) => { + return produce(node, (draft) => { + let current = draft; + let last: WorkflowNode | WorkflowBranchNode | undefined = { + id: "", + name: "", + type: WorkflowNodeType.Start, + next: draft, + }; + while (current && last) { + if (current.id === branchNodeId) { + if (!isWorkflowBranchNode(current)) { + return draft; + } + current.branches.splice(branchIndex, 1); + + // 如果仅剩一个分支,删除分支节点,将分支节点的下一个节点挂载到当前节点 + if (current.branches.length === 1) { + const branch = current.branches[0]; + if (branch.next) { + last.next = branch.next; + let lastNode: WorkflowNode | WorkflowBranchNode | undefined = branch.next; + while (lastNode?.next) { + lastNode = lastNode.next; + } + lastNode.next = current.next; + } else { + last.next = current.next; + } + } + + break; + } + if (isWorkflowBranchNode(current)) { + current.branches = current.branches.map((branch) => removeBranch(branch, branchNodeId, branchIndex)); + } + current = current.next as WorkflowNode; + last = last.next; + } + return draft; + }); +}; + +export type WorkflowBranchNode = { + id: string; + name: string; + type: WorkflowNodeType; + branches: WorkflowNode[]; + next?: WorkflowNode | WorkflowBranchNode; +}; + +export type WorkflowNodeIo = { + name: string; + type: string; + required: boolean; + description?: string; + value?: string; + valueSelector?: WorkflowNodeIoValueSelector; +}; + +export type WorkflowNodeIoValueSelector = { + id: string; + name: string; +}; + +type WorkflowwNodeDropdwonItem = { + type: WorkflowNodeType; + providerType?: string; + name: string; + icon: WorkflowwNodeDropdwonItemIcon; + leaf?: boolean; + children?: WorkflowwNodeDropdwonItem[]; +}; + +export enum WorkflowwNodeDropdwonItemIconType { + Icon, + Provider, +} + +export type WorkflowwNodeDropdwonItemIcon = { + type: WorkflowwNodeDropdwonItemIconType; + name: string; +}; + +const workflowNodeDropdownApplyList: WorkflowwNodeDropdwonItem[] = accessProviders + .filter((item) => { + return item[3] === "apply" || item[3] === "all"; + }) + .map((item) => { + return { + type: WorkflowNodeType.Apply, + providerType: item[0], + name: i18n.t(item[1]), + leaf: true, + icon: { + type: WorkflowwNodeDropdwonItemIconType.Provider, + name: item[2], + }, + }; + }); + +const workflowNodeDropdownDeployList: WorkflowwNodeDropdwonItem[] = accessProviders + .filter((item) => { + return item[3] === "deploy" || item[3] === "all"; + }) + .map((item) => { + return { + type: WorkflowNodeType.Apply, + providerType: item[0], + name: i18n.t(item[1]), + leaf: true, + icon: { + type: WorkflowwNodeDropdwonItemIconType.Provider, + name: item[2], + }, + }; + }); + +export const workflowNodeDropdownList: WorkflowwNodeDropdwonItem[] = [ + { + type: WorkflowNodeType.Apply, + name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Apply) ?? "", + icon: { + type: WorkflowwNodeDropdwonItemIconType.Icon, + name: "NotebookPen", + }, + children: workflowNodeDropdownApplyList, + }, + { + type: WorkflowNodeType.Deploy, + name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Deploy) ?? "", + icon: { + type: WorkflowwNodeDropdwonItemIconType.Icon, + name: "CloudUpload", + }, + children: workflowNodeDropdownDeployList, + }, + { + type: WorkflowNodeType.Branch, + name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Branch) ?? "", + leaf: true, + icon: { + type: WorkflowwNodeDropdwonItemIconType.Icon, + name: "GitFork", + }, + }, + { + type: WorkflowNodeType.Notify, + name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Notify) ?? "", + leaf: true, + icon: { + type: WorkflowwNodeDropdwonItemIconType.Icon, + name: "Megaphone", + }, + }, +]; diff --git a/ui/src/pages/workflow/index.tsx b/ui/src/pages/workflow/index.tsx new file mode 100644 index 00000000..a363ce75 --- /dev/null +++ b/ui/src/pages/workflow/index.tsx @@ -0,0 +1,52 @@ +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import End from "@/components/workflow/End"; +import NodeRender from "@/components/workflow/NodeRender"; + +import WorkflowProvider from "@/components/workflow/WorkflowProvider"; +import { WorkflowNode } from "@/domain/workflow"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { useMemo } from "react"; + +import { useShallow } from "zustand/shallow"; + +const selectState = (state: WorkflowState) => ({ + root: state.root, +}); + +const Workflow = () => { + // 3. 使用正确的选择器和 shallow 比较 + const { root } = useWorkflowStore(useShallow(selectState)); + + const elements = useMemo(() => { + let current = root; + + const elements: JSX.Element[] = []; + + while (current) { + // 处理普通节点 + elements.push(); + current = current.next as WorkflowNode; + } + + elements.push(); + + return elements; + }, [root]); + + return ( + <> + + +
+ +
{elements}
+ + + +
+
+ + ); +}; + +export default Workflow; diff --git a/ui/src/providers/workflow/index.ts b/ui/src/providers/workflow/index.ts new file mode 100644 index 00000000..93ada241 --- /dev/null +++ b/ui/src/providers/workflow/index.ts @@ -0,0 +1,84 @@ +import { addBranch, addNode, removeBranch, removeNode, updateNode, WorkflowBranchNode, WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; +import { create } from "zustand"; + +export type WorkflowState = { + root: WorkflowNode; + updateNode: (node: WorkflowNode) => void; + addNode: (node: WorkflowNode, preId: string) => void; + addBranch: (branchId: string) => void; + removeNode: (nodeId: string) => void; + removeBranch: (branchId: string, index: number) => void; +}; + +export const useWorkflowStore = create((set) => ({ + root: { + id: "1", + name: "开始", + type: WorkflowNodeType.Start, + next: { + id: "2", + name: "结束", + type: WorkflowNodeType.Branch, + branches: [ + { + id: "3", + name: "条件1", + type: WorkflowNodeType.Condition, + next: { + id: "4", + name: "条件2", + type: WorkflowNodeType.Apply, + }, + }, + { + id: "5", + name: "条件2", + type: WorkflowNodeType.Condition, + }, + ], + }, + }, + updateNode: (node: WorkflowNode | WorkflowBranchNode) => { + set((state: WorkflowState) => { + const newRoot = updateNode(state.root, node); + console.log(newRoot); + return { + root: newRoot, + }; + }); + }, + addNode: (node: WorkflowNode | WorkflowBranchNode, preId: string) => + set((state: WorkflowState) => { + const newRoot = addNode(state.root, preId, node); + + return { + root: newRoot, + }; + }), + addBranch: (branchId: string) => + set((state: WorkflowState) => { + const newRoot = addBranch(state.root, branchId); + + return { + root: newRoot, + }; + }), + + removeBranch: (branchId: string, index: number) => + set((state: WorkflowState) => { + const newRoot = removeBranch(state.root, branchId, index); + + return { + root: newRoot, + }; + }), + + removeNode: (nodeId: string) => + set((state: WorkflowState) => { + const newRoot = removeNode(state.root, nodeId); + + return { + root: newRoot, + }; + }), +})); diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 296266c1..897e13d2 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -13,6 +13,7 @@ import Dashboard from "./pages/dashboard/Dashboard"; import Account from "./pages/setting/Account"; import Notify from "./pages/setting/Notify"; import SSLProvider from "./pages/setting/SSLProvider"; +import Workflow from "./pages/workflow"; export const router = createHashRouter([ { @@ -75,6 +76,6 @@ export const router = createHashRouter([ }, { path: "/about", - element:
About
, + element: , }, ]);