This commit is contained in:
yoan 2024-11-05 21:00:53 +08:00
parent 718cfccbea
commit 613b6839b8
23 changed files with 1597 additions and 21 deletions

51
ui/package-lock.json generated
View File

@ -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
}
}
} }
} }
} }

View File

@ -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",

View 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;

View 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);

View 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;

View 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;

View 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);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
};

View 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;

View 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;

View 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;
};

View File

@ -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
View 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",
},
},
];

View 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;

View 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,
};
}),
}));

View File

@ -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 />,
}, },
]); ]);