From da34978f6c1f617d681946fc97ca1970d56b18c1 Mon Sep 17 00:00:00 2001 From: ZLY Date: Wed, 15 Oct 2025 15:53:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E5=AE=9E=E7=8E=B0=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E8=8A=82=E7=82=B9=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改节点类型获取逻辑,从 node.data.type 获取节点类型-为组件标识符添加默认空字符串处理- 在节点编辑器接口中添加索引签名以支持动态属性 - 阻止循环开始节点展示编辑框 - 更新本地节点编辑器以支持循环开始和结束节点类型 - 添加条件表格组件用于配置循环跳出条件- 在流程回调钩子中引入循环节点组件和相关处理逻辑 - 新增循环节点组件,包含开始和结束节点的视觉表示 - 实现添加循环节点时自动创建开始和结束节点及其连接边 - 优化数据转换逻辑以支持新的循环节点结构 --- .../FlowEditor/node/loopNode/LoopNode.tsx | 90 +++++ .../nodeEditors/LocalNodeEditor.tsx | 3 +- .../components/ConditionsTable.tsx | 341 ++++++++++++++++++ .../nodeEditors/components/LoopEditor.tsx | 19 +- .../FlowEditor/nodeEditors/index.tsx | 2 + src/hooks/useFlowCallbacks.ts | 122 ++++++- src/pages/flowEditor/index.tsx | 2 + src/utils/convertFlowData.ts | 6 +- 8 files changed, 574 insertions(+), 11 deletions(-) create mode 100644 src/components/FlowEditor/node/loopNode/LoopNode.tsx create mode 100644 src/components/FlowEditor/nodeEditors/components/ConditionsTable.tsx diff --git a/src/components/FlowEditor/node/loopNode/LoopNode.tsx b/src/components/FlowEditor/node/loopNode/LoopNode.tsx new file mode 100644 index 0000000..65e3117 --- /dev/null +++ b/src/components/FlowEditor/node/loopNode/LoopNode.tsx @@ -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 ; + } + else { + return ; + } + }; + + return ( +
+
+ {setIcon()} + {title} +
+ + {/* 左侧输入连接点 */} + + + {/* 右侧输出连接点 */} + + + {/* 顶部连接点,用于标识循环开始和结束节点是一组 */} + + + {/* 节点内容区域 */} +
+
+
+
+
+
+
+ ); +}; + +export default LoopNode; \ No newline at end of file diff --git a/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx b/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx index b9df0a5..baa192e 100644 --- a/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx +++ b/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx @@ -35,7 +35,8 @@ const LocalNodeEditor: React.FC = ({ return ; case 'WAIT': // 等待 return ; - case 'LOOP': // 循环 + case 'LOOP_START': // 循环 + case 'LOOP_END': // 循环 return ; case 'CYCLE': // 周期 return ; diff --git a/src/components/FlowEditor/nodeEditors/components/ConditionsTable.tsx b/src/components/FlowEditor/nodeEditors/components/ConditionsTable.tsx new file mode 100644 index 0000000..b6422b4 --- /dev/null +++ b/src/components/FlowEditor/nodeEditors/components/ConditionsTable.tsx @@ -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 = ({ + initialData, + nodeData, + onUpdateData + }) => { + const [data, setData] = useState([]); + const [apiOutsList, setApiOutsList] = useState([]); + const [leftList, setLeftList] = useState([]); + + const columns = [ + { + title: '序号', + dataIndex: 'index', + render: (_: any, record: TableDataItem, i) => ( + {i + 1} + ) + }, + { + title: '逻辑出口', + dataIndex: 'apiOutId', + render: (_: any, record: TableDataItem) => ( + handleSave({ ...record, apiOutId: value })} + placeholder="请输入逻辑出口" + /> + ) + }, + { + title: '左值', + dataIndex: 'lftVal', + render: (_: any, record: TableDataItem) => ( + handleSave({ ...record, operator: value })} + placeholder="请选择运算/比较符" + /> + ) + }, + { + title: '右值', + dataIndex: 'valueType', + render: (_: any, record: TableDataItem) => ( +
+ handleSave({ ...record, rgtVal: value })} + placeholder={'请输入'} + /> + ) : ()} +
+ ) + }, + { + title: '操作', + dataIndex: 'op', + render: (_: any, record: TableDataItem) => ( + + ) + } + ]; + + 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 ( + <> + + + + ); +}; + +export default ConditionsTable; \ No newline at end of file diff --git a/src/components/FlowEditor/nodeEditors/components/LoopEditor.tsx b/src/components/FlowEditor/nodeEditors/components/LoopEditor.tsx index b87aba5..b667b6e 100644 --- a/src/components/FlowEditor/nodeEditors/components/LoopEditor.tsx +++ b/src/components/FlowEditor/nodeEditors/components/LoopEditor.tsx @@ -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 = ({ nodeData, updateNodeData }) => { return ( @@ -17,6 +18,18 @@ const LoopEditor: React.FC = ({ nodeData, updateNodeData }) => }); }} /> + + + 跳出循环条件 + + { + updateNodeData('component', { + ...data + }); + }} /> ); }; diff --git a/src/components/FlowEditor/nodeEditors/index.tsx b/src/components/FlowEditor/nodeEditors/index.tsx index efe2ef4..efef826 100644 --- a/src/components/FlowEditor/nodeEditors/index.tsx +++ b/src/components/FlowEditor/nodeEditors/index.tsx @@ -10,6 +10,8 @@ export interface NodeEditorProps { node?: Node; nodeData: any; updateNodeData: (key: string, value: any) => void; + + [key: string]: any; } // 节点编辑器映射 diff --git a/src/hooks/useFlowCallbacks.ts b/src/hooks/useFlowCallbacks.ts index 97db27b..ec036c6 100644 --- a/src/hooks/useFlowCallbacks.ts +++ b/src/hooks/useFlowCallbacks.ts @@ -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('保存成功'); diff --git a/src/pages/flowEditor/index.tsx b/src/pages/flowEditor/index.tsx index e4ae4d5..9d75c7e 100644 --- a/src/pages/flowEditor/index.tsx +++ b/src/pages/flowEditor/index.tsx @@ -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); }, diff --git a/src/utils/convertFlowData.ts b/src/utils/convertFlowData.ts index 351ec0d..19bff39 100644 --- a/src/utils/convertFlowData.ts +++ b/src/utils/convertFlowData.ts @@ -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; }