feat(flow): 实现循环节点功能并优化编辑器

- 修改节点类型获取逻辑,从 node.data.type 获取节点类型-为组件标识符添加默认空字符串处理- 在节点编辑器接口中添加索引签名以支持动态属性
- 阻止循环开始节点展示编辑框
- 更新本地节点编辑器以支持循环开始和结束节点类型
- 添加条件表格组件用于配置循环跳出条件- 在流程回调钩子中引入循环节点组件和相关处理逻辑
- 新增循环节点组件,包含开始和结束节点的视觉表示
- 实现添加循环节点时自动创建开始和结束节点及其连接边
- 优化数据转换逻辑以支持新的循环节点结构
master
钟良源 4 months ago
parent c6ad30b213
commit da34978f6c

@ -0,0 +1,90 @@
import React from 'react';
import { useStore } from '@xyflow/react';
import styles from '@/components/FlowEditor/node/style/baseOther.module.less';
import DynamicIcon from '@/components/DynamicIcon';
import { Handle, Position } from '@xyflow/react';
// 循环节点组件,用于显示循环开始和循环结束节点
const LoopNode = ({ data, id }: { data: any; id: string }) => {
const title = data.title || '循环节点';
const isStartNode = data.type === 'LOOP_START';
// 获取节点选中状态 - 适配React Flow v12 API
const isSelected = useStore((state) =>
state.nodeLookup.get(id)?.selected || false
);
// 设置图标
const setIcon = () => {
if (isStartNode) {
return <DynamicIcon type="IconPlayArrow" style={{ fontSize: '16px', marginRight: '5px' }} />;
}
else {
return <DynamicIcon type="IconStop" style={{ fontSize: '16px', marginRight: '5px' }} />;
}
};
return (
<div className={`${styles['node-container']} ${isSelected ? styles.selected : ''}`}>
<div className={styles['node-header']} style={{ backgroundColor: '#1890ff' }}>
{setIcon()}
{title}
</div>
{/* 左侧输入连接点 */}
<Handle
type="target"
position={Position.Left}
id="loop-input"
style={{
background: '#2290f6',
width: '8px',
height: '8px',
border: '2px solid #fff',
boxShadow: '0 0 4px rgba(0,0,0,0.2)',
left: '-4px'
}}
/>
{/* 右侧输出连接点 */}
<Handle
type="source"
position={Position.Right}
id="loop-output"
style={{
background: '#2290f6',
width: '8px',
height: '8px',
border: '2px solid #fff',
boxShadow: '0 0 4px rgba(0,0,0,0.2)',
right: '-4px'
}}
/>
{/* 顶部连接点,用于标识循环开始和结束节点是一组 */}
<Handle
type={id.includes('END') ? 'target' : 'source'}
position={Position.Top}
id={`${id}-group`}
style={{
background: '#2290f6',
width: '8px',
height: '8px',
border: '2px solid #fff',
boxShadow: '0 0 4px rgba(0,0,0,0.2)',
top: '-4px'
}}
/>
{/* 节点内容区域 */}
<div className={styles['node-content']}>
<div className={styles['node-inputs']}>
<div className={styles['node-input-label']}>
</div>
</div>
</div>
</div>
);
};
export default LoopNode;

@ -35,7 +35,8 @@ const LocalNodeEditor: React.FC<NodeEditorProps> = ({
return <OrEditor nodeData={nodeData} updateNodeData={updateNodeData} />;
case 'WAIT': // 等待
return <WaitEditor nodeData={nodeData} updateNodeData={updateNodeData} />;
case 'LOOP': // 循环
case 'LOOP_START': // 循环
case 'LOOP_END': // 循环
return <LoopEditor nodeData={nodeData} updateNodeData={updateNodeData} />;
case 'CYCLE': // 周期
return <CycleEditor nodeData={nodeData} updateNodeData={updateNodeData} />;

@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import { Input, Select, Table, Button } from '@arco-design/web-react';
import { IconDelete } from '@arco-design/web-react/icon';
interface TableDataItem {
key: number | string;
id: string;
apiOutId: string,
lftVal: string,
operator: string,
valueType: string,
rgtVal: string
[key: string]: any; // 允许其他自定义字段
}
interface ConditionsTableProps {
initialData: any;
nodeData: any;
onUpdateData: (data: any) => void;
}
const dataTypeOptions = [
{ label: '字符串', value: 'string' },
{ label: '数字', value: 'number' },
{ label: '布尔值(真)', value: 'boolean-true' },
{ label: '布尔值(假)', value: 'boolean-false' },
{ label: '表达式', value: 'expression' }
];
const operationOptions = [
{ label: '==', value: '==' },
{ label: '!=', value: '!=' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' }
// { label: '包含', value: 'contains' },
// { label: '不包含', value: 'notContains' },
// { label: '匹配正则表达式', value: 'matchRegex' },
// { label: '不匹配正则表达式', value: 'notMatchRegex' },
// { label: '为空', value: 'isEmpty' },
// { label: '不为空', value: 'isNotEmpty' },
// { label: '为真', value: 'isTrue' },
// { label: '为假', value: 'isFalse' },
// { label: '为空或为假', value: 'isEmptyOrFalse' },
// { label: '不为空或为真', value: 'isNotEmptyOrTrue' }
];
const ConditionsTable: React.FC<ConditionsTableProps> = ({
initialData,
nodeData,
onUpdateData
}) => {
const [data, setData] = useState<any[]>([]);
const [apiOutsList, setApiOutsList] = useState([]);
const [leftList, setLeftList] = useState([]);
const columns = [
{
title: '序号',
dataIndex: 'index',
render: (_: any, record: TableDataItem, i) => (
<span>{i + 1}</span>
)
},
{
title: '逻辑出口',
dataIndex: 'apiOutId',
render: (_: any, record: TableDataItem) => (
<Input
value={record.apiOutId}
onChange={(value) => handleSave({ ...record, apiOutId: value })}
placeholder="请输入逻辑出口"
/>
)
},
{
title: '左值',
dataIndex: 'lftVal',
render: (_: any, record: TableDataItem) => (
<Select
autoWidth={{ minWidth: 200, maxWidth: 500 }}
options={leftList}
value={record.lftVal}
onChange={(value) => handleSave({ ...record, lftVal: value })}
placeholder="请选择需要引用的出入参数"
/>
)
},
{
title: '运算/比较',
dataIndex: 'operator',
render: (_: any, record: TableDataItem) => (
<Select
autoWidth={{ minWidth: 200, maxWidth: 500 }}
options={operationOptions}
value={record.operator}
onChange={(value) => handleSave({ ...record, operator: value })}
placeholder="请选择运算/比较符"
/>
)
},
{
title: '右值',
dataIndex: 'valueType',
render: (_: any, record: TableDataItem) => (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Select
autoWidth={{ minWidth: 200, maxWidth: 500 }}
options={dataTypeOptions}
value={record.valueType}
onChange={(value) => handleSave({ ...record, valueType: value })}
placeholder="请选择右值类型"
/>
{['string', 'number', 'expression'].includes(record.valueType) ? (
<Input
style={{ marginTop: 8 }}
autoWidth={{ minWidth: 200, maxWidth: 500 }}
value={record.rgtVal}
onChange={(value) => handleSave({ ...record, rgtVal: value })}
placeholder={'请输入'}
/>
) : (<span></span>)}
</div>
)
},
{
title: '操作',
dataIndex: 'op',
render: (_: any, record: TableDataItem) => (
<Button onClick={() => removeRow(record.key)} type="text" status="danger">
<IconDelete />
</Button>
)
}
];
const convertData = (originData) => {
console.log('apiOutsList:', apiOutsList);
const apiOutIds = apiOutsList;
const conditions = originData.map(item => {
let expression = '';
if (item.valueType.includes('boolean')) {
const splitStr = item.valueType.split('-')[1];
expression = `$.${item.lftVal}${item.operator}${splitStr}`;
}
else expression = `$.${item.lftVal}${item.operator}${item.rgtVal}`;
if (item.apiOutId && !apiOutIds.includes(item.apiOutId)) {
apiOutIds.push(item.apiOutId);
}
return {
apiOutId: item.apiOutId,
valueType: item.valueType,
expression: expression
};
});
return {
type: nodeData.type,
customDef: JSON.stringify({
apiOutIds,
conditions,
// 只需要动态添加开始节点的NodeId 循环开始的节点不允许编辑信息的,所以接口需要的信息会在节点实例的时候配置
loopStartNodeId: nodeData.component.loopStartNodeId
})
};
};
const getOperator = (expr: string) => {
let operator;
if (expr.includes('==')) {
operator = '==';
}
else if (expr.includes('>=')) {
operator = '>=';
}
else if (expr.includes('<=')) {
operator = '<=';
}
else if (expr.includes('<')) {
operator = '<';
}
else if (expr.includes('>')) {
operator = '>';
}
else {
operator = '!=';
}
return operator;
};
// 反转结构的函数,将处理后的数据转回原始格式
const reverseDataStructure = (processedData: any) => {
try {
const parsedCustomDef = JSON.parse(processedData.customDef);
if (!parsedCustomDef.conditions) {
return [];
}
return parsedCustomDef.conditions.map((condition: any, index: number) => {
// 解析表达式以获取左值、操作符和右值
let lftVal = '';
let operator = '';
let rgtVal = '';
const valueType = condition.valueType || '';
if (condition.expression) {
// 处理布尔值表达式
if (valueType.includes('boolean')) {
const splitStr = valueType.split('-')[1];
operator = getOperator(condition.expression);
const pattern = new RegExp(`\\$\\.(.+)(${operator})${splitStr}`);
const match = condition.expression.match(pattern);
if (match) {
lftVal = match[1];
operator = match[2];
}
}
// 处理其他类型的表达式
else {
// 简单的解析逻辑,可能需要根据实际表达式格式进行调整
const match = condition.expression.match(/\$\.([^=!<>]+)(==|!=|>=|<=|>|<)(.+)/);
if (match) {
lftVal = match[1];
operator = match[2];
rgtVal = match[3];
}
}
}
return {
key: index,
id: Date.now(),
apiOutId: condition.apiOutId || '',
lftVal,
operator,
valueType,
rgtVal
};
});
} catch (e) {
console.error('Error parsing customDef:', e);
return [];
}
};
// 提取apiIns和apiOuts中的name属性合并成一个一维数组
const extractApiNames = () => {
const apiInsNames = nodeData.parameters?.apiIns?.map((item: any) => item.name) || [];
const apiOutsNames = nodeData.parameters?.apiOuts?.map((item: any) => item.name) || [];
return [...apiInsNames, ...apiOutsNames];
};
// 提取dataIns中的属性并构造成options结构
const extractDataInsOptions = () => {
const dataInsOptions = nodeData.parameters?.dataIns?.map((item: any) => ({
value: item.id,
label: item.id
})) || [];
return [...dataInsOptions];
};
const handleSave = (row: TableDataItem) => {
const newData = [...data];
const index = newData.findIndex((item) => row.key === item.key);
if (index >= 0) {
newData.splice(index, 1, { ...newData[index], ...row });
}
else {
newData.push(row);
}
setData(newData);
// 重新构建数据结构
const newComponentData = convertData(newData);
onUpdateData(newComponentData);
};
const removeRow = (key: number | string) => {
const newData = data.filter((item) => item.key !== key);
setData(newData);
// 重新构建数据结构
const newComponentData = convertData(newData);
onUpdateData(newComponentData);
};
const addRow = () => {
const newKey = Date.now();
const newRow = {
key: newKey,
id: '',
apiOutId: '',
lftVal: '',
operator: '',
valueType: '',
rgtVal: ''
};
const newData = [...data, newRow];
setData(newData);
// 重新构建数据结构
const newComponentData = convertData(newData);
onUpdateData(newComponentData);
};
// 监听nodeData.parameters.dataIns的变化更新leftList
useEffect(() => {
if (nodeData.parameters?.dataIns) {
setLeftList(extractDataInsOptions());
}
}, [nodeData.parameters?.dataIns]);
useEffect(() => {
try {
console.log('nodeData:', nodeData);
setApiOutsList(extractApiNames());
setLeftList(extractDataInsOptions());
setData(reverseDataStructure(initialData));
} catch (e) {
setApiOutsList([]);
setLeftList([]);
setData([]);
}
}, []);
return (
<>
<Table columns={columns} data={data} pagination={false} />
<Button
style={{ height: 45 }}
long
type="outline"
onClick={addRow}
>
+
</Button>
</>
);
};
export default ConditionsTable;

@ -1,8 +1,9 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { NodeEditorProps } from '@/components/FlowEditor/nodeEditors';
import { Typography } from '@arco-design/web-react';
import { Typography, Slider } from '@arco-design/web-react';
import { IconUnorderedList } from '@arco-design/web-react/icon';
import ParamsTable from './ParamsTable';
import ParamsTable from '@/components/FlowEditor/nodeEditors/components/ParamsTable';
import ConditionsTable from '@/components/FlowEditor/nodeEditors/components/ConditionsTable';
const LoopEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) => {
return (
@ -17,6 +18,18 @@ const LoopEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) =>
});
}}
/>
<Typography.Title heading={5}>
<IconUnorderedList style={{ marginRight: 5, marginTop: 20 }} />
</Typography.Title>
<ConditionsTable
initialData={nodeData.component || {}}
nodeData={nodeData || null}
onUpdateData={(data) => {
updateNodeData('component', {
...data
});
}} />
</>
);
};

@ -10,6 +10,8 @@ export interface NodeEditorProps {
node?: Node;
nodeData: any;
updateNodeData: (key: string, value: any) => void;
[key: string]: any;
}
// 节点编辑器映射

@ -17,6 +17,7 @@ import { defaultNodeTypes } from '@/components/FlowEditor/node/types/defaultType
import useWebSocket from '@/hooks/useWebSocket';
import { useAlignmentGuidelines } from '@/hooks/useAlignmentGuidelines';
import LocalNode from '@/components/FlowEditor/node/localNode/LocalNode';
import LoopNode from '@/components/FlowEditor/node/loopNode/LoopNode';
import BasicNode from '@/components/FlowEditor/node/basicNode/BasicNode';
import { updateCanvasDataMap } from '@/store/ideContainer';
@ -59,7 +60,7 @@ export const useFlowCallbacks = (
const apiIns = nodeParams.apiIns || [];
if (apiOuts.some((api: any) => (api.name || api.id) === handleId) ||
apiIns.some((api: any) => (api.name || api.id) === handleId)) {
apiIns.some((api: any) => (api.name || api.id) === handleId) || (handleId.includes('loop'))) {
return 'api';
}
@ -190,7 +191,6 @@ export const useFlowCallbacks = (
// 获取源节点和目标节点的参数信息
const sourceParams = sourceNode.data?.parameters || {};
const targetParams = targetNode.data?.parameters || {};
console.log(sourceParams, targetParams, params);
// 获取源handle和目标handle的类型 (api或data)
const sourceHandleType = getHandleType(params.sourceHandle, sourceParams);
@ -287,6 +287,12 @@ export const useFlowCallbacks = (
y: event.clientY
});
// 特殊处理循环节点,添加开始和结束节点
if (nodeData.nodeType === 'LOOP') {
addLoopNodeWithStartEnd(position, nodeData);
return;
}
const newNode = {
id: `${nodeData.nodeType}-${Date.now()}`,
type: nodeData.nodeType,
@ -318,6 +324,107 @@ export const useFlowCallbacks = (
[reactFlowInstance, edges]
);
// 添加循环节点及其开始和结束节点
const addLoopNodeWithStartEnd = useCallback((position: { x: number, y: number }, nodeData: any) => {
// 创建循环开始节点
const loopStartNode = {
id: `LOOP_START-${Date.now()}`,
type: 'LOOP', // 使用本地节点类型
position: { x: position.x, y: position.y },
data: {
title: '循环开始',
type: 'LOOP_START',
parameters: {
apiIns: [],
apiOuts: [{ name: 'loopStart', desc: '', dataType: '', defaultValue: '' }],
dataIns: [],
dataOuts: []
},
component: {}
}
};
// 创建循环结束节点
const loopEndNode = {
id: `LOOP_END-${Date.now()}`,
type: 'LOOP', // 使用本地节点类型
position: { x: position.x + 400, y: position.y },
data: {
title: '循环结束',
type: 'LOOP_END',
parameters: {
apiIns: [{ name: 'continue', desc: '', dataType: '', defaultValue: '' }],
apiOuts: [{ name: 'break', desc: '', dataType: '', defaultValue: '' }],
dataIns: [{
'arrayType': null,
'dataType': 'INTEGER',
'defaultValue': 10,
'desc': '最大循环次数',
'id': 'maxTime'
}],
dataOuts: []
},
component: {
type: 'LOOP_END',
customDef: '',
loopStartNodeId: loopStartNode.id // 这里的参数是为了提供在组件内部处理数据是使用最后这个字段要序列化后放进customDef
}
}
};
loopStartNode.data.component = {
type: 'LOOP_START',
customDef: JSON.stringify({ loopEndNodeId: loopEndNode.id })
};
// 创建连接边(连接循环开始和结束节点的顶部连接点)
const newEdges = [
{
id: `${loopStartNode.id}-${loopEndNode.id}-group`,
source: loopStartNode.id,
target: loopEndNode.id,
sourceHandle: `${loopStartNode.id}-group`,
targetHandle: `${loopEndNode.id}-group`,
type: 'custom'
}
];
// 将未定义的节点动态追加进nodeTypes
const nodeMap = Array.from(Object.values(nodeTypeMap).map(key => key));
if (!nodeMap.includes('LOOP')) {
registerNodeType('LOOP', LoopNode, '循环');
}
setNodes((nds: Node[]) => {
const newNodes = [...nds, loopStartNode, loopEndNode];
// 添加节点后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...newNodes], edges: [...edges, ...newEdges] }
});
document.dispatchEvent(event);
}, 0);
return newNodes;
});
setEdges((eds: Edge[]) => {
const updatedEdges = [...eds, ...newEdges];
// 添加边后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...nodes, loopStartNode, loopEndNode], edges: [...updatedEdges] }
});
document.dispatchEvent(event);
}, 0);
return updatedEdges;
});
}, [nodes, edges]);
const onNodeDrag = useCallback(
(_: any, node: Node) => {
// 获取对齐线
@ -539,6 +646,12 @@ export const useFlowCallbacks = (
const nodeDefinition = localNodeData.find(n => n.nodeType === nodeType) || node;
if (!nodeDefinition) return;
// 特殊处理循环节点,添加开始和结束节点
if (nodeType === 'LOOP') {
addLoopNodeWithStartEnd(position, nodeDefinition);
return;
}
// 创建新节点
const newNode = {
id: `${nodeType}-${Date.now()}`,
@ -571,7 +684,7 @@ export const useFlowCallbacks = (
return newNodes;
});
}, [reactFlowInstance, edges]);
}, [reactFlowInstance, edges, addLoopNodeWithStartEnd]);
// 处理添加节点的统一方法
const handleAddNode = useCallback((nodeType: string, node: any) => {
@ -594,8 +707,9 @@ export const useFlowCallbacks = (
try {
// 转换会原始数据类型
const revertedData = revertFlowData(nodes, edges);
console.log('initialData:', initialData);
console.log('revertedData:', revertedData);
// return;
const res: any = await setMainFlow(revertedData, initialData.id);
if (res.code === 200) {
Message.success('保存成功');

@ -150,6 +150,8 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
(event: React.MouseEvent, node: Node) => {
// 不可编辑的类型
if (['AND', 'OR', 'JSON2STR', 'STR2JSON'].includes(node.type)) return;
// 循环开始的节点不展示编辑框
if (['LOOP_START'].includes(node.data.type as string)) return;
setEditingNode(node);
setIsEditModalOpen(true);
},

@ -172,7 +172,7 @@ export const revertFlowData = (nodes: any[], edges: any[]) => {
const nodeName = node.data?.title || nodeId;
// 确定节点类型
let nodeType = node.type;
let nodeType = node.data.type;
// 特殊处理 start 和 end 节点
if (nodeId === 'start') {
nodeType = 'start';
@ -197,8 +197,8 @@ export const revertFlowData = (nodes: any[], edges: any[]) => {
if (node.data?.component) {
nodeConfig.component = {
type: nodeType,
compIdentifier: node.data.component.compIdentifier,
compInstanceIdentifier: node.data.component.compInstanceIdentifier
compIdentifier: node.data.component.compIdentifier || '',
compInstanceIdentifier: node.data.component.compInstanceIdentifier || ''
};
if (node.data.component?.customDef) nodeConfig.component.customDef = node.data.component.customDef;
}

Loading…
Cancel
Save