This commit is contained in:
戚辰先生 2024-12-05 21:40:59 +08:00
parent 4b37225a50
commit c1da34e46b
5 changed files with 497 additions and 12 deletions

View File

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

View File

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

View File

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

View File

@ -1,17 +1,26 @@
import React, {useEffect, useRef, useState} from 'react'; import React, {useEffect, useRef, useState} from 'react';
import {useNavigate, useParams} from 'react-router-dom'; 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 {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons';
import {getDefinition, updateDefinition} from '../../service'; import {getDefinition, updateDefinition} from '../../service';
import {WorkflowDefinition, WorkflowStatus} from '../../../Workflow/types'; import {WorkflowDefinition, WorkflowStatus} from '../../../Workflow/types';
import {Graph} from '@antv/x6'; import {Graph, Node, Cell} from '@antv/x6';
import '@antv/x6-react-shape'; import '@antv/x6-react-shape';
import './index.module.less'; import './index.module.less';
import NodePanel from './components/NodePanel'; import NodePanel from './components/NodePanel';
import NodeConfig from './components/NodeConfig';
import Toolbar from './components/Toolbar';
import { NodeType } from './service'; import { NodeType } from './service';
const {Sider, Content} = Layout; const {Sider, Content} = Layout;
interface NodeData {
type: string;
name?: string;
description?: string;
config: Record<string, any>;
}
const FlowDesigner: React.FC = () => { const FlowDesigner: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const {id} = useParams<{ id: string }>(); const {id} = useParams<{ id: string }>();
@ -20,6 +29,10 @@ const FlowDesigner: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph>(); const graphRef = useRef<Graph>();
const draggedNodeRef = useRef<NodeType>(); 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 = () => { const initGraph = () => {
@ -127,6 +140,26 @@ const FlowDesigner: React.FC = () => {
// 监听画布拖拽事件 // 监听画布拖拽事件
containerRef.current.addEventListener('dragover', handleDragOver); containerRef.current.addEventListener('dragover', handleDragOver);
containerRef.current.addEventListener('drop', handleDrop); 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, // 节点高度的一半,使节点中心对准鼠标 y: position.y - 20, // 节点高度的一半,使节点中心对准鼠标
width: 180, width: 180,
height: 40, height: 40,
label: nodeType.name, shape: 'rect',
attrs: { attrs: {
body: { body: {
fill: '#fff', fill: '#fff',
@ -176,6 +209,7 @@ const FlowDesigner: React.FC = () => {
ry: 4, ry: 4,
}, },
label: { label: {
text: nodeType.name,
fill: '#333', fill: '#333',
fontSize: 14, fontSize: 14,
refX: 0.5, refX: 0.5,
@ -224,14 +258,51 @@ const FlowDesigner: React.FC = () => {
}, },
data: { data: {
type: nodeType.code, type: nodeType.code,
name: nodeType.name,
config: {}, config: {},
}, },
}); });
// 选中新创建的节点 // 选中新创建的节点
const cells = graphRef.current.getSelectedCells(); graphRef.current.getNodes().forEach(n => {
cells.forEach(cell => cell.unselect()); n.setAttrByPath('body/strokeWidth', 1);
node.select(); });
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('dragover', handleDragOver);
containerRef.current.removeEventListener('drop', handleDrop); containerRef.current.removeEventListener('drop', handleDrop);
} }
if (graphRef.current) {
graphRef.current.dispose();
}
}; };
}, [detail, containerRef.current]); }, [detail, containerRef.current]);
@ -325,10 +399,32 @@ const FlowDesigner: React.FC = () => {
<Sider width={280} className="workflow-designer-sider"> <Sider width={280} className="workflow-designer-sider">
<NodePanel onNodeDragStart={handleNodeDragStart}/> <NodePanel onNodeDragStart={handleNodeDragStart}/>
</Sider> </Sider>
<Layout>
<Toolbar graph={graphRef.current} />
<Content className="workflow-designer-content"> <Content className="workflow-designer-content">
<div ref={containerRef} className="workflow-designer-graph"/> <div ref={containerRef} className="workflow-designer-graph"/>
</Content> </Content>
</Layout> </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> </Card>
); );
}; };

View File

@ -4,26 +4,55 @@ export interface NodeExecutor {
code: string; code: string;
name: string; name: string;
description: 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 { export interface NodeType {
id: number; id: number;
code: string; code: string;
name: string; name: string;
category: 'TASK' | 'EVENT' | 'GATEWAY'; category: 'BASIC' | 'TASK' | 'EVENT' | 'GATEWAY';
description: string; description: string;
enabled: boolean; enabled: boolean;
icon: string; icon: string;
color: string; color: string;
executors: NodeExecutor[]; executors: NodeExecutor[];
configSchema: string;
defaultConfig: string;
createTime: string; createTime: string;
updateTime: string; updateTime: string;
version: number;
deleted: boolean;
createBy: string;
updateBy: string;
extraData: any;
} }
export interface NodeTypeQuery { export interface NodeTypeQuery {
enabled?: boolean; enabled?: boolean;
category?: 'TASK' | 'EVENT' | 'GATEWAY'; category?: 'BASIC' | 'TASK' | 'EVENT' | 'GATEWAY';
} }
// 获取节点类型列表 // 获取节点类型列表