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 (
+ <>
+
+
+ >
+ );
+};
+
+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);
+ }}
+ >
+ 删除分支
+
+
+
+
+
+
+
+ >
+ );
+};
+
+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]}]({provider[2]})
+
{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);
+ }}
+ >
+ 删除节点
+
+
+
+ >
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+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 (
+ <>
+
+
+ >
+ );
+};
+
+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: ,
},
]);