feat: import/export workflow graph

This commit is contained in:
Fu Diwei
2025-08-25 20:05:29 +08:00
parent 9d1dc8564f
commit 5f04277bad
23 changed files with 1081 additions and 407 deletions
-1
View File
@@ -273,7 +273,6 @@ func newInOutManager() InOutManager {
const (
stateIOTypeRef = "ref"
stateIOType = "recordRef"
)
const (
+346 -348
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -13,20 +13,20 @@
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3",
"@codemirror/legacy-modes": "^6.5.1",
"@flowgram.ai/document": "^0.4.0",
"@flowgram.ai/fixed-layout-editor": "^0.4.0",
"@flowgram.ai/minimap-plugin": "^0.4.0",
"@flowgram.ai/document": "0.4.3",
"@flowgram.ai/fixed-layout-editor": "0.4.3",
"@flowgram.ai/minimap-plugin": "0.4.3",
"@tabler/icons-react": "^3.34.1",
"@uiw/codemirror-extensions-basic-setup": "^4.25.1",
"@uiw/codemirror-theme-vscode": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1",
"ahooks": "^3.9.0",
"antd": "^5.27.0",
"ahooks": "^3.9.4",
"antd": "^5.27.1",
"antd-zod": "^7.0.0",
"clsx": "^2.1.1",
"cron-parser": "^5.3.0",
"file-saver": "^2.0.5",
"i18next": "^25.3.6",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"immer": "^10.1.1",
"nanoid": "^5.1.5",
@@ -35,14 +35,15 @@
"react": "^18.3.1",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-i18next": "^15.6.1",
"react-router-dom": "^7.8.1",
"react-i18next": "^15.7.2",
"react-router-dom": "^7.8.2",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.17",
"zustand": "^5.0.7"
"yaml": "^2.8.1",
"zod": "^4.1.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@eslint/js": "^9.34.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@types/file-saver": "^2.0.7",
@@ -53,10 +54,10 @@
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.33.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-better-tailwindcss": "^3.7.4",
"eslint-plugin-better-tailwindcss": "^3.7.5",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
@@ -65,7 +66,7 @@
"prettier": "^3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "^5.8.3",
"typescript-eslint": "^8.39.1",
"typescript-eslint": "^8.40.0",
"vite": "^6.3.5"
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ const ModalForm = <T extends NonNullable<unknown> = any>({
onOk={handleOkClick}
onCancel={handleCancelClick}
>
<div className="pt-4 pb-2">
<div className="py-3">
<Form className={className} style={style} {...mergedFormProps} form={formInst}>
{children}
</Form>
@@ -146,7 +146,7 @@ const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigge
const useDrawer = () => {
type DataType = AccessEditDrawerProps["data"];
const [data, setData] = useState<DataType>();
const [open, setOpen] = useState<boolean>(false);
const [open, setOpen] = useState(false);
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
@@ -65,7 +65,7 @@ const CertificateDetailDrawer = ({ afterClose, data, loading, trigger, ...props
const useDrawer = () => {
type DataType = CertificateDetailDrawerProps["data"];
const [data, setData] = useState<DataType>();
const [open, setOpen] = useState<boolean>(false);
const [open, setOpen] = useState(false);
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { useTranslation } from "react-i18next";
import { FlowNodeBaseType, FlowNodeSplitType } from "@flowgram.ai/fixed-layout-editor";
import { IconClipboard } from "@tabler/icons-react";
import { App, Button, Form, Radio, Tooltip } from "antd";
import { stringify as stringifyYaml } from "yaml";
import CodeInput from "@/components/CodeInput";
import { type WorkflowGraph, type WorkflowNode } from "@/domain/workflow";
import { getAllNodeRegistries } from "./designer/nodes";
export type WorkflowGraphExportBoxFormats = "json" | "yaml";
export interface WorkflowGraphExportBoxProps {
className?: string;
style?: React.CSSProperties;
data: WorkflowGraph;
}
const serialize = (graph: WorkflowGraph | undefined, format: WorkflowGraphExportBoxFormats): string | undefined => {
if (!graph) return;
const nodeRegistries = getAllNodeRegistries();
const deepConvert = (node: WorkflowNode): Map<string, unknown> => {
// 利用 Map 来保证字段序列化的有序性
const map = new Map<string, unknown>([
["id", node.id],
["type", node.type],
["name", node.data.name],
]);
if (node.data.disabled != null) {
if (node.data.disabled) {
map.set("disabled", node.data.disabled);
}
}
if (node.data.config != null) {
map.set("config", node.data.config);
}
if (node.blocks != null) {
const branchLikeNodeTypes = [
FlowNodeBaseType.BLOCK,
FlowNodeSplitType.SIMPLE_SPLIT,
FlowNodeSplitType.DYNAMIC_SPLIT,
FlowNodeSplitType.STATIC_SPLIT,
] as string[];
const hasChildren = node.blocks.length > 0 || branchLikeNodeTypes.includes(nodeRegistries.find((r) => r.type === node.type)?.extend ?? "");
if (hasChildren) {
const childern = node.blocks.map((block) => deepConvert(block));
map.set("blocks", childern);
}
}
Object.entries(node.data).forEach(([k, v]) => {
if (k === "name" || k === "disabled" || k === "config") return;
map.set(k, v);
});
return map;
};
const nodes = graph.nodes.map((node) => deepConvert(node));
let content: string = "";
switch (format) {
case "json":
content = JSON.stringify(
{ ...graph, nodes },
(_, value) => {
if (value instanceof Map) {
return Object.fromEntries(value.entries());
} else {
return value;
}
},
2
);
break;
case "yaml":
content = stringifyYaml(
{ ...graph, nodes },
{
indent: 2,
defaultKeyType: "PLAIN",
defaultStringType: "QUOTE_DOUBLE",
}
);
break;
}
return content;
};
const WorkflowGraphExportBox = ({ className, style, data }: WorkflowGraphExportBoxProps) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [format, setFormat] = useState<WorkflowGraphExportBoxFormats>("yaml");
const [content, setContent] = useState<string>();
useEffect(() => {
setContent(serialize(data, format));
}, [data, format]);
return (
<Form className={className} style={style} layout="vertical">
<Form.Item className="mb-4" label={t("workflow.detail.design.action.export.form.format.label")}>
<Radio.Group className="w-full" value={format} onChange={(e) => setFormat(e.target.value)}>
<Radio.Button className="w-1/2 text-center" value="yaml">
YAML
</Radio.Button>
<Radio.Button className="w-1/2 text-center" value="json">
JSON
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label={t("workflow.detail.design.action.export.form.content.label")}>
<div className="absolute -top-[6px] right-0 -translate-y-full">
<Tooltip title={t("common.button.copy")}>
<CopyToClipboard
text={content!}
onCopy={() => {
message.success(t("common.text.copied"));
}}
>
<Button icon={<IconClipboard size="1.25em" />} disabled={!content} size="small" type="text" />
</CopyToClipboard>
</Tooltip>
</div>
<CodeInput height="calc(min(60vh, 512px))" language={format} value={content} readOnly />
</Form.Item>
</Form>
);
};
export default WorkflowGraphExportBox;
@@ -0,0 +1,95 @@
import { startTransition, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Modal } from "antd";
import { type WorkflowGraph } from "@/domain/workflow";
import { useTriggerElement } from "@/hooks";
import WorkflowGraphExportBox from "./WorkflowGraphExportBox";
export interface WorkflowGraphExportModalProps {
afterClose?: () => void;
data: WorkflowGraph;
loading?: boolean;
open?: boolean;
trigger?: React.ReactNode;
onOpenChange?: (open: boolean) => void;
}
const WorkflowGraphExportModal = ({ afterClose, data, loading, trigger, ...props }: WorkflowGraphExportModalProps) => {
const { t } = useTranslation();
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) });
const handleCancelClick = () => {
setOpen(false);
};
return (
<>
{triggerEl}
<Modal
afterClose={afterClose}
closable
destroyOnHidden
footer={null}
loading={loading}
open={open}
title={t("workflow.detail.design.action.export.modal.title")}
width="768px"
onCancel={handleCancelClick}
>
<div className="py-3 pb-0">
<WorkflowGraphExportBox data={data} />
</div>
</Modal>
</>
);
};
const useModal = () => {
type DataType = WorkflowGraphExportModalProps["data"];
const [data, setData] = useState<DataType>();
const [open, setOpen] = useState(false);
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
}, []);
return {
modalProps: {
afterClose: () => {
startTransition(() => {
if (!open) {
setData(void 0);
}
});
},
data: data!,
open,
onOpenChange,
},
open: (data: DataType) => {
setData(data);
setOpen(true);
},
close: () => {
setOpen(false);
},
};
};
const _default = Object.assign(WorkflowGraphExportModal, {
useModal,
});
export default _default;
@@ -0,0 +1,249 @@
import { forwardRef, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import { Form, Radio } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { parse as parseYaml } from "yaml";
import { z } from "zod";
import CodeInput from "@/components/CodeInput";
import { WORKFLOW_NODE_TYPES, type WorkflowGraph, type WorkflowNode, type WorkflowNodeType } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
export type WorkflowGraphImportInputBoxFormats = "json" | "yaml";
export interface WorkflowGraphImportInputBoxProps {
className?: string;
style?: React.CSSProperties;
}
export interface WorkflowGraphImportInputBoxInstance {
validate: () => Promise<WorkflowGraph | undefined>;
}
const deserialize = (content: string | undefined, format: WorkflowGraphImportInputBoxFormats): WorkflowGraph | undefined => {
if (!content?.trim()) return;
let temp: any;
switch (format) {
case "json":
temp = JSON.parse(content);
break;
case "yaml":
temp = parseYaml(content);
break;
}
const deepParse = (item: any): WorkflowNode => {
item = item ?? {};
const node: WorkflowNode = {
id: item.id?.toString() ?? "",
type: (item.type?.toString() ?? "") as WorkflowNodeType,
data: {
name: item.name?.toString() ?? "",
disabled: item.disabled === true || item.disabled === "true",
config: item.config,
},
blocks: Array.isArray(item.blocks) ? item.blocks.map((block: any) => deepParse(block)) : [],
};
if (item.data != null) {
Object.entries(item.data).forEach(([k, v]) => {
if (k === "id" || k === "type" || k === "meta" || k === "blocks") return;
if (k === "name" || k === "disabled" || k === "config") return;
node.data[k] = v;
});
}
return node;
};
const nodes = Array.from(temp.nodes ?? []).map((item) => deepParse(item));
return { nodes: nodes };
};
const WorkflowGraphImportInputBox = forwardRef<WorkflowGraphImportInputBoxInstance, WorkflowGraphImportInputBoxProps>(({ className, style }, ref) => {
const { t } = useTranslation();
const formSchema = z
.object({
format: z.enum(["json", "yaml"]),
content: z.string().refine((v) => !!v?.trim(), t("workflow.detail.design.action.import.form.content.errmsg.invalid")),
})
.superRefine((values, ctx) => {
let graph: WorkflowGraph | undefined;
try {
graph = deserialize(values.content, values.format);
} catch {
ctx.addIssue({
code: "custom",
message: t("workflow.detail.design.action.import.form.content.errmsg.invalid"),
path: ["content"],
});
return;
}
if (graph) {
const errmsgs: string[] = [];
if (graph.nodes.at(0)?.type !== WORKFLOW_NODE_TYPES.START) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.first_node_start"));
}
if (graph.nodes.at(-1)?.type !== WORKFLOW_NODE_TYPES.END) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.last_node_end"));
}
let startNodeId: string | undefined;
let startNodeDuplicated: boolean = false;
const nodeIds = new Set<string>();
const deepValidate = (node: WorkflowNode) => {
// 验证字段:ID
if (!/^(?![_-])[a-zA-Z0-9_-]{1,32}$/.test(node.id)) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.invalid_id", { nodeId: node.id }));
}
// 验证字段:配置项
if (node.data.config != null) {
if (typeof node.data.config !== "object" || Array.isArray(node.data.config)) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.invalid_config", { nodeId: node.id }));
}
}
// 验证节点 ID 是否冲突
if (nodeIds.has(node.id)) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.conflict_id", { nodeId: node.id }));
} else {
nodeIds.add(node.id);
}
// 验证开始节点是否重复
if (node.type === WORKFLOW_NODE_TYPES.START) {
if (startNodeId) {
if (!startNodeDuplicated) {
startNodeDuplicated = true;
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.duplicate_start"));
}
} else {
startNodeId = node.id;
}
}
// 验证 Condition 分支结构
if (node.type === WORKFLOW_NODE_TYPES.CONDITION) {
const blocks = node.blocks ?? [];
const f1 = blocks.length > 0;
const f2 = blocks.every((block) => block.type === WORKFLOW_NODE_TYPES.BRANCHBLOCK);
if (!f1 || !f2) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.abnormal_condition_branch", { nodeId: node.id }));
}
} else if (node.type === WORKFLOW_NODE_TYPES.BRANCHBLOCK) {
const blocks = node.blocks ?? [];
const f1 = blocks.length > 0;
const f2 = blocks.every((block) => block.type !== WORKFLOW_NODE_TYPES.BRANCHBLOCK);
if (!f1 || !f2) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.abnormal_condition_branch", { nodeId: node.id }));
}
}
// 验证 TryCatch 分支结构
if (node.type === WORKFLOW_NODE_TYPES.TRYCATCH) {
const blocks = node.blocks ?? [];
const f1 = blocks.length >= 2;
const f2 = blocks[0]?.type === WORKFLOW_NODE_TYPES.TRYBLOCK;
const f3 = blocks.some((block) => block.type === WORKFLOW_NODE_TYPES.CATCHBLOCK);
if (!f1 || !f2 || !f3) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.abnormal_try_catch_branch", { nodeId: node.id }));
}
} else if (node.type === WORKFLOW_NODE_TYPES.TRYBLOCK || node.type === WORKFLOW_NODE_TYPES.CATCHBLOCK) {
const blocks = node.blocks ?? [];
const f1 = blocks.length > 0;
const f2 = blocks.every((block) => block.type !== WORKFLOW_NODE_TYPES.TRYBLOCK);
const f3 = blocks.every((block) => block.type !== WORKFLOW_NODE_TYPES.CATCHBLOCK);
if (!f1 || !f2 || !f3) {
errmsgs.push(t("workflow.detail.design.action.import.form.content.errmsg.abnormal_try_catch_branch", { nodeId: node.id }));
}
}
// 验证子节点
if (Array.isArray(node.blocks)) {
node.blocks.forEach((block) => deepValidate(block));
}
};
graph.nodes.forEach((node) => deepValidate(node));
const MAX_ISSUE_COUNT = 5;
for (let i = 0; i < Math.min(MAX_ISSUE_COUNT, errmsgs.length); i++) {
ctx.addIssue({
code: "custom",
message: errmsgs[i],
path: ["content"],
});
}
}
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm<z.infer<typeof formSchema>>({
name: "workflowGraphExportInputBoxForm",
initialValues: {
format: "yaml",
content: "",
},
});
const fieldFormat = Form.useWatch<WorkflowGraphImportInputBoxFormats>("format", formInst);
const fieldContent = Form.useWatch<string>("content", formInst);
const handleFormatChange = (format: WorkflowGraphImportInputBoxFormats) => {
formInst.setFieldValue("format", format);
switch (format) {
case "json":
try {
JSON.parse(fieldContent);
} catch {
formInst.setFieldValue("content", "");
}
break;
case "yaml":
try {
parseYaml(fieldContent);
} catch {
formInst.setFieldValue("content", "");
}
break;
}
};
useImperativeHandle(ref, () => {
return {
validate: async () => {
const formValues = await formInst.validateFields();
return deserialize(formValues.content, formValues.format);
},
};
});
return (
<Form className={className} style={style} {...formProps} clearOnDestroy={true} form={formInst} layout="vertical" preserve={false} scrollToFirstError>
<Form.Item className="mb-4" name="format" label={t("workflow.detail.design.action.import.form.format.label")} rules={[formRule]}>
<Radio.Group className="w-full" onChange={(e) => handleFormatChange(e.target.value)}>
<Radio.Button className="w-1/2 text-center" value="yaml">
YAML
</Radio.Button>
<Radio.Button className="w-1/2 text-center" value="json">
JSON
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name="content" label={t("workflow.detail.design.action.import.form.content.label")} rules={[formRule]}>
<CodeInput height="calc(min(60vh, 512px))" language={fieldFormat} value={fieldContent} />
</Form.Item>
</Form>
);
});
export default WorkflowGraphImportInputBox;
@@ -0,0 +1,119 @@
import { startTransition, useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Button, Flex, Modal } from "antd";
import { type WorkflowGraph } from "@/domain/workflow";
import { useTriggerElement } from "@/hooks";
import WorkflowImportExportForm, { type WorkflowGraphImportInputBoxInstance } from "./WorkflowGraphImportInputBox";
export interface WorkflowGraphImportModalProps {
afterClose?: () => void;
open?: boolean;
trigger?: React.ReactNode;
onCancel?: () => void;
onOk?: (graph: WorkflowGraph) => void;
onOpenChange?: (open: boolean) => void;
}
const WorkflowGraphImportModal = ({ afterClose, trigger, onCancel, onOk, ...props }: WorkflowGraphImportModalProps) => {
const { t } = useTranslation();
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) });
const graphInputBoxRef = useRef<WorkflowGraphImportInputBoxInstance>(null);
const handleCancelClick = () => {
setOpen(false);
onCancel?.();
};
const handleOkClick = async () => {
const graph = await graphInputBoxRef.current!.validate();
setOpen(false);
if (graph != null) {
onOk?.(graph);
}
};
return (
<>
{triggerEl}
<Modal
afterClose={afterClose}
closable
destroyOnHidden
footer={
<Flex className="px-2" justify="end" gap="small">
<Button onClick={handleCancelClick}>{t("common.button.cancel")}</Button>
<Button type="primary" onClick={handleOkClick}>
{t("workflow.detail.design.action.import.modal.ok_button")}
</Button>
</Flex>
}
open={open}
title={t("workflow.detail.design.action.import.modal.title")}
width="768px"
onCancel={handleCancelClick}
>
<div className="py-3">
<WorkflowImportExportForm ref={graphInputBoxRef} />
</div>
</Modal>
</>
);
};
const useModal = () => {
const [open, setOpen] = useState(false);
const [onOkHandler, setOnOkHandler] = useState<{ handler: WorkflowGraphImportModalProps["onOk"] }>();
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
}, []);
return {
modalProps: {
afterClose: () => {
startTransition(() => {
if (!open) {
setOnOkHandler(void 0);
}
});
},
open,
onOk: (graph: WorkflowGraph) => {
onOkHandler?.handler?.(graph);
},
onOpenChange,
},
open: () => {
setOpen(true);
const { promise, resolve } = Promise.withResolvers<WorkflowGraph>();
setOnOkHandler({ handler: (graph) => resolve(graph) });
return promise;
},
close: () => {
setOpen(false);
},
};
};
const _default = Object.assign(WorkflowGraphImportModal, {
useModal,
});
export default _default;
@@ -65,7 +65,7 @@ const WorkflowRunDetailDrawer = ({ afterClose, data, loading, trigger, ...props
const useDrawer = () => {
type DataType = WorkflowRunDetailDrawerProps["data"];
const [data, setData] = useState<DataType>();
const [open, setOpen] = useState<boolean>(false);
const [open, setOpen] = useState(false);
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
@@ -75,7 +75,7 @@ const NodeDrawer = ({ node, trigger, ...props }: NodeDrawerProps) => {
const useDrawer = () => {
type NodeDataType = NodeDrawerProps["node"];
const [node, setNode] = useState<NodeDataType>();
const [open, setOpen] = useState<boolean>(false);
const [open, setOpen] = useState(false);
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
@@ -21,7 +21,7 @@ import { useNodeFormContext } from "./_context";
import { getAllPreviousNodes } from "../_util";
import { NodeType } from "../nodes/typings";
export interface BranchBlockNodeConfigFormExpressionEditorProps {
export interface BranchBlockNodeConfigExprInputBoxProps {
className?: string;
style?: React.CSSProperties;
defaultValue?: Expr;
@@ -29,8 +29,8 @@ export interface BranchBlockNodeConfigFormExpressionEditorProps {
onChange?: (value: Expr) => void;
}
export interface BranchBlockNodeConfigFormExpressionEditorInstance {
validate: () => Promise<void>;
export interface BranchBlockNodeConfigExprInputBoxInstance {
validate: () => Promise<Expr | undefined>;
}
// 表单内部使用的扁平结构
@@ -50,7 +50,7 @@ type ConditionFormValues = {
logicalOperator: ExprLogicalOperator;
};
const exprToFormValues = (expr?: Expr): ConditionFormValues => {
const exprToFormValues = (expr: Expr | undefined): ConditionFormValues => {
if (!expr) return getInitialValues();
const conditions: ConditionItem[] = [];
@@ -138,7 +138,7 @@ const formValuesToExpr = (values: ConditionFormValues): Expr | undefined => {
return expr;
};
const BranchBlockNodeConfigFormExpressionEditor = forwardRef<BranchBlockNodeConfigFormExpressionEditorInstance, BranchBlockNodeConfigFormExpressionEditorProps>(
const BranchBlockNodeConfigExprInputBox = forwardRef<BranchBlockNodeConfigExprInputBoxInstance, BranchBlockNodeConfigExprInputBoxProps>(
({ className, style, ...props }, ref) => {
const { t } = useTranslation();
@@ -153,7 +153,7 @@ const BranchBlockNodeConfigFormExpressionEditor = forwardRef<BranchBlockNodeConf
const { node } = useNodeFormContext();
const [formInst] = Form.useForm<ConditionFormValues>();
const formName = useAntdFormName({ form: formInst, name: "workflowNodeBranchBlockConfigFormExpressionEditorForm" });
const formName = useAntdFormName({ form: formInst, name: "workflowNodeBranchBlockConfigExprInputBoxForm" });
const [formModel, setFormModel] = useState<ConditionFormValues>(getInitialValues());
useEffect(() => {
@@ -253,8 +253,9 @@ const BranchBlockNodeConfigFormExpressionEditor = forwardRef<BranchBlockNodeConf
return {
validate: async () => {
await formInst.validateFields();
return formValuesToExpr(formInst.getFieldsValue());
},
} as BranchBlockNodeConfigFormExpressionEditorInstance;
};
});
return (
@@ -383,4 +384,4 @@ const getInitialValues = (): ConditionFormValues => {
};
};
export default BranchBlockNodeConfigFormExpressionEditor;
export default BranchBlockNodeConfigExprInputBox;
@@ -18,7 +18,7 @@ import {
import { useAntdForm } from "@/hooks";
import { NodeFormContextProvider } from "./_context";
import BranchBlockNodeConfigFormExpressionEditor, { type BranchBlockNodeConfigFormExpressionEditorInstance } from "./BranchBlockNodeConfigFormExpressionEditor";
import BranchBlockNodeConfigExprInputBox, { type BranchBlockNodeConfigExprInputBoxInstance } from "./BranchBlockNodeConfigExprInputBox";
import { NodeType } from "../nodes/typings";
@@ -41,7 +41,7 @@ const BranchBlockNodeConfigForm = ({ node, ...props }: BranchBlockNodeConfigForm
const formSchema = getSchema({ i18n }).superRefine(async (values, ctx) => {
if (values.expression != null) {
try {
await exprEditorRef.current!.validate();
await exprInputBoxRef.current!.validate();
} catch {
if (!ctx.issues.some((issue) => issue.path?.[0] === "expression")) {
ctx.addIssue({
@@ -60,14 +60,14 @@ const BranchBlockNodeConfigForm = ({ node, ...props }: BranchBlockNodeConfigForm
initialValues: initialValues ?? getInitialValues(),
});
const exprEditorRef = useRef<BranchBlockNodeConfigFormExpressionEditorInstance>(null);
const exprInputBoxRef = useRef<BranchBlockNodeConfigExprInputBoxInstance>(null);
return (
<NodeFormContextProvider value={{ node }}>
<Form {...formProps} clearOnDestroy={true} form={formInst} layout="vertical" preserve={false} scrollToFirstError>
<div id="parameters" data-anchor="parameters">
<Form.Item name="expression" label={t("workflow_node.branch_block.form.expression.label")} rules={[formRule]}>
<BranchBlockNodeConfigFormExpressionEditor ref={exprEditorRef} />
<BranchBlockNodeConfigExprInputBox ref={exprInputBoxRef} />
</Form.Item>
</div>
</Form>
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { type FlowNodeEntity, getNodeForm, useClientContext, useRefresh, useWatchFormValueIn } from "@flowgram.ai/fixed-layout-editor";
import { type FlowNodeEntity, getNodeForm, useClientContext, useRefresh } from "@flowgram.ai/fixed-layout-editor";
import { IconEye, IconEyeOff, IconX } from "@tabler/icons-react";
import { useControllableValue } from "ahooks";
import { Anchor, type AnchorProps, App, Button, Drawer, Flex, type FormInstance, Tooltip, Typography } from "antd";
@@ -41,7 +41,7 @@ export const NodeConfigDrawer = ({ children, afterClose, anchor, footer = true,
const containerRef = useRef<HTMLDivElement>(null);
const [formPending, setFormPending] = useState<boolean>(false);
const [formPending, setFormPending] = useState(false);
const nodeRegistry = node?.getNodeRegistry<NodeRegistry>();
const NodeIcon = nodeRegistry?.meta?.icon;
@@ -134,13 +134,12 @@ export const NodeConfigDrawer = ({ children, afterClose, anchor, footer = true,
const newValues = picker(formInst.getFieldsValue(true));
const changed = !isEqual(oldValues, {}) && !isEqual(oldValues, newValues);
const { promise, resolve, reject } = Promise.withResolvers();
const { promise, resolve } = Promise.withResolvers();
if (changed) {
modal.confirm({
title: t("common.text.operation_confirm"),
content: t("workflow.detail.design.unsaved_changes.confirm"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
} else {
resolve(void 0);
+1
View File
@@ -59,6 +59,7 @@ export type WorkflowNode = {
name?: string;
disabled?: boolean;
config?: Record<string, unknown>;
[key: string]: unknown;
};
blocks?: WorkflowNode[];
};
+3 -1
View File
@@ -1,9 +1,10 @@
{
"common.button.add": "Add",
"common.button.cancel": "Cancel",
"common.button.close": "Close",
"common.button.confirm": "Confirm",
"common.button.create": "Create",
"common.button.copy": "Copy",
"common.button.create": "Create",
"common.button.delete": "Delete",
"common.button.download": "Download",
"common.button.edit": "Edit",
@@ -23,6 +24,7 @@
"common.text.operation_succeeded": "Operation succeeded",
"common.text.operation_failed": "Operation failed",
"common.text.request_error": "Request error",
"common.text.saving": "Saving ...",
"common.text.search": "Search ...",
"common.menu.document": "Document",
+18
View File
@@ -82,6 +82,24 @@
"workflow.detail.design.action.rollback.button": "Rollback",
"workflow.detail.design.action.rollback.modal.title": "Rollback changes",
"workflow.detail.design.action.rollback.modal.content": "Are you sure to rollback your changes to the last published version?",
"workflow.detail.design.action.import.button": "Import",
"workflow.detail.design.action.import.modal.title": "Import workflow",
"workflow.detail.design.action.import.modal.ok_button": "Import",
"workflow.detail.design.action.import.form.format.label": "Format",
"workflow.detail.design.action.import.form.content.label": "Content",
"workflow.detail.design.action.import.form.content.errmsg.invalid": "Please enter a valid content",
"workflow.detail.design.action.import.form.content.errmsg.first_node_start": "The first node must be a start node",
"workflow.detail.design.action.import.form.content.errmsg.last_node_end": "The last node must be an end node",
"workflow.detail.design.action.import.form.content.errmsg.duplicate_start": "The start node must be unique",
"workflow.detail.design.action.import.form.content.errmsg.invalid_id": "Node ID invalid: #{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.invalid_config": "Node config invalid: #{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.conflict_id": "Node ID conflict: #{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.abnormal_condition_branch": "Abnormal parallel/conditional branch: #{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.abnormal_try_catch_branch": "Abnormal execution result branch: #{{nodeId}}",
"workflow.detail.design.action.export.button": "Export",
"workflow.detail.design.action.export.modal.title": "Export workflow",
"workflow.detail.design.action.export.form.format.label": "Format",
"workflow.detail.design.action.export.form.content.label": "Content",
"workflow.detail.design.uncompleted_design.alert": "The orchestration is not ready. Please check if there are any nodes not configured.",
"workflow.detail.design.unpublished_draft.alert": "The orchestration is not released yet.",
"workflow.detail.design.unsaved_changes.confirm": "You have unsaved changes. Do you really want to close the panel and drop those changes?",
+3 -1
View File
@@ -1,9 +1,10 @@
{
"common.button.add": "新增",
"common.button.cancel": "取消",
"common.button.close": "关闭",
"common.button.confirm": "确定",
"common.button.create": "新建",
"common.button.copy": "复制",
"common.button.create": "新建",
"common.button.delete": "刪除",
"common.button.download": "下载",
"common.button.edit": "编辑",
@@ -23,6 +24,7 @@
"common.text.operation_succeeded": "操作成功",
"common.text.operation_failed": "操作失败",
"common.text.request_error": "请求错误",
"common.text.saving": "保存中 ……",
"common.text.search": "搜索 ……",
"common.menu.document": "文档",
+18
View File
@@ -82,6 +82,24 @@
"workflow.detail.design.action.rollback.button": "回退更改",
"workflow.detail.design.action.rollback.modal.title": "回退更改",
"workflow.detail.design.action.rollback.modal.content": "确定要回退到最近一次发布的版本吗?",
"workflow.detail.design.action.import.button": "导入",
"workflow.detail.design.action.import.modal.title": "导入工作流",
"workflow.detail.design.action.import.modal.ok_button": "导入",
"workflow.detail.design.action.import.form.format.label": "格式",
"workflow.detail.design.action.import.form.content.label": "内容",
"workflow.detail.design.action.import.form.content.errmsg.invalid": "请输入有效的内容",
"workflow.detail.design.action.import.form.content.errmsg.first_node_start": "第一个节点必须是开始节点",
"workflow.detail.design.action.import.form.content.errmsg.last_node_end": "最后一个节点必须是结束节点",
"workflow.detail.design.action.import.form.content.errmsg.duplicate_start": "有且只能有一个开始节点",
"workflow.detail.design.action.import.form.content.errmsg.invalid_id": "节点 ID 无效:#{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.invalid_config": "节点配置无效:#{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.conflict_id": "节点 ID 冲突:#{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.abnormal_condition_branch": "并行/条件分支结构异常:#{{nodeId}}",
"workflow.detail.design.action.import.form.content.errmsg.abnormal_try_catch_branch": "执行结果分支结构异常:#{{nodeId}}",
"workflow.detail.design.action.export.button": "导出",
"workflow.detail.design.action.export.modal.title": "导出工作流",
"workflow.detail.design.action.export.form.format.label": "格式",
"workflow.detail.design.action.export.form.content.label": "内容",
"workflow.detail.design.uncompleted_design.alert": "流程编排未就绪,请检查是否有节点未完成配置。",
"workflow.detail.design.unpublished_draft.alert": "当前编排有尚未发布的更改。",
"workflow.detail.design.unsaved_changes.confirm": "你有尚未保存的更改。确定要关闭面板吗?",
+1 -2
View File
@@ -65,13 +65,12 @@ const WorkflowDetail = () => {
}, [workflow.lastRunStatus]);
const handleRunClick = () => {
const { promise, resolve, reject } = Promise.withResolvers();
const { promise, resolve } = Promise.withResolvers();
if (workflow.hasDraft) {
modal.confirm({
title: t("workflow.action.run.modal.title"),
content: t("workflow.action.run.modal.content"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
} else {
resolve(void 0);
@@ -1,12 +1,14 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowBackUp, IconDots } from "@tabler/icons-react";
import { IconArrowBackUp, IconDots, IconTransferIn, IconTransferOut } from "@tabler/icons-react";
import { useDeepCompareEffect } from "ahooks";
import { Alert, App, Button, Card, Dropdown, Result, Space, theme } from "antd";
import { debounce } from "radash";
import Show from "@/components/Show";
import { WorkflowDesigner, type WorkflowDesignerInstance, WorkflowNodeDrawer, WorkflowToolbar } from "@/components/workflow/designer";
import WorkflowGraphExportModal from "@/components/workflow/WorkflowGraphExportModal";
import WorkflowGraphImportModal from "@/components/workflow/WorkflowGraphImportModal";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
import { getErrMsg } from "@/utils/error";
@@ -93,8 +95,33 @@ const WorkflowDetailDesign = () => {
});
};
const handleImportClick = async () => {
graphImportModal.open().then(async (graph) => {
const loadingKey = Math.random().toString(36).substring(0, 8);
message.loading({ key: loadingKey, content: t("common.text.saving"), duration: 0 });
try {
await workflowStore.orchestrate(graph);
message.destroy(loadingKey);
message.success(t("common.text.operation_succeeded"));
} catch (err) {
console.error(err);
message.destroy(loadingKey);
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
});
};
const handleExportClick = () => {
graphExportModal.open(workflow.graphDraft!);
};
const { drawerProps: designerNodeDrawerProps, ...designerNodeDrawer } = WorkflowNodeDrawer.useDrawer();
const { modalProps: graphImportModalProps, ...graphImportModal } = WorkflowGraphImportModal.useModal();
const { modalProps: graphExportModalProps, ...graphExportModal } = WorkflowGraphExportModal.useModal();
return (
<div className="size-full">
<Card
@@ -131,6 +158,21 @@ const WorkflowDetailDesign = () => {
icon: <IconArrowBackUp size="1.25em" />,
onClick: handleRollbackClick,
},
{
type: "divider",
},
{
key: "import",
label: t("workflow.detail.design.action.import.button"),
icon: <IconTransferIn size="1.25em" />,
onClick: handleImportClick,
},
{
key: "export",
label: t("workflow.detail.design.action.export.button"),
icon: <IconTransferOut size="1.25em" />,
onClick: handleExportClick,
},
],
}}
trigger={["click"]}
@@ -164,6 +206,9 @@ const WorkflowDetailDesign = () => {
<WorkflowNodeDrawer {...designerNodeDrawerProps} />
</WorkflowDesigner>
<WorkflowGraphImportModal {...graphImportModalProps} />
<WorkflowGraphExportModal {...graphExportModalProps} />
</Card>
</div>
);
+3 -18
View File
@@ -14,30 +14,15 @@ export const validDomainName = (value: string, { allowWildcard = false }: { allo
};
export const validEmailAddress = (value: string) => {
try {
z.email().parse(value);
return true;
} catch (_) {
return false;
}
return z.email().safeParse(value).success;
};
export const validIPv4Address = (value: string) => {
try {
z.ipv4().parse(value);
return true;
} catch (_) {
return false;
}
return z.ipv4().safeParse(value).success;
};
export const validIPv6Address = (value: string) => {
try {
z.ipv6().parse(value);
return true;
} catch (_) {
return false;
}
return z.ipv6().safeParse(value).success;
};
export const validHttpOrHttpsUrl = (value: string) => {