mirror of
https://github.com/woodchen-ink/certimate.git
synced 2025-07-18 17:31:55 +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",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
"cron-parser": "^4.9.0",
|
||||||
"i18next": "^23.15.1",
|
"i18next": "^23.15.1",
|
||||||
"i18next-browser-languagedetector": "^8.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
@ -47,7 +48,8 @@
|
|||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.1",
|
"vaul": "^0.9.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8",
|
||||||
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@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",
|
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
"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": {
|
"node_modules/cross-fetch": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
|
||||||
@ -6628,6 +6649,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
"cron-parser": "^4.9.0",
|
||||||
"i18next": "^23.15.1",
|
"i18next": "^23.15.1",
|
||||||
"i18next-browser-languagedetector": "^8.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
@ -49,7 +50,8 @@
|
|||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.1",
|
"vaul": "^0.9.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8",
|
||||||
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@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,8 +10,7 @@ type AccessProvider = {
|
|||||||
searchContent: string;
|
searchContent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const accessProvidersMap: Map<AccessProvider["type"], AccessProvider> = new Map(
|
export const accessProviders = [
|
||||||
[
|
|
||||||
["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"],
|
["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"],
|
||||||
["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"],
|
["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"],
|
||||||
["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"],
|
["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"],
|
||||||
@ -28,7 +27,10 @@ export const accessProvidersMap: Map<AccessProvider["type"], AccessProvider> = n
|
|||||||
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"],
|
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"],
|
||||||
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"],
|
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"],
|
||||||
["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy", "k8s:kubernetes"],
|
["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 }])
|
];
|
||||||
|
|
||||||
|
export const accessProvidersMap: Map<AccessProvider["type"], AccessProvider> = new Map(
|
||||||
|
accessProviders.map(([type, name, icon, usage, searchContent]) => [type, { type, name, icon, usage: usage as AccessUsages, searchContent: searchContent }])
|
||||||
);
|
);
|
||||||
|
|
||||||
export const accessTypeFormSchema = z.union(
|
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 Account from "./pages/setting/Account";
|
||||||
import Notify from "./pages/setting/Notify";
|
import Notify from "./pages/setting/Notify";
|
||||||
import SSLProvider from "./pages/setting/SSLProvider";
|
import SSLProvider from "./pages/setting/SSLProvider";
|
||||||
|
import Workflow from "./pages/workflow";
|
||||||
|
|
||||||
export const router = createHashRouter([
|
export const router = createHashRouter([
|
||||||
{
|
{
|
||||||
@ -75,6 +76,6 @@ export const router = createHashRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/about",
|
path: "/about",
|
||||||
element: <div>About</div>,
|
element: <Workflow />,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user