1
This commit is contained in:
parent
4b37225a50
commit
c1da34e46b
@ -0,0 +1,142 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Form, Input, Select, InputNumber, Switch } from 'antd';
|
||||
import { NodeType, JsonSchema, JsonSchemaProperty } from '../../service';
|
||||
|
||||
interface NodeConfigProps {
|
||||
nodeType: NodeType;
|
||||
form: any;
|
||||
}
|
||||
|
||||
const NodeConfig: React.FC<NodeConfigProps> = ({ nodeType, form }) => {
|
||||
// 解析配置模式
|
||||
const schema = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(nodeType.configSchema) as JsonSchema;
|
||||
} catch (error) {
|
||||
console.error('解析配置模式失败:', error);
|
||||
return null;
|
||||
}
|
||||
}, [nodeType.configSchema]);
|
||||
|
||||
// 解析默认配置
|
||||
const defaultConfig = useMemo(() => {
|
||||
try {
|
||||
return nodeType.defaultConfig ? JSON.parse(nodeType.defaultConfig) : {};
|
||||
} catch (error) {
|
||||
console.error('解析默认配置失败:', error);
|
||||
return {};
|
||||
}
|
||||
}, [nodeType.defaultConfig]);
|
||||
|
||||
// 根据属性类型渲染表单控件
|
||||
const renderFormItem = (key: string, property: JsonSchemaProperty) => {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
minimum,
|
||||
maximum,
|
||||
minLength,
|
||||
maxLength,
|
||||
enum: enumValues,
|
||||
enumNames,
|
||||
pattern,
|
||||
format,
|
||||
} = property;
|
||||
|
||||
let formItem;
|
||||
const rules = [];
|
||||
|
||||
// 添加必填规则
|
||||
if (schema?.required?.includes(key)) {
|
||||
rules.push({ required: true, message: `请输入${title}` });
|
||||
}
|
||||
|
||||
// 添加长度限制
|
||||
if (minLength !== undefined) {
|
||||
rules.push({ min: minLength, message: `最少输入${minLength}个字符` });
|
||||
}
|
||||
if (maxLength !== undefined) {
|
||||
rules.push({ max: maxLength, message: `最多输入${maxLength}个字符` });
|
||||
}
|
||||
|
||||
// 添加数值范围限制
|
||||
if (minimum !== undefined) {
|
||||
rules.push({ min: minimum, message: `不能小于${minimum}` });
|
||||
}
|
||||
if (maximum !== undefined) {
|
||||
rules.push({ max: maximum, message: `不能大于${maximum}` });
|
||||
}
|
||||
|
||||
// 添加正则校验
|
||||
if (pattern) {
|
||||
rules.push({ pattern: new RegExp(pattern), message: `格式不正确` });
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
if (enumValues) {
|
||||
formItem = (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={`请选择${title}`}
|
||||
options={enumValues.map((value, index) => ({
|
||||
label: enumNames?.[index] || value,
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
} else if (format === 'shell') {
|
||||
formItem = <Input.TextArea rows={6} placeholder={`请输入${title}`} />;
|
||||
} else {
|
||||
formItem = <Input placeholder={`请输入${title}`} />;
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
formItem = (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={`请输入${title}`}
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'boolean':
|
||||
formItem = <Switch checkedChildren="是" unCheckedChildren="否" />;
|
||||
break;
|
||||
default:
|
||||
formItem = <Input placeholder={`请输入${title}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
label={title}
|
||||
name={key}
|
||||
tooltip={description}
|
||||
rules={rules}
|
||||
initialValue={defaultConfig[key] ?? property.default}
|
||||
>
|
||||
{formItem}
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染表单项
|
||||
const renderFormItems = () => {
|
||||
if (!schema?.properties) return null;
|
||||
|
||||
return Object.entries(schema.properties).map(([key, property]) =>
|
||||
renderFormItem(key, property)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="node-config">
|
||||
{renderFormItems()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeConfig;
|
||||
@ -0,0 +1,54 @@
|
||||
.workflow-toolbar {
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
color: #d9d9d9;
|
||||
background: transparent;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: #d9d9d9;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&-dangerous {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
color: #d9d9d9;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: #d9d9d9;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-divider {
|
||||
height: 16px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { Space, Button, Tooltip, Divider } from 'antd';
|
||||
import {
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
FullscreenOutlined,
|
||||
OneToOneOutlined,
|
||||
SelectOutlined,
|
||||
DeleteOutlined,
|
||||
UndoOutlined,
|
||||
RedoOutlined,
|
||||
CopyOutlined,
|
||||
SnippetsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Graph } from '@antv/x6';
|
||||
import './index.less';
|
||||
|
||||
interface ToolbarProps {
|
||||
graph: Graph | undefined;
|
||||
}
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({ graph }) => {
|
||||
// 缩放画布
|
||||
const zoom = (delta: number) => {
|
||||
if (!graph) return;
|
||||
const zoom = graph.zoom();
|
||||
graph.zoom(zoom + delta);
|
||||
};
|
||||
|
||||
// 适应画布
|
||||
const fitContent = () => {
|
||||
if (!graph) return;
|
||||
graph.zoomToFit({ padding: 20 });
|
||||
};
|
||||
|
||||
// 实际大小
|
||||
const resetZoom = () => {
|
||||
if (!graph) return;
|
||||
graph.scale(1);
|
||||
graph.centerContent();
|
||||
};
|
||||
|
||||
// 全选
|
||||
const selectAll = () => {
|
||||
if (!graph) return;
|
||||
const nodes = graph.getNodes();
|
||||
const edges = graph.getEdges();
|
||||
graph.resetSelection();
|
||||
graph.select(nodes);
|
||||
graph.select(edges);
|
||||
};
|
||||
|
||||
// 删除选中
|
||||
const deleteSelected = () => {
|
||||
if (!graph) return;
|
||||
const cells = graph.getSelectedCells();
|
||||
graph.removeCells(cells);
|
||||
};
|
||||
|
||||
// 撤销
|
||||
const undo = () => {
|
||||
if (!graph) return;
|
||||
if (graph.canUndo()) {
|
||||
graph.undo();
|
||||
}
|
||||
};
|
||||
|
||||
// 重做
|
||||
const redo = () => {
|
||||
if (!graph) return;
|
||||
if (graph.canRedo()) {
|
||||
graph.redo();
|
||||
}
|
||||
};
|
||||
|
||||
// 复制
|
||||
const copy = () => {
|
||||
if (!graph) return;
|
||||
const cells = graph.getSelectedCells();
|
||||
if (cells.length > 0) {
|
||||
graph.copy(cells);
|
||||
}
|
||||
};
|
||||
|
||||
// 粘贴
|
||||
const paste = () => {
|
||||
if (!graph) return;
|
||||
if (!graph.isClipboardEmpty()) {
|
||||
const cells = graph.paste({ offset: 20 });
|
||||
graph.cleanSelection();
|
||||
graph.select(cells);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workflow-toolbar">
|
||||
<Space split={<Divider type="vertical" />}>
|
||||
<Space>
|
||||
<Tooltip title="放大">
|
||||
<Button icon={<ZoomInOutlined />} onClick={() => zoom(0.1)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小">
|
||||
<Button icon={<ZoomOutOutlined />} onClick={() => zoom(-0.1)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="适应画布">
|
||||
<Button icon={<FullscreenOutlined />} onClick={fitContent} />
|
||||
</Tooltip>
|
||||
<Tooltip title="实际大小">
|
||||
<Button icon={<OneToOneOutlined />} onClick={resetZoom} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Tooltip title="全选">
|
||||
<Button icon={<SelectOutlined />} onClick={selectAll} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={deleteSelected}
|
||||
danger
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Tooltip title="撤销">
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={undo}
|
||||
disabled={!graph?.canUndo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重做">
|
||||
<Button
|
||||
icon={<RedoOutlined />}
|
||||
onClick={redo}
|
||||
disabled={!graph?.canRedo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Tooltip title="复制">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={copy}
|
||||
disabled={!graph?.getSelectedCells().length}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="粘贴">
|
||||
<Button
|
||||
icon={<SnippetsOutlined />}
|
||||
onClick={paste}
|
||||
disabled={graph?.isClipboardEmpty()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
@ -1,17 +1,26 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate, useParams} from 'react-router-dom';
|
||||
import {Button, Card, Layout, message, Space, Spin} from 'antd';
|
||||
import {Button, Card, Layout, message, Space, Spin, Drawer, Form} from 'antd';
|
||||
import {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons';
|
||||
import {getDefinition, updateDefinition} from '../../service';
|
||||
import {WorkflowDefinition, WorkflowStatus} from '../../../Workflow/types';
|
||||
import {Graph} from '@antv/x6';
|
||||
import {Graph, Node, Cell} from '@antv/x6';
|
||||
import '@antv/x6-react-shape';
|
||||
import './index.module.less';
|
||||
import NodePanel from './components/NodePanel';
|
||||
import NodeConfig from './components/NodeConfig';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import { NodeType } from './service';
|
||||
|
||||
const {Sider, Content} = Layout;
|
||||
|
||||
interface NodeData {
|
||||
type: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
const FlowDesigner: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {id} = useParams<{ id: string }>();
|
||||
@ -20,6 +29,10 @@ const FlowDesigner: React.FC = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph>();
|
||||
const draggedNodeRef = useRef<NodeType>();
|
||||
const [configVisible, setConfigVisible] = useState(false);
|
||||
const [currentNode, setCurrentNode] = useState<Node>();
|
||||
const [currentNodeType, setCurrentNodeType] = useState<NodeType>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 初始化图形
|
||||
const initGraph = () => {
|
||||
@ -127,6 +140,26 @@ const FlowDesigner: React.FC = () => {
|
||||
// 监听画布拖拽事件
|
||||
containerRef.current.addEventListener('dragover', handleDragOver);
|
||||
containerRef.current.addEventListener('drop', handleDrop);
|
||||
|
||||
// 监听节点点击事件
|
||||
graph.on('node:click', ({node}: {node: Node}) => {
|
||||
setCurrentNode(node);
|
||||
const data = node.getData() as NodeData;
|
||||
if (data) {
|
||||
// 获取节点类型
|
||||
const nodeType = draggedNodeRef.current;
|
||||
if (nodeType && nodeType.code === data.type) {
|
||||
setCurrentNodeType(nodeType);
|
||||
}
|
||||
// 设置表单值
|
||||
form.setFieldsValue({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
...data.config,
|
||||
});
|
||||
setConfigVisible(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理拖拽移动
|
||||
@ -166,7 +199,7 @@ const FlowDesigner: React.FC = () => {
|
||||
y: position.y - 20, // 节点高度的一半,使节点中心对准鼠标
|
||||
width: 180,
|
||||
height: 40,
|
||||
label: nodeType.name,
|
||||
shape: 'rect',
|
||||
attrs: {
|
||||
body: {
|
||||
fill: '#fff',
|
||||
@ -176,6 +209,7 @@ const FlowDesigner: React.FC = () => {
|
||||
ry: 4,
|
||||
},
|
||||
label: {
|
||||
text: nodeType.name,
|
||||
fill: '#333',
|
||||
fontSize: 14,
|
||||
refX: 0.5,
|
||||
@ -224,14 +258,51 @@ const FlowDesigner: React.FC = () => {
|
||||
},
|
||||
data: {
|
||||
type: nodeType.code,
|
||||
name: nodeType.name,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
// 选中新创建的节点
|
||||
const cells = graphRef.current.getSelectedCells();
|
||||
cells.forEach(cell => cell.unselect());
|
||||
node.select();
|
||||
graphRef.current.getNodes().forEach(n => {
|
||||
n.setAttrByPath('body/strokeWidth', 1);
|
||||
});
|
||||
node.setAttrByPath('body/strokeWidth', 2);
|
||||
|
||||
// 打开配置抽屉
|
||||
setCurrentNode(node);
|
||||
setCurrentNodeType(nodeType);
|
||||
form.setFieldsValue({
|
||||
name: nodeType.name,
|
||||
});
|
||||
setConfigVisible(true);
|
||||
};
|
||||
|
||||
// 处理配置保存
|
||||
const handleConfigSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (currentNode) {
|
||||
const data = currentNode.getData() as NodeData;
|
||||
const { name, description, ...config } = values;
|
||||
|
||||
// 更新节点数据
|
||||
currentNode.setData({
|
||||
...data,
|
||||
name,
|
||||
description,
|
||||
config,
|
||||
});
|
||||
|
||||
// 更新节点标签
|
||||
currentNode.setAttrByPath('label/text', name);
|
||||
|
||||
message.success('配置保存成功');
|
||||
setConfigVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
}
|
||||
};
|
||||
|
||||
// 获取详情
|
||||
@ -286,6 +357,9 @@ const FlowDesigner: React.FC = () => {
|
||||
containerRef.current.removeEventListener('dragover', handleDragOver);
|
||||
containerRef.current.removeEventListener('drop', handleDrop);
|
||||
}
|
||||
if (graphRef.current) {
|
||||
graphRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, [detail, containerRef.current]);
|
||||
|
||||
@ -325,10 +399,32 @@ const FlowDesigner: React.FC = () => {
|
||||
<Sider width={280} className="workflow-designer-sider">
|
||||
<NodePanel onNodeDragStart={handleNodeDragStart}/>
|
||||
</Sider>
|
||||
<Content className="workflow-designer-content">
|
||||
<div ref={containerRef} className="workflow-designer-graph"/>
|
||||
</Content>
|
||||
<Layout>
|
||||
<Toolbar graph={graphRef.current} />
|
||||
<Content className="workflow-designer-content">
|
||||
<div ref={containerRef} className="workflow-designer-graph"/>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
<Drawer
|
||||
title="节点配置"
|
||||
width={400}
|
||||
open={configVisible}
|
||||
onClose={() => setConfigVisible(false)}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setConfigVisible(false)}>取消</Button>
|
||||
<Button type="primary" onClick={handleConfigSave}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{currentNodeType && (
|
||||
<NodeConfig nodeType={currentNodeType} form={form} />
|
||||
)}
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,26 +4,55 @@ export interface NodeExecutor {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
configSchema: Record<string, any>;
|
||||
configSchema: string;
|
||||
defaultConfig: string | null;
|
||||
}
|
||||
|
||||
export interface JsonSchemaProperty {
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: any;
|
||||
format?: string;
|
||||
pattern?: string;
|
||||
enum?: string[];
|
||||
enumNames?: string[];
|
||||
}
|
||||
|
||||
export interface JsonSchema {
|
||||
type: string;
|
||||
properties: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
export interface NodeType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: 'TASK' | 'EVENT' | 'GATEWAY';
|
||||
category: 'BASIC' | 'TASK' | 'EVENT' | 'GATEWAY';
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
color: string;
|
||||
executors: NodeExecutor[];
|
||||
configSchema: string;
|
||||
defaultConfig: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
version: number;
|
||||
deleted: boolean;
|
||||
createBy: string;
|
||||
updateBy: string;
|
||||
extraData: any;
|
||||
}
|
||||
|
||||
export interface NodeTypeQuery {
|
||||
enabled?: boolean;
|
||||
category?: 'TASK' | 'EVENT' | 'GATEWAY';
|
||||
category?: 'BASIC' | 'TASK' | 'EVENT' | 'GATEWAY';
|
||||
}
|
||||
|
||||
// 获取节点类型列表
|
||||
|
||||
Loading…
Reference in New Issue
Block a user