feat(ui): WorkflowNew page

This commit is contained in:
Fu Diwei 2025-01-02 20:24:16 +08:00
parent b6dd2248c8
commit c6a8f923e4
21 changed files with 415 additions and 225 deletions

View File

@ -37,8 +37,8 @@ type WorkflowNode struct {
Name string `json:"name"`
Next *WorkflowNode `json:"next"`
Config map[string]any `json:"config"`
Input []WorkflowNodeIo `json:"input"`
Output []WorkflowNodeIo `json:"output"`
Input []WorkflowNodeIO `json:"input"`
Output []WorkflowNodeIO `json:"output"`
Validated bool `json:"validated"`
Type string `json:"type"`
@ -76,16 +76,16 @@ func (n *WorkflowNode) GetConfigInt64(key string) int64 {
return 0
}
type WorkflowNodeIo struct {
type WorkflowNodeIO struct {
Label string `json:"label"`
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Value any `json:"value"`
ValueSelector WorkflowNodeIoValueSelector `json:"valueSelector"`
ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"`
}
type WorkflowNodeIoValueSelector struct {
type WorkflowNodeIOValueSelector struct {
Id string `json:"id"`
Name string `json:"name"`
}

View File

@ -7,6 +7,6 @@ type WorkflowOutput struct {
Workflow string `json:"workflow"`
NodeId string `json:"nodeId"`
Node *WorkflowNode `json:"node"`
Output []WorkflowNodeIo `json:"output"`
Output []WorkflowNodeIO `json:"output"`
Succeed bool `json:"succeed"`
}

View File

@ -35,7 +35,7 @@ func (w *WorkflowOutputRepository) Get(ctx context.Context, nodeId string) (*dom
return nil, errors.New("failed to unmarshal node")
}
output := make([]domain.WorkflowNodeIo, 0)
output := make([]domain.WorkflowNodeIO, 0)
if err := record.UnmarshalJSONField("output", &output); err != nil {
return nil, errors.New("failed to unmarshal output")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { BookOutlined as BookOutlinedIcon } from "@ant-design/icons";
import { ReadOutlined as ReadOutlinedIcon } from "@ant-design/icons";
import { Divider, Space, Typography } from "antd";
import { version } from "@/domain/version";
@ -16,7 +16,7 @@ const Version = ({ className, style }: VersionProps) => {
<Space className={className} style={style} size={4}>
<Typography.Link type="secondary" href="https://docs.certimate.me" target="_blank">
<div className="flex items-center justify-center space-x-1">
<BookOutlinedIcon />
<ReadOutlinedIcon />
<span>{t("common.menu.document")}</span>
</div>
</Typography.Link>

View File

@ -1,7 +1,7 @@
import { PlusOutlined as PlusOutlinedIcon } from "@ant-design/icons";
import { Dropdown } from "antd";
import { newWorkflowNode, workflowNodeDropdownList, type WorkflowNodeType } from "@/domain/workflow";
import { type WorkflowNodeType, newNode, workflowNodeDropdownList } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
@ -12,7 +12,7 @@ const AddNode = ({ data }: NodeProps | BrandNodeProps) => {
const { addNode } = useWorkflowStore(useZustandShallowSelector(["addNode"]));
const handleTypeSelected = (type: WorkflowNodeType, provider?: string) => {
const node = newWorkflowNode(type, {
const node = newNode(type, {
providerType: provider,
});

View File

@ -1,11 +1,18 @@
import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react";
import {
CloudUploadOutlined as CloudUploadOutlinedIcon,
SendOutlined as SendOutlinedIcon,
SisternodeOutlined as SisternodeOutlinedIcon,
SolutionOutlined as SolutionOutlinedIcon,
} from "@ant-design/icons";
import { Avatar } from "antd";
import { type WorkflowNodeDropdwonItemIcon, WorkflowNodeDropdwonItemIconType } from "@/domain/workflow";
const icons = new Map([
["NotebookPen", <NotebookPen size={16} />],
["CloudUpload", <CloudUpload size={16} />],
["GitFork", <GitFork size={16} />],
["Megaphone", <Megaphone size={16} />],
["ApplyNodeIcon", <SolutionOutlinedIcon />],
["DeployNodeIcon", <CloudUploadOutlinedIcon />],
["BranchNodeIcon", <SisternodeOutlinedIcon />],
["NotifyNodeIcon", <SendOutlinedIcon />],
]);
const DropdownMenuItemIcon = ({ type, name }: WorkflowNodeDropdwonItemIcon) => {
@ -13,7 +20,7 @@ const DropdownMenuItemIcon = ({ type, name }: WorkflowNodeDropdwonItemIcon) => {
if (type === WorkflowNodeDropdwonItemIconType.Icon) {
return icons.get(name);
} else {
return <img src={name} className="inline-block size-4" />;
return <Avatar src={name} size="small" />;
}
};

View File

@ -8,7 +8,7 @@ export interface CertificateModel extends BaseModel {
certUrl: string;
certStableUrl: string;
output: string;
expireAt: string;
expireAt: ISO8601String;
workflow: string;
nodeId: string;
expand: {

View File

@ -1,16 +1,10 @@
import dayjs from "dayjs";
import { produce } from "immer";
import { nanoid } from "nanoid";
import i18n from "@/i18n";
import { deployProvidersMap } from "./provider";
export type WorkflowOutput = {
time: string;
title: string;
content: string;
error: string;
};
export interface WorkflowModel extends BaseModel {
name: string;
description?: string;
@ -22,6 +16,7 @@ export interface WorkflowModel extends BaseModel {
hasDraft?: boolean;
}
// #region Node
export enum WorkflowNodeType {
Start = "start",
End = "end",
@ -33,7 +28,7 @@ export enum WorkflowNodeType {
Custom = "custom",
}
export const workflowNodeTypeDefaultName: Map<WorkflowNodeType, string> = new Map([
const workflowNodeTypeDefaultNames: Map<WorkflowNodeType, string> = new Map([
[WorkflowNodeType.Start, i18n.t("workflow_node.start.label")],
[WorkflowNodeType.End, i18n.t("workflow_node.end.label")],
[WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")],
@ -44,21 +39,7 @@ export const workflowNodeTypeDefaultName: Map<WorkflowNodeType, string> = new Ma
[WorkflowNodeType.Custom, i18n.t("workflow_node.custom.title")],
]);
export type WorkflowNodeIo = {
name: string;
type: string;
required: boolean;
label: string;
value?: string;
valueSelector?: WorkflowNodeIoValueSelector;
};
export type WorkflowNodeIoValueSelector = {
id: string;
name: string;
};
export const workflowNodeTypeDefaultInput: Map<WorkflowNodeType, WorkflowNodeIo[]> = new Map([
const workflowNodeTypeDefaultInputs: Map<WorkflowNodeType, WorkflowNodeIO[]> = new Map([
[WorkflowNodeType.Apply, []],
[
WorkflowNodeType.Deploy,
@ -74,7 +55,7 @@ export const workflowNodeTypeDefaultInput: Map<WorkflowNodeType, WorkflowNodeIo[
[WorkflowNodeType.Notify, []],
]);
export const workflowNodeTypeDefaultOutput: Map<WorkflowNodeType, WorkflowNodeIo[]> = new Map([
const workflowNodeTypeDefaultOutputs: Map<WorkflowNodeType, WorkflowNodeIO[]> = new Map([
[
WorkflowNodeType.Apply,
[
@ -90,88 +71,122 @@ export const workflowNodeTypeDefaultOutput: Map<WorkflowNodeType, WorkflowNodeIo
[WorkflowNodeType.Notify, []],
]);
export type WorkflowNodeConfig = Record<string, unknown>;
export type WorkflowNode = {
id: string;
name: string;
type: WorkflowNodeType;
validated?: boolean;
input?: WorkflowNodeIo[];
config?: WorkflowNodeConfig;
output?: WorkflowNodeIo[];
config?: Record<string, unknown>;
input?: WorkflowNodeIO[];
output?: WorkflowNodeIO[];
next?: WorkflowNode | WorkflowBranchNode;
branches?: WorkflowNode[];
validated?: boolean;
};
/**
* @deprecated
*/
export type WorkflowBranchNode = {
id: string;
name: string;
type: WorkflowNodeType.Branch;
branches: WorkflowNode[];
next?: WorkflowNode | WorkflowBranchNode;
};
type NewWorkflowNodeOptions = {
export type WorkflowNodeIO = {
name: string;
type: string;
required: boolean;
label: string;
value?: string;
valueSelector?: WorkflowNodeIOValueSelector;
};
export type WorkflowNodeIOValueSelector = {
id: string;
name: string;
};
// #endregion
type InitWorkflowOptions = {
template?: "standard";
};
export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => {
const root = newNode(WorkflowNodeType.Start, {}) as WorkflowNode;
root.config = { executionMethod: "manual" };
if (options.template === "standard") {
let temp = root;
temp.next = newNode(WorkflowNodeType.Apply, {});
temp = temp.next;
temp.next = newNode(WorkflowNodeType.Deploy, {});
temp = temp.next;
temp.next = newNode(WorkflowNodeType.Notify, {});
}
return {
id: null!,
name: `MyWorkflow-${dayjs().format("YYYYMMDDHHmmss")}`,
type: root.config!.executionMethod as string,
crontab: root.config!.crontab as string,
enabled: false,
draft: root,
hasDraft: true,
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
};
type NewNodeOptions = {
branchIndex?: number;
providerType?: string;
};
export const initWorkflow = (): WorkflowModel => {
// 开始节点
const rs = newWorkflowNode(WorkflowNodeType.Start, {});
let root = rs;
export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions): WorkflowNode | WorkflowBranchNode => {
const nodeTypeName = workflowNodeTypeDefaultNames.get(nodeType) || "";
const nodeName = options.branchIndex != null ? `${nodeTypeName} ${options.branchIndex + 1}` : nodeTypeName;
// 申请节点
root.next = newWorkflowNode(WorkflowNodeType.Apply, {});
root = root.next;
// 部署节点
root.next = newWorkflowNode(WorkflowNodeType.Deploy, {});
root = root.next;
// 通知节点
root.next = newWorkflowNode(WorkflowNodeType.Notify, {});
return {
id: "",
name: i18n.t("workflow.props.name.default"),
type: "auto",
crontab: "0 0 * * *",
enabled: false,
draft: rs,
created: new Date().toUTCString(),
updated: new Date().toUTCString(),
};
const node: WorkflowNode | WorkflowBranchNode = {
id: nanoid(),
name: nodeName,
type: nodeType,
};
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: {
switch (nodeType) {
case WorkflowNodeType.Apply:
case WorkflowNodeType.Deploy:
{
node.config = {
providerType: options.providerType,
},
input: workflowNodeTypeDefaultInput.get(type),
output: workflowNodeTypeDefaultOutput.get(type),
};
node.input = workflowNodeTypeDefaultInputs.get(nodeType);
node.output = workflowNodeTypeDefaultOutputs.get(nodeType);
}
break;
case WorkflowNodeType.Condition:
{
node.validated = true;
}
break;
case WorkflowNodeType.Branch:
{
node.branches = [newNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newNode(WorkflowNodeType.Condition, { branchIndex: 1 })];
}
break;
}
if (type == WorkflowNodeType.Condition) {
rs.validated = true;
}
if (type === WorkflowNodeType.Branch) {
rs = {
...rs,
branches: [newWorkflowNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newWorkflowNode(WorkflowNodeType.Condition, { branchIndex: 1 })],
};
}
return rs;
return node;
};
export const isWorkflowBranchNode = (node: WorkflowNode | WorkflowBranchNode): node is WorkflowBranchNode => {
@ -226,7 +241,7 @@ export const addBranch = (node: WorkflowNode | WorkflowBranchNode, branchNodeId:
return draft;
}
current.branches.push(
newWorkflowNode(WorkflowNodeType.Condition, {
newNode(WorkflowNodeType.Condition, {
branchIndex: current.branches.length,
})
);
@ -340,21 +355,24 @@ export const getWorkflowOutputBeforeId = (node: WorkflowNode | WorkflowBranchNod
return output;
};
export const isAllNodesValidated = (node: WorkflowNode | WorkflowBranchNode): boolean => {
export const isAllNodesValidated = (node: WorkflowNode): boolean => {
let current = node as typeof node | undefined;
while (current) {
if (!isWorkflowBranchNode(current) && !current.validated) {
return false;
}
if (isWorkflowBranchNode(current)) {
for (const branch of current.branches) {
if (current.type === WorkflowNodeType.Branch) {
for (const branch of current.branches!) {
if (!isAllNodesValidated(branch)) {
return false;
}
}
} else {
if (!current.validated) {
return false;
}
}
current = current.next;
}
return true;
};
@ -372,14 +390,9 @@ export const getExecuteMethod = (node: WorkflowNode): { type: string; crontab: s
}
};
export type WorkflowBranchNode = {
id: string;
name: string;
type: WorkflowNodeType;
branches: WorkflowNode[];
next?: WorkflowNode | WorkflowBranchNode;
};
/**
* @deprecated
*/
type WorkflowNodeDropdwonItem = {
type: WorkflowNodeType;
providerType?: string;
@ -389,16 +402,25 @@ type WorkflowNodeDropdwonItem = {
children?: WorkflowNodeDropdwonItem[];
};
/**
* @deprecated
*/
export enum WorkflowNodeDropdwonItemIconType {
Icon,
Provider,
}
/**
* @deprecated
*/
export type WorkflowNodeDropdwonItemIcon = {
type: WorkflowNodeDropdwonItemIconType;
name: string;
};
/**
* @deprecated
*/
const workflowNodeDropdownDeployList: WorkflowNodeDropdwonItem[] = Array.from(deployProvidersMap.values()).map((item) => {
return {
type: WorkflowNodeType.Apply,
@ -412,41 +434,44 @@ const workflowNodeDropdownDeployList: WorkflowNodeDropdwonItem[] = Array.from(de
};
});
/**
* @deprecated
*/
export const workflowNodeDropdownList: WorkflowNodeDropdwonItem[] = [
{
type: WorkflowNodeType.Apply,
name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Apply) ?? "",
name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Apply) ?? "",
icon: {
type: WorkflowNodeDropdwonItemIconType.Icon,
name: "NotebookPen",
name: "ApplyNodeIcon",
},
leaf: true,
},
{
type: WorkflowNodeType.Deploy,
name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Deploy) ?? "",
name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Deploy) ?? "",
icon: {
type: WorkflowNodeDropdwonItemIconType.Icon,
name: "CloudUpload",
name: "DeployNodeIcon",
},
children: workflowNodeDropdownDeployList,
},
{
type: WorkflowNodeType.Branch,
name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Branch) ?? "",
name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Branch) ?? "",
leaf: true,
icon: {
type: WorkflowNodeDropdwonItemIconType.Icon,
name: "GitFork",
name: "BranchNodeIcon",
},
},
{
type: WorkflowNodeType.Notify,
name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Notify) ?? "",
name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Notify) ?? "",
leaf: true,
icon: {
type: WorkflowNodeDropdwonItemIconType.Icon,
name: "Megaphone",
name: "NotifyNodeIcon",
},
},
];

View File

@ -1,5 +1,3 @@
import { type WorkflowOutput } from "./workflow";
export interface WorkflowRunModel extends BaseModel {
workflow: string;
log: WorkflowRunLog[];
@ -10,5 +8,12 @@ export interface WorkflowRunModel extends BaseModel {
export type WorkflowRunLog = {
nodeName: string;
error: string;
outputs: WorkflowOutput[];
outputs: WorkflowRunLogOutput[];
};
export type WorkflowRunLogOutput = {
time: ISO8601String;
title: string;
content: string;
error: string;
};

4
ui/src/global.d.ts vendored
View File

@ -1,6 +1,8 @@
import { type BaseModel as PbBaseModel } from "pocketbase";
declare global {
declare type ISO8601String = string;
declare interface BaseModel extends PbBaseModel {
created: ISO8601String;
updated: ISO8601String;
@ -10,8 +12,6 @@ declare global {
declare type MaybeModelRecord<T extends BaseModel = BaseModel> = T | Omit<T, "id" | "created" | "updated" | "deleted">;
declare type MaybeModelRecordWithId<T extends BaseModel = BaseModel> = T | Pick<T, "id">;
declare type ISO8601String = string;
}
export {};

View File

@ -3,7 +3,7 @@ import { initReactI18next } from "react-i18next";
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import resources, { LOCALE_ZH_NAME, LOCALE_EN_NAME } from "./locales";
import resources, { LOCALE_EN_NAME, LOCALE_ZH_NAME } from "./locales";
i18n
.use(LanguageDetector)

View File

@ -3,9 +3,8 @@
"common.button.cancel": "Cancel",
"common.button.copy": "Copy",
"common.button.delete": "Delete",
"common.button.disable": "Disable",
"common.button.edit": "Edit",
"common.button.enable": "Enable",
"common.button.more": "More",
"common.button.ok": "Ok",
"common.button.reset": "Reset",
"common.button.save": "Save",

View File

@ -7,14 +7,9 @@
"workflow.action.edit": "Edit workflow",
"workflow.action.delete": "Delete workflow",
"workflow.action.delete.confirm": "Are you sure to delete this workflow?",
"workflow.action.discard": "Discard changes",
"workflow.action.discard.confirm": "Are you sure to discard your changes?",
"workflow.action.release": "Release",
"workflow.action.release.confirm": "Are you sure to release your changes?",
"workflow.action.release.failed.uncompleted": "Please complete the orchestration first",
"workflow.action.run": "Run",
"workflow.action.run.confirm": "There are unreleased changes, are you sure to run this workflow based on the latest released version?",
"workflow.action.enable": "Enable",
"workflow.action.enable.failed.uncompleted": "Please complete the orchestration and publish the changes first",
"workflow.action.disable": "Disable",
"workflow.props.name": "Name",
"workflow.props.description": "Description",
@ -28,14 +23,28 @@
"workflow.props.created_at": "Created at",
"workflow.props.updated_at": "Updated at",
"workflow.detail.orchestration.tab": "Orchestration",
"workflow.detail.runs.tab": "History runs",
"workflow.new.title": "Create Workflow",
"workflow.new.subtitle": "Apply, deploy and notify with Workflows",
"workflow.new.templates.title": "Choose a Workflow Template",
"workflow.new.templates.template.standard.title": "Standard template",
"workflow.new.templates.template.standard.description": "A standard operating procedure that includes application, deployment, and notification steps.",
"workflow.new.templates.template.blank.title": "Blank template",
"workflow.new.templates.template.blank.description": "Customize all the contents of the workflow from the beginning.",
"workflow.detail.baseinfo.modal.title": "Workflow base information",
"workflow.detail.baseinfo.form.name.label": "Name",
"workflow.detail.baseinfo.form.name.placeholder": "Please enter name",
"workflow.detail.baseinfo.form.description.label": "Description",
"workflow.detail.baseinfo.form.description.placeholder": "Please enter description",
"workflow.detail.orchestration.tab": "Orchestration",
"workflow.detail.orchestration.action.discard": "Discard changes",
"workflow.detail.orchestration.action.discard.confirm": "Are you sure to discard your changes?",
"workflow.detail.orchestration.action.release": "Release",
"workflow.detail.orchestration.action.release.confirm": "Are you sure to release your changes?",
"workflow.detail.orchestration.action.release.failed.uncompleted": "Please complete the orchestration first",
"workflow.detail.orchestration.action.run": "Run",
"workflow.detail.orchestration.action.run.confirm": "There are unreleased changes, are you sure to run this workflow based on the latest released version?",
"workflow.detail.runs.tab": "History runs",
"workflow.common.certificate.label": "Certificate",
"workflow.node.setting.label": "Setting Node",

View File

@ -3,9 +3,8 @@
"common.button.cancel": "取消",
"common.button.copy": "复制",
"common.button.delete": "刪除",
"common.button.disable": "禁用",
"common.button.edit": "编辑",
"common.button.enable": "启用",
"common.button.more": "更多",
"common.button.ok": "确定",
"common.button.reset": "重置",
"common.button.save": "保存",

View File

@ -7,14 +7,9 @@
"workflow.action.edit": "编辑工作流",
"workflow.action.delete": "删除工作流",
"workflow.action.delete.confirm": "确定要删除此工作流吗?",
"workflow.action.discard": "撤销更改",
"workflow.action.discard.confirm": "确定要撤销更改并回退到最近一次发布的版本吗?",
"workflow.action.release": "发布更改",
"workflow.action.release.confirm": "确定要发布更改吗?",
"workflow.action.release.failed.uncompleted": "请先完成流程编排",
"workflow.action.run": "执行",
"workflow.action.run.confirm": "存在未发布的更改,确定要按最近一次发布的版本来执行此工作流吗?",
"workflow.action.enable": "启用",
"workflow.action.enable.failed.uncompleted": "请先完成流程编排并发布更改",
"workflow.action.disable": "禁用",
"workflow.props.name": "名称",
"workflow.props.description": "描述",
@ -28,14 +23,28 @@
"workflow.props.created_at": "创建时间",
"workflow.props.updated_at": "更新时间",
"workflow.detail.orchestration.tab": "流程编排",
"workflow.detail.runs.tab": "执行历史",
"workflow.new.title": "新建工作流",
"workflow.new.subtitle": "使用工作流来申请证书、部署上传和发送通知",
"workflow.new.templates.title": "选择工作流模板",
"workflow.new.templates.template.standard.title": "标准模板",
"workflow.new.templates.template.standard.description": "一个包含申请 + 部署 + 通知步骤的标准工作流程。",
"workflow.new.templates.template.blank.title": "空白模板",
"workflow.new.templates.template.blank.description": "从零开始自定义工作流的任务内容。",
"workflow.detail.baseinfo.modal.title": "编辑基本信息",
"workflow.detail.baseinfo.form.name.label": "名称",
"workflow.detail.baseinfo.form.name.placeholder": "请输入工作流名称",
"workflow.detail.baseinfo.form.description.label": "描述",
"workflow.detail.baseinfo.form.description.placeholder": "请输入工作流描述",
"workflow.detail.orchestration.tab": "流程编排",
"workflow.detail.orchestration.action.discard": "撤销更改",
"workflow.detail.orchestration.action.discard.confirm": "确定要撤销更改并回退到最近一次发布的版本吗?",
"workflow.detail.orchestration.action.release": "发布更改",
"workflow.detail.orchestration.action.release.confirm": "确定要发布更改吗?",
"workflow.detail.orchestration.action.release.failed.uncompleted": "流程编排未完成,请检查是否有节点未设置",
"workflow.detail.orchestration.action.run": "执行",
"workflow.detail.orchestration.action.run.confirm": "此工作流存在未发布的更改,将以最近一次发布的版本为准,确定要继续执行吗?",
"workflow.detail.runs.tab": "执行历史",
"workflow.common.certificate.label": "证书",
"workflow.node.setting.label": "设置节点",

View File

@ -5,6 +5,7 @@ import {
ApartmentOutlined as ApartmentOutlinedIcon,
CaretRightOutlined as CaretRightOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
DownOutlined as DownOutlinedIcon,
EllipsisOutlined as EllipsisOutlinedIcon,
HistoryOutlined as HistoryOutlinedIcon,
UndoOutlined as UndoOutlinedIcon,
@ -45,8 +46,8 @@ const WorkflowDetail = () => {
);
useEffect(() => {
// TODO: loading
init(workflowId);
}, [workflowId, init]);
init(workflowId!);
}, [workflowId]);
const [tabValue, setTabValue] = useState<"orchestration" | "runs">("orchestration");
@ -70,10 +71,13 @@ const WorkflowDetail = () => {
const [allowDiscard, setAllowDiscard] = useState(false);
const [allowRelease, setAllowRelease] = useState(false);
const [allowRun, setAllowRun] = useState(false);
useDeepCompareEffect(() => {
const hasReleased = !!workflow.content;
const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content);
setAllowDiscard(hasChanges && !workflowRunning);
setAllowRelease(hasChanges && !workflowRunning);
setAllowDiscard(!workflowRunning && hasReleased && hasChanges);
setAllowRelease(!workflowRunning && hasChanges);
setAllowRun(hasReleased);
}, [workflow, workflowRunning]);
const handleBaseInfoFormFinish = async (values: Pick<WorkflowModel, "name" | "description">) => {
@ -86,13 +90,18 @@ const WorkflowDetail = () => {
}
};
const handleEnableChange = () => {
if (!workflow.enabled && !isAllNodesValidated(workflow.content!)) {
const handleEnableChange = async () => {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
messageApi.warning(t("workflow.action.enable.failed.uncompleted"));
return;
}
switchEnable();
try {
await switchEnable();
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
};
const handleDeleteClick = () => {
@ -114,18 +123,24 @@ const WorkflowDetail = () => {
};
const handleDiscardClick = () => {
modalApi.confirm({
title: t("workflow.detail.orchestration.action.discard"),
content: t("workflow.detail.orchestration.action.discard.confirm"),
onOk: () => {
alert("TODO");
},
});
};
const handleReleaseClick = () => {
if (!isAllNodesValidated(workflow.draft!)) {
messageApi.warning(t("workflow.action.release.failed.uncompleted"));
messageApi.warning(t("workflow.detail.orchestration.action.release.failed.uncompleted"));
return;
}
modalApi.confirm({
title: t("workflow.action.release"),
content: t("workflow.action.release.confirm"),
title: t("workflow.detail.orchestration.action.release"),
content: t("workflow.detail.orchestration.action.release.confirm"),
onOk: async () => {
try {
await save();
@ -148,8 +163,8 @@ const WorkflowDetail = () => {
const { promise, resolve, reject } = Promise.withResolvers();
if (workflow.hasDraft) {
modalApi.confirm({
title: t("workflow.action.run"),
content: t("workflow.action.run.confirm"),
title: t("workflow.detail.orchestration.action.run"),
content: t("workflow.detail.orchestration.action.run.confirm"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
@ -164,7 +179,7 @@ const WorkflowDetail = () => {
try {
await runWorkflow(workflowId!);
messageApi.warning(t("common.text.operation_succeeded"));
messageApi.success(t("common.text.operation_succeeded"));
} catch (err) {
if (err instanceof ClientResponseError && err.isAbort) {
return;
@ -189,17 +204,19 @@ const WorkflowDetail = () => {
style={{ paddingBottom: 0 }}
title={workflow.name}
extra={[
<Button.Group key="actions">
<WorkflowBaseInfoModalForm data={workflow} trigger={<Button>{t("common.button.edit")}</Button>} onFinish={handleBaseInfoFormFinish} />
<WorkflowBaseInfoModalForm key="edit" data={workflow} trigger={<Button>{t("common.button.edit")}</Button>} onFinish={handleBaseInfoFormFinish} />,
<Button onClick={handleEnableChange}>{workflow.enabled ? t("common.button.disable") : t("common.button.enable")}</Button>
<Button key="enable" onClick={handleEnableChange}>
{workflow.enabled ? t("workflow.action.disable") : t("workflow.action.enable")}
</Button>,
<Dropdown
key="more"
menu={{
items: [
{
key: "delete",
label: t("common.button.delete"),
label: t("workflow.action.delete"),
danger: true,
icon: <DeleteOutlinedIcon />,
onClick: () => {
@ -210,9 +227,10 @@ const WorkflowDetail = () => {
}}
trigger={["click"]}
>
<Button icon={<EllipsisOutlinedIcon />} />
</Dropdown>
</Button.Group>,
<Button icon={<DownOutlinedIcon />} iconPosition="end">
{t("common.button.more")}
</Button>
</Dropdown>,
]}
>
<Typography.Paragraph type="secondary">{workflow.description}</Typography.Paragraph>
@ -239,13 +257,13 @@ const WorkflowDetail = () => {
</div>
<div className="absolute top-0 right-0 z-[1]">
<Space>
<Button icon={<CaretRightOutlinedIcon />} loading={workflowRunning} type="primary" onClick={handleRunClick}>
{t("workflow.action.run")}
<Button disabled={!allowRun} icon={<CaretRightOutlinedIcon />} loading={workflowRunning} type="primary" onClick={handleRunClick}>
{t("workflow.detail.orchestration.action.run")}
</Button>
<Button.Group>
<Button color="primary" disabled={!allowRelease} variant="outlined" onClick={handleReleaseClick}>
{t("workflow.action.release")}
{t("workflow.detail.orchestration.action.release")}
</Button>
<Dropdown
@ -254,7 +272,7 @@ const WorkflowDetail = () => {
{
key: "discard",
disabled: !allowDiscard,
label: t("workflow.action.discard"),
label: t("workflow.detail.orchestration.action.discard"),
icon: <UndoOutlinedIcon />,
onClick: handleDiscardClick,
},

View File

@ -245,7 +245,7 @@ const WorkflowList = () => {
const handleEnabledChange = async (workflow: WorkflowModel) => {
try {
if (!workflow.enabled && !isAllNodesValidated(workflow.content!)) {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
messageApi.warning(t("workflow.action.enable.failed.uncompleted"));
return;
}

View File

@ -0,0 +1,123 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { PageHeader } from "@ant-design/pro-components";
import { Card, Col, Row, Spin, Typography, notification } from "antd";
import { sleep } from "radash";
import { type WorkflowModel, initWorkflow } from "@/domain/workflow";
import { save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error";
const TEMPLATE_KEY_BLANK = "blank" as const;
const TEMPLATE_KEY_STANDARD = "standard" as const;
type TemplateKeys = typeof TEMPLATE_KEY_BLANK | typeof TEMPLATE_KEY_STANDARD;
const WorkflowNew = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const templateGridSpans = {
xs: { flex: "100%" },
md: { flex: "100%" },
lg: { flex: "50%" },
xl: { flex: "50%" },
xxl: { flex: "50%" },
};
const [templateSelectKey, setTemplateSelectKey] = useState<TemplateKeys>();
const handleTemplateSelect = async (key: TemplateKeys) => {
if (templateSelectKey) return;
setTemplateSelectKey(key);
try {
let workflow: WorkflowModel;
switch (key) {
case TEMPLATE_KEY_BLANK:
workflow = initWorkflow();
break;
case TEMPLATE_KEY_STANDARD:
workflow = initWorkflow({ template: "standard" });
break;
default:
throw "Invalid args: `key`";
}
workflow = await saveWorkflow(workflow);
await sleep(500);
await navigate(`/workflows/${workflow.id}`, { replace: true });
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
setTemplateSelectKey(undefined);
}
};
return (
<div>
{NotificationContextHolder}
<Card styles={{ body: { padding: "0.5rem", paddingBottom: 0 } }}>
<PageHeader title={t("workflow.new.title")}>
<Typography.Paragraph type="secondary">{t("workflow.new.subtitle")}</Typography.Paragraph>
</PageHeader>
</Card>
<div className="p-4">
<div className="max-w-[960px] mx-auto px-2">
<Typography.Text type="secondary">
<div className="mt-4 mb-8 text-xl">{t("workflow.new.templates.title")}</div>
</Typography.Text>
<Row className="justify-stretch" gutter={[16, 16]}>
<Col {...templateGridSpans}>
<Card
className="size-full"
cover={<img className="min-h-[120px] object-contain" src="/imgs/workflow/tpl-standard.png" />}
hoverable
onClick={() => handleTemplateSelect(TEMPLATE_KEY_STANDARD)}
>
<div className="flex items-center gap-4 w-full">
<Card.Meta
className="flex-grow"
title={t("workflow.new.templates.template.standard.title")}
description={t("workflow.new.templates.template.standard.description")}
/>
<Spin spinning={templateSelectKey === TEMPLATE_KEY_STANDARD} />
</div>
</Card>
</Col>
<Col {...templateGridSpans}>
<Card
className="size-full"
cover={<img className="min-h-[120px] object-contain" src="/imgs/workflow/tpl-blank.png" />}
hoverable
onClick={() => handleTemplateSelect(TEMPLATE_KEY_BLANK)}
>
<div className="flex items-center gap-4 w-full">
<Card.Meta
className="flex-grow"
title={t("workflow.new.templates.template.blank.title")}
description={t("workflow.new.templates.template.blank.description")}
/>
<Spin spinning={templateSelectKey === TEMPLATE_KEY_BLANK} />
</div>
</Card>
</Col>
</Row>
</div>
</div>
</div>
);
};
export default WorkflowNew;

View File

@ -1,18 +1,16 @@
import { create } from "zustand";
import {
type WorkflowBranchNode,
type WorkflowModel,
type WorkflowNode,
addBranch,
addNode,
getExecuteMethod,
getWorkflowOutputBeforeId,
initWorkflow,
removeBranch,
removeNode,
updateNode,
type WorkflowBranchNode,
type WorkflowModel,
type WorkflowNode,
WorkflowNodeType,
} from "@/domain/workflow";
import { get as getWorkflow, save as saveWorkflow } from "@/repository/workflow";
@ -27,44 +25,29 @@ export type WorkflowState = {
getWorkflowOuptutBeforeId: (id: string, type: string) => WorkflowNode[];
switchEnable(): void;
save(): void;
init(id?: string): void;
init(id: string): void;
setBaseInfo: (name: string, description: string) => void;
};
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
workflow: {
id: "",
name: "",
type: WorkflowNodeType.Start,
} as WorkflowModel,
workflow: {} as WorkflowModel,
initialized: false,
init: async (id?: string) => {
let data = {
id: "",
name: "",
type: "auto",
} as WorkflowModel;
if (!id) {
data = initWorkflow();
} else {
data = await getWorkflow(id);
}
init: async (id: string) => {
const data = await getWorkflow(id);
set({
workflow: data,
initialized: true,
});
},
setBaseInfo: async (name: string, description: string) => {
const data: Record<string, string | boolean | WorkflowNode> = {
id: (get().workflow.id as string) ?? "",
name: name || "",
description: description || "",
};
if (!data.id) {
data.draft = get().workflow.draft as WorkflowNode;
}
const resp = await saveWorkflow(data);
set((state: WorkflowState) => {
return {
@ -77,17 +60,18 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
switchEnable: async () => {
const root = get().workflow.draft as WorkflowNode;
const root = get().workflow.content as WorkflowNode;
const executeMethod = getExecuteMethod(root);
const resp = await saveWorkflow({
id: (get().workflow.id as string) ?? "",
content: root,
enabled: !get().workflow.enabled,
hasDraft: false,
type: executeMethod.type,
crontab: executeMethod.crontab,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -95,13 +79,13 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
id: resp.id,
content: resp.content,
enabled: resp.enabled,
hasDraft: false,
type: resp.type,
crontab: resp.crontab,
},
};
});
},
save: async () => {
const root = get().workflow.draft as WorkflowNode;
const executeMethod = getExecuteMethod(root);
@ -112,6 +96,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
type: executeMethod.type,
crontab: executeMethod.crontab,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -125,6 +110,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
updateNode: async (node: WorkflowNode | WorkflowBranchNode) => {
const newRoot = updateNode(get().workflow.draft as WorkflowNode, node);
const resp = await saveWorkflow({
@ -132,6 +118,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
draft: newRoot,
hasDraft: true,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -143,6 +130,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
addNode: async (node: WorkflowNode | WorkflowBranchNode, preId: string) => {
const newRoot = addNode(get().workflow.draft as WorkflowNode, preId, node);
const resp = await saveWorkflow({
@ -150,6 +138,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
draft: newRoot,
hasDraft: true,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -161,6 +150,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
addBranch: async (branchId: string) => {
const newRoot = addBranch(get().workflow.draft as WorkflowNode, branchId);
const resp = await saveWorkflow({
@ -168,6 +158,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
draft: newRoot,
hasDraft: true,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -179,6 +170,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
removeBranch: async (branchId: string, index: number) => {
const newRoot = removeBranch(get().workflow.draft as WorkflowNode, branchId, index);
const resp = await saveWorkflow({
@ -186,6 +178,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
draft: newRoot,
hasDraft: true,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -197,6 +190,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
removeNode: async (nodeId: string) => {
const newRoot = removeNode(get().workflow.draft as WorkflowNode, nodeId);
const resp = await saveWorkflow({
@ -204,6 +198,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
draft: newRoot,
hasDraft: true,
});
set((state: WorkflowState) => {
return {
workflow: {
@ -215,6 +210,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
};
});
},
getWorkflowOuptutBeforeId: (id: string, type: string) => {
return getWorkflowOutputBeforeId(get().workflow.draft as WorkflowNode, id, type);
},