workflow
This commit is contained in:
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;
|
||||
};
|
||||
Reference in New Issue
Block a user