mirror of
https://github.com/woodchen-ink/certimate.git
synced 2025-07-18 09:21:56 +08:00
workflow
This commit is contained in:
parent
718cfccbea
commit
613b6839b8
51
ui/package-lock.json
generated
51
ui/package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
95
ui/src/components/workflow/AddNode.tsx
Normal file
95
ui/src/components/workflow/AddNode.tsx
Normal file
@ -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 (
|
||||
<div className="before:content-[''] before:w-[2px] before:bg-stone-300 before:absolute before:h-full before:left-[50%] before:-translate-x-[50%] before:top-0 pt-6 pb-9 relative flex flex-col items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="">
|
||||
<div className="bg-stone-400 hover:bg-stone-500 rounded-full z-10 relative outline-none">
|
||||
<Plus size={18} className="text-white" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>选择节点类型</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{workflowNodeDropdownList.map((item) => (
|
||||
<Show
|
||||
key={item.type}
|
||||
when={!!item.leaf}
|
||||
fallback={
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex space-x-2">
|
||||
<DropdownMenuItemIcon type={item.icon.type} name={item.icon.name} /> <div>{item.name}</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{item.children?.map((subItem) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={subItem.providerType}
|
||||
className="flex space-x-2"
|
||||
onClick={() => {
|
||||
handleTypeSelected(item.type, subItem.providerType);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItemIcon type={subItem.icon.type} name={subItem.icon.name} /> <div>{subItem.name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
key={item.type}
|
||||
className="flex space-x-2"
|
||||
onClick={() => {
|
||||
handleTypeSelected(item.type, item.providerType);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItemIcon type={item.icon.type} name={item.icon.name} /> <div>{item.name}</div>
|
||||
</DropdownMenuItem>
|
||||
</Show>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNode;
|
353
ui/src/components/workflow/ApplyForm.tsx
Normal file
353
ui/src/components/workflow/ApplyForm.tsx
Normal file
@ -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<z.infer<typeof formSchema>>({
|
||||
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<typeof formSchema>) => {
|
||||
updateNode({ ...data, config });
|
||||
hidePanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 dark:text-stone-200">
|
||||
{/* 域名 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<>
|
||||
<StringList
|
||||
value={field.value}
|
||||
valueType="domain"
|
||||
onValueChange={(domain: string) => {
|
||||
form.setValue("domain", domain);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 邮箱 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex justify-between w-full">
|
||||
<div>{t("domain.application.form.email.label") + " " + t("domain.application.form.email.tips")}</div>
|
||||
<EmailsEdit
|
||||
trigger={
|
||||
<div className="flex items-center font-normal cursor-pointer text-primary hover:underline">
|
||||
<Plus size={14} />
|
||||
{t("common.add")}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue("email", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("domain.application.form.email.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("domain.application.form.email.list")}</SelectLabel>
|
||||
{(emails.content as EmailsSetting).emails.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
<div>{item}</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* DNS 服务商授权 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="access"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex justify-between w-full">
|
||||
<div>{t("domain.application.form.access.label")}</div>
|
||||
<AccessEditDialog
|
||||
trigger={
|
||||
<div className="flex items-center font-normal cursor-pointer text-primary hover:underline">
|
||||
<Plus size={14} />
|
||||
{t("common.add")}
|
||||
</div>
|
||||
}
|
||||
op="add"
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue("access", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("domain.application.form.access.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("domain.application.form.access.list")}</SelectLabel>
|
||||
{accesses
|
||||
.filter((item) => item.usage != "deploy")
|
||||
.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<img className="w-6" src={accessProvidersMap.get(item.configType)?.icon} />
|
||||
<div>{item.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<hr />
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="w-full my-4">
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<span className="flex-1 text-sm text-left text-gray-600">{t("domain.application.form.advanced_settings.label")}</span>
|
||||
<ChevronsUpDown className="w-4 h-4" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/* 证书算法 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyAlgorithm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain.application.form.key_algorithm.label")}</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue("keyAlgorithm", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("domain.application.form.key_algorithm.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="RSA2048">RSA2048</SelectItem>
|
||||
<SelectItem value="RSA3072">RSA3072</SelectItem>
|
||||
<SelectItem value="RSA4096">RSA4096</SelectItem>
|
||||
<SelectItem value="RSA8192">RSA8192</SelectItem>
|
||||
<SelectItem value="EC256">EC256</SelectItem>
|
||||
<SelectItem value="EC384">EC384</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* DNS */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameservers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StringList
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(val: string) => {
|
||||
form.setValue("nameservers", val);
|
||||
}}
|
||||
valueType="dns"
|
||||
></StringList>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* DNS 超时时间 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain.application.form.timeout.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("domain.application.form.timeout.placeholder")}
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
form.setValue("timeout", parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 禁用 CNAME 跟随 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableFollowCNAME"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<div className="flex">
|
||||
<span className="mr-1">{t("domain.application.form.disable_follow_cname.label")} </span>
|
||||
<TooltipFast
|
||||
className="max-w-[20rem]"
|
||||
contentView={
|
||||
<p>
|
||||
{t("domain.application.form.disable_follow_cname.tips")}
|
||||
<a
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
href="https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname"
|
||||
>
|
||||
{t("domain.application.form.disable_follow_cname.tips_link")}
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</TooltipFast>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ApplyForm);
|
67
ui/src/components/workflow/BranchNode.tsx
Normal file
67
ui/src/components/workflow/BranchNode.tsx
Normal file
@ -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(<NodeRender data={current} branchId={branchNodeId} branchIndex={branchIndex} key={current.id} />);
|
||||
current = current.next;
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-t-[2px] border-b-[2px] relative flex gap-x-16 border-stone-300 bg-slate-50">
|
||||
<Button
|
||||
onClick={() => {
|
||||
addBranch(data.id);
|
||||
}}
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className="text-xs px-2 h-6 rounded-full absolute -top-3 left-[50%] -translate-x-1/2 z-10"
|
||||
>
|
||||
添加分支
|
||||
</Button>
|
||||
|
||||
{data.branches.map((branch, index) => (
|
||||
<div
|
||||
key={branch.id}
|
||||
className="relative flex flex-col items-center before:content-[''] before:w-[2px] before:bg-stone-300 before:absolute before:h-full before:left-[50%] before:-translate-x-[50%] before:top-0"
|
||||
>
|
||||
{index == 0 && (
|
||||
<>
|
||||
<div className="w-[50%] h-2 absolute -top-1 bg-stone-50 -left-[1px]"></div>
|
||||
<div className="w-[50%] h-2 absolute -bottom-1 bg-stone-50 -left-[1px]"></div>
|
||||
</>
|
||||
)}
|
||||
{index == data.branches.length - 1 && (
|
||||
<>
|
||||
<div className="w-[50%] h-2 absolute -top-1 bg-stone-50 -right-[1px]"></div>
|
||||
<div className="w-[50%] h-2 absolute -bottom-1 bg-stone-50 -right-[1px]"></div>
|
||||
</>
|
||||
)}
|
||||
{/* 条件 1 */}
|
||||
<div className="relative flex flex-col items-center">{renderNodes(branch, data.id, index)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AddNode data={data} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default BranchNode;
|
47
ui/src/components/workflow/ConditionNode.tsx
Normal file
47
ui/src/components/workflow/ConditionNode.tsx
Normal file
@ -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<HTMLDivElement>) => {
|
||||
updateNode({ ...data, name: e.target.innerText });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md shadow-md w-[261px] mt-10 relative z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="absolute right-2 top-1">
|
||||
<Ellipsis size={17} className="text-stone-600" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex space-x-2 text-red-600"
|
||||
onClick={() => {
|
||||
removeBranch(branchId ?? "", branchIndex ?? 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} /> <div>删除分支</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="w-[261px] flex flex-col justify-center text-foreground rounded-md bg-white px-5 py-5">
|
||||
<div contentEditable suppressContentEditableWarning onBlur={handleNameBlur} className="text-center outline-slate-200">
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddNode data={data} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionNode;
|
39
ui/src/components/workflow/DeployPanelBody.tsx
Normal file
39
ui/src/components/workflow/DeployPanelBody.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
{/* 默认展示服务商列表 */}
|
||||
<div className="text-lg font-semibold text-gray-700">选择服务商</div>
|
||||
{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) => (
|
||||
<div key={rowIndex} className="flex space-x-5">
|
||||
{providerRow.map((provider, index) => (
|
||||
<div key={index} className="flex space-x-2 w-1/3 items-center cursor-pointer hover:bg-slate-100 p-2 rounded-sm">
|
||||
<img src={provider[2]} alt={provider[1]} className="w-8 h-8" />
|
||||
<div className="text-muted-foreground">{t(provider[1])}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DeployPanelBody);
|
23
ui/src/components/workflow/DropdownMenuItemIcon.tsx
Normal file
23
ui/src/components/workflow/DropdownMenuItemIcon.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { WorkflowwNodeDropdwonItemIcon, WorkflowwNodeDropdwonItemIconType } from "@/domain/workflow";
|
||||
import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react";
|
||||
|
||||
const icons = new Map([
|
||||
["NotebookPen", <NotebookPen size={16} />],
|
||||
["CloudUpload", <CloudUpload size={16} />],
|
||||
["GitFork", <GitFork size={16} />],
|
||||
["Megaphone", <Megaphone size={16} />],
|
||||
]);
|
||||
|
||||
const DropdownMenuItemIcon = ({ type, name }: WorkflowwNodeDropdwonItemIcon) => {
|
||||
const getIcon = () => {
|
||||
if (type === WorkflowwNodeDropdwonItemIconType.Icon) {
|
||||
return icons.get(name);
|
||||
} else {
|
||||
return <img src={name} className="w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return getIcon();
|
||||
};
|
||||
|
||||
export default DropdownMenuItemIcon;
|
10
ui/src/components/workflow/End.tsx
Normal file
10
ui/src/components/workflow/End.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const End = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-[18px] rounded-full w-[18px] bg-stone-400"></div>
|
||||
<div className="text-sm text-stone-400 mt-2">流程结束</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default End;
|
76
ui/src/components/workflow/Node.tsx
Normal file
76
ui/src/components/workflow/Node.tsx
Normal file
@ -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<HTMLDivElement>) => {
|
||||
updateNode({ ...data, name: e.target.innerText });
|
||||
};
|
||||
|
||||
const { showPanel } = usePanel();
|
||||
|
||||
const handleNodeSettingClick = () => {
|
||||
showPanel({
|
||||
name: data.name,
|
||||
children: <PanelBody data={data} />,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md shadow-md w-[260px] relative">
|
||||
{data.type != WorkflowNodeType.Start && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="absolute right-2 top-1">
|
||||
<Ellipsis className="text-white" size={17} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex space-x-2 text-red-600"
|
||||
onClick={() => {
|
||||
removeNode(data.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} /> <div>删除节点</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-[260px] h-[60px] flex flex-col justify-center items-center bg-primary text-white rounded-t-md px-5">
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleNameBlur}
|
||||
className="w-full text-center outline-none focus:bg-white focus:text-stone-600 focus:rounded-sm"
|
||||
>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 text-sm text-primary flex flex-col justify-center bg-white">
|
||||
<div className="leading-7 text-primary cursor-pointer" onClick={handleNodeSettingClick}>
|
||||
设置节点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddNode data={data} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Node;
|
29
ui/src/components/workflow/NodeRender.tsx
Normal file
29
ui/src/components/workflow/NodeRender.tsx
Normal file
@ -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 <Node data={data} />;
|
||||
case WorkflowNodeType.End:
|
||||
return <End />;
|
||||
case WorkflowNodeType.Branch:
|
||||
return <BranchNode data={data as WorkflowBranchNode} />;
|
||||
case WorkflowNodeType.Condition:
|
||||
return <ConditionNode data={data as WorkflowNode} branchId={branchId} branchIndex={branchIndex} />;
|
||||
}
|
||||
};
|
||||
|
||||
return <>{render()}</>;
|
||||
});
|
||||
|
||||
export default NodeRender;
|
67
ui/src/components/workflow/NodeTypesPanel.tsx
Normal file
67
ui/src/components/workflow/NodeTypesPanel.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<div className="flex space-x-2">
|
||||
<div
|
||||
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
|
||||
onClick={() => {
|
||||
onTypeSelected(WorkflowNodeType.Apply);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
|
||||
<NotebookPen className="text-white" size={18} />
|
||||
</div>
|
||||
|
||||
<div className="text-slate-600">申请</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
|
||||
onClick={() => {
|
||||
onTypeSelected(WorkflowNodeType.Deploy);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
|
||||
<CloudUpload className="text-white" size={18} />
|
||||
</div>
|
||||
|
||||
<div className="text-slate-600">部署</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div
|
||||
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
|
||||
onClick={() => {
|
||||
onTypeSelected(WorkflowNodeType.Branch);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
|
||||
<GitFork className="text-white" size={18} />
|
||||
</div>
|
||||
|
||||
<div className="text-slate-600">分支</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
|
||||
onClick={() => {
|
||||
onTypeSelected(WorkflowNodeType.Notify);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
|
||||
<Megaphone className="text-white" size={18} />
|
||||
</div>
|
||||
|
||||
<div className="text-slate-600">推送</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeTypesPanel;
|
23
ui/src/components/workflow/Panel.tsx
Normal file
23
ui/src/components/workflow/Panel.tsx
Normal file
@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-[640px] p-0">
|
||||
<SheetTitle className="bg-primary p-4 text-white">{name}</SheetTitle>
|
||||
|
||||
<div className="p-10 flex-col space-y-5">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default Panel;
|
30
ui/src/components/workflow/PanelBody.tsx
Normal file
30
ui/src/components/workflow/PanelBody.tsx
Normal file
@ -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 <StartForm data={data} />;
|
||||
case WorkflowNodeType.Apply:
|
||||
return <ApplyForm data={data} />;
|
||||
case WorkflowNodeType.Notify:
|
||||
return <DeployPanelBody data={data} />;
|
||||
case WorkflowNodeType.Branch:
|
||||
return <div>分支节点</div>;
|
||||
case WorkflowNodeType.Condition:
|
||||
return <div>条件节点</div>;
|
||||
default:
|
||||
return <> </>;
|
||||
}
|
||||
};
|
||||
|
||||
return <>{getBody()}</>;
|
||||
};
|
||||
|
||||
export default PanelBody;
|
42
ui/src/components/workflow/PanelProvider.tsx
Normal file
42
ui/src/components/workflow/PanelProvider.tsx
Normal file
@ -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<PanelContextType | undefined>(undefined);
|
||||
|
||||
export const PanelProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [panelContent, setPanelContent] = useState<PanelContentProps | null>(null);
|
||||
|
||||
const showPanel = (panelContent: PanelContentProps) => {
|
||||
setOpen(true);
|
||||
setPanelContent(panelContent);
|
||||
};
|
||||
const hidePanel = () => {
|
||||
setOpen(false);
|
||||
setPanelContent(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelContext.Provider value={{ open, showPanel, hidePanel }}>
|
||||
{children}
|
||||
<Panel open={open} onOpenChange={setOpen} children={panelContent?.children} name={panelContent?.name ?? ""} />
|
||||
</PanelContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePanel = () => {
|
||||
const context = useContext(PanelContext);
|
||||
if (!context) {
|
||||
throw new Error("useDialog must be used within DialogProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
144
ui/src/components/workflow/StartForm.tsx
Normal file
144
ui/src/components/workflow/StartForm.tsx
Normal file
@ -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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
executionMethod: config.executionMethod as string,
|
||||
crontab: config.crontab as string,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (config: z.infer<typeof formSchema>) => {
|
||||
updateNode({ ...data, config: { ...config } });
|
||||
hidePanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.stopPropagation();
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="executionMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>执行方式</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
{...field}
|
||||
value={method}
|
||||
onValueChange={(val: string) => {
|
||||
setMethod(val);
|
||||
}}
|
||||
className="flex space-x-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="option-one" />
|
||||
<Label htmlFor="option-one">自动</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="option-two" />
|
||||
<Label htmlFor="option-two">手动</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="crontab"
|
||||
render={({ field }) => (
|
||||
<FormItem hidden={method == "manual"}>
|
||||
<FormLabel>定时表达式</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartForm;
|
13
ui/src/components/workflow/WorkflowProvider.tsx
Normal file
13
ui/src/components/workflow/WorkflowProvider.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { ConfigProvider } from "@/providers/config";
|
||||
import React from "react";
|
||||
import { PanelProvider } from "./PanelProvider";
|
||||
|
||||
const WorkflowProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<PanelProvider>{children}</PanelProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowProvider;
|
11
ui/src/components/workflow/types.ts
Normal file
11
ui/src/components/workflow/types.ts
Normal file
@ -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;
|
||||
};
|
@ -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<AccessProvider["type"], AccessProvider> = 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(
|
||||
|
317
ui/src/domain/workflow.ts
Normal file
317
ui/src/domain/workflow.ts
Normal file
@ -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<WorkflowNodeType, string> = new Map([
|
||||
[WorkflowNodeType.Start, "开始"],
|
||||
[WorkflowNodeType.End, "结束"],
|
||||
[WorkflowNodeType.Branch, "分支"],
|
||||
[WorkflowNodeType.Condition, "分支"],
|
||||
[WorkflowNodeType.Apply, "申请"],
|
||||
[WorkflowNodeType.Deploy, "部署"],
|
||||
[WorkflowNodeType.Notify, "通知"],
|
||||
[WorkflowNodeType.Custom, "自定义"],
|
||||
]);
|
||||
|
||||
export type WorkflowNodeConfig = Record<string, string | boolean | number | string[] | undefined>;
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
];
|
52
ui/src/pages/workflow/index.tsx
Normal file
52
ui/src/pages/workflow/index.tsx
Normal file
@ -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(<NodeRender data={current} key={current.id} />);
|
||||
current = current.next as WorkflowNode;
|
||||
}
|
||||
|
||||
elements.push(<End key="workflow-end" />);
|
||||
|
||||
return elements;
|
||||
}, [root]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowProvider>
|
||||
<ScrollArea className="h-[100vh] w-full bg-slate-50 relative">
|
||||
<div className="h-16 sticky top-0 left-0 z-20 shadow-md bg-white"></div>
|
||||
|
||||
<div className=" flex flex-col items-center mt-8">{elements}</div>
|
||||
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</WorkflowProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workflow;
|
84
ui/src/providers/workflow/index.ts
Normal file
84
ui/src/providers/workflow/index.ts
Normal file
@ -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<WorkflowState>((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,
|
||||
};
|
||||
}),
|
||||
}));
|
@ -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: <div>About</div>,
|
||||
element: <Workflow />,
|
||||
},
|
||||
]);
|
||||
|
Loading…
x
Reference in New Issue
Block a user