From e0bba0753eb8574d6841fe7bc256b5287c7c214f Mon Sep 17 00:00:00 2001 From: ZLY Date: Sun, 19 Oct 2025 15:20:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E6=B7=BB=E5=8A=A0REST=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=BC=96=E8=BE=91=E5=99=A8=E5=92=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现REST节点编辑器,支持方法选择、URL输入和参数配置 - 添加URL格式校验功能,实时提示用户输入有效性 - 集成CodeMirror编辑器用于请求报文编辑和格式化- 创建KYTable组件用于键值对参数编辑 - 新增REST节点内容展示组件nodeContentREST.tsx - 在useFlowCallbacks中注册REST节点类型- 调整LocalNodeEditor表单最小宽度以改善布局 --- .../FlowEditor/node/restNode/RestNode.tsx | 30 ++ .../nodeEditors/LocalNodeEditor.tsx | 2 +- .../nodeEditors/components/KYTable.tsx | 115 ++++++++ .../nodeEditors/components/RestEditor.tsx | 273 +++++++++++++++++- src/hooks/useFlowCallbacks.ts | 3 + .../flowEditor/components/nodeContentREST.tsx | 182 ++++++++++++ 6 files changed, 591 insertions(+), 14 deletions(-) create mode 100644 src/components/FlowEditor/node/restNode/RestNode.tsx create mode 100644 src/components/FlowEditor/nodeEditors/components/KYTable.tsx create mode 100644 src/pages/flowEditor/components/nodeContentREST.tsx diff --git a/src/components/FlowEditor/node/restNode/RestNode.tsx b/src/components/FlowEditor/node/restNode/RestNode.tsx new file mode 100644 index 0000000..a35f601 --- /dev/null +++ b/src/components/FlowEditor/node/restNode/RestNode.tsx @@ -0,0 +1,30 @@ +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 NodeContentREST from '@/pages/flowEditor/components/nodeContentREST'; + +const setIcon = () => { + return ; +}; + +const CodeNode = ({ data, id }: { data: any; id: string }) => { + const title = data.title || 'REST调用'; + + // 获取节点选中状态 - 适配React Flow v12 API + const isSelected = useStore((state) => + state.nodeLookup.get(id)?.selected || false + ); + + return ( +
+
+ {setIcon()} + {title} +
+ +
+ ); +}; + +export default CodeNode; \ No newline at end of file diff --git a/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx b/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx index 4c0f918..347ae2b 100644 --- a/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx +++ b/src/components/FlowEditor/nodeEditors/LocalNodeEditor.tsx @@ -79,7 +79,7 @@ const LocalNodeEditor: React.FC = ({ }; return ( -
+ {renderLocalNodeEditor()}
); diff --git a/src/components/FlowEditor/nodeEditors/components/KYTable.tsx b/src/components/FlowEditor/nodeEditors/components/KYTable.tsx new file mode 100644 index 0000000..4145376 --- /dev/null +++ b/src/components/FlowEditor/nodeEditors/components/KYTable.tsx @@ -0,0 +1,115 @@ +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; + dataType: string; + arrayType: string; + desc: string; + defaultValue: string; + + [key: string]: any; // 允许其他自定义字段 +} + +interface ParamsTableProps { + initialData: TableDataItem[] | any; + onChange: (data) => void; +} + +const ParamsTable: React.FC = ({ + initialData, + onChange + }) => { + const [data, setData] = useState([]); + + useEffect(() => { + // 为现有数据添加key属性(如果不存在) + const dataWithKeys = initialData?.map((item, index) => ({ + ...item, + key: item.key ?? index + })) || []; + setData(dataWithKeys); + }, [initialData]); + + + const columns = [ + { + title: 'key', + dataIndex: 'paramsKey', + render: (_: any, record: TableDataItem) => ( + handleSave({ ...record, paramsKey: value })} + /> + ) + }, + { + title: 'value', + dataIndex: 'paramsValue', + render: (_: any, record: TableDataItem) => ( + handleSave({ ...record, paramsValue: value })} + /> + ) + }, + { + title: '操作', + dataIndex: 'op', + render: (_: any, record: TableDataItem) => ( + record.id !== 'maxTime' && + ) + } + ]; + + 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); + onChange(newData); + }; + + const removeRow = (key: number | string) => { + const newData = data.filter((item) => item.key !== key); + setData(newData); + onChange(newData); + }; + + const addRow = () => { + const newKey = Date.now(); + const newRow = { + key: newKey, + paramsKey: '', + paramsValue: '' + }; + const newData = [...data, newRow]; + setData(newData); + onChange(newData); + }; + + return ( + <> + + + + ); +}; + +export default ParamsTable; \ No newline at end of file diff --git a/src/components/FlowEditor/nodeEditors/components/RestEditor.tsx b/src/components/FlowEditor/nodeEditors/components/RestEditor.tsx index 07c9c91..fa9c1d3 100644 --- a/src/components/FlowEditor/nodeEditors/components/RestEditor.tsx +++ b/src/components/FlowEditor/nodeEditors/components/RestEditor.tsx @@ -1,22 +1,269 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { NodeEditorProps } from '@/components/FlowEditor/nodeEditors'; -import { Typography } from '@arco-design/web-react'; +import { Typography, Form, Grid, Select, Input, Button, Link, Message } from '@arco-design/web-react'; import { IconUnorderedList } from '@arco-design/web-react/icon'; import ParamsTable from './ParamsTable'; +import KYTable from '@/components/FlowEditor/nodeEditors/components/KYTable'; +import CodeMirror from '@uiw/react-codemirror'; +import { json } from '@codemirror/lang-json'; + +const Row = Grid.Row; +const Col = Grid.Col; + +const FormItem = Form.Item; +const Option = Select.Option; + +const RestEditor: React.FC void +}> = ({ nodeData, updateNodeData, onValidationChange }) => { + const [formData, setFormData] = useState({ + method: 'GET', + url: '', + inputType: 'JSON', + headers: [], + defaultInput: '', + dataIns: nodeData.parameters.dataIns || [], + component: {} + }); + + const [isShowHeaders, setIsShowHeaders] = useState(false); + const [isShowDataIns, setIsShowDataIns] = useState(false); + const [isValidUrl, setIsValidUrl] = useState(true); // 添加URL有效性状态 + const [value, setValue] = useState(''); + + const methodList = ['GET', 'POST']; + const dataTypeList = [ + { label: 'JSON', value: 'JSON' }, + { label: '路径参数', value: '路径参数' } + ]; + + // URL 校验函数 + const validateUrl = useCallback((url: string) => { + if (!url) return true; // 空URL认为是有效的(但会在提交时提示) + + // 基本的 URL 格式校验正则表达式 + const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/; + return urlPattern.test(url); + }, []); + + // 通知父组件校验状态变化 + useEffect(() => { + if (onValidationChange) { + onValidationChange(isValidUrl); + } + }, [isValidUrl, onValidationChange]); + + // 初始化数据 + useEffect(() => { + if (nodeData.component && nodeData.component.customDef) { + try { + const customDef = typeof nodeData.component.customDef === 'string' + ? JSON.parse(nodeData.component.customDef) + : nodeData.component.customDef; + + setFormData(prev => ({ + ...prev, + method: customDef.method || 'GET', + url: customDef.url || '', + inputType: customDef.inputType || 'JSON', + headers: customDef.headers || [], + defaultInput: customDef.defaultInput || '', + dataIns: nodeData.parameters.dataIns || [] + })); + + // 检查初始URL有效性 + const initialUrlValid = validateUrl(customDef.url || ''); + setIsValidUrl(initialUrlValid); + + setIsShowHeaders(!!(customDef.headers && customDef.headers.length > 0)); + setIsShowDataIns(!!(nodeData.parameters.dataIns && nodeData.parameters.dataIns.length > 0)); + } catch (e) { + console.error('解析组件数据失败:', e); + } + } + }, [nodeData, validateUrl]); + + // 更新表单数据时同步到节点 + const handleFormChange = (newFormData) => { + setFormData(newFormData); + + // 更新 component 数据 + const componentData = { + ...nodeData.component, + customDef: JSON.stringify({ + method: newFormData.method, + url: newFormData.url, + inputType: newFormData.inputType, + headers: newFormData.headers, + defaultInput: newFormData.defaultInput + }), + type: 'REST' + }; + + updateNodeData('component', componentData); + }; + + const handleChangeMethod = (method) => { + handleFormChange({ ...formData, method }); + }; + + const handleUrlChange = (url) => { + // 实时校验URL + const isValid = validateUrl(url); + setIsValidUrl(isValid); + + handleFormChange({ ...formData, url }); + + // 可选:在用户输入时实时校验 URL + if (url && !isValid) { + // 这里可以添加实时提示,但不阻止用户输入 + console.warn('URL 格式可能不正确'); + } + }; + + const formatJSON = () => { + try { + console.log('formData.defaultInput:', formData.defaultInput); + const formatted = JSON.stringify(JSON.parse(formData.defaultInput), null, 2); + handleFormChange({ ...formData, defaultInput: formatted }); + } catch (e) { + // 如果不是有效的JSON,不进行格式化 + Message.error('无效的 JSON 格式'); + } + }; + + // URL 校验并提示 + const handleUrlBlur = () => { + if (formData.url && !validateUrl(formData.url)) { + Message.warning('请输入有效的 URL 地址'); + } + }; -const RestEditor: React.FC = ({ nodeData, updateNodeData }) => { return ( <> - 输入参数 - { - updateNodeData('parameters', { - ...nodeData.parameters, - dataIns: data - }); - }} - /> +
+
+ 请求方法 + +
+ + + + + + + + {formData.url && !isValidUrl && ( +
+ 请输入有效的 URL 地址 +
+ )} +
+ + + + + + + + + +
+ +
+ 请求头 + {!isShowHeaders ? ( + setIsShowHeaders(true)} style={{ marginLeft: 8 }}> + 配置 + + ) : null} + + + {isShowHeaders ? ( +
+ {/* 这里需要实现KV编辑器,暂时用简单输入框替代 */} + { + handleFormChange({ ...formData, headers: data }); + }} /> +
+ ) : null} +
+ + + + + 请求报文 + + 格式化 + + + + { + setValue(data); + handleFormChange({ ...formData, defaultInput: data }); + }} /> + + + + + + 输入参数 + {!isShowDataIns ? ( + setIsShowDataIns(true)} style={{ marginLeft: 8 }}> + 配置 + + ) : null} + + + {isShowDataIns ? ( +
+ { + handleFormChange({ ...formData, dataIns }); + // 同时更新parameters.dataIns + updateNodeData('parameters', { + ...nodeData.parameters, + dataIns + }); + }} + /> +
+ ) : null} +
+ + + + ); }; diff --git a/src/hooks/useFlowCallbacks.ts b/src/hooks/useFlowCallbacks.ts index 6ef3cda..cdc319a 100644 --- a/src/hooks/useFlowCallbacks.ts +++ b/src/hooks/useFlowCallbacks.ts @@ -22,6 +22,7 @@ import BasicNode from '@/components/FlowEditor/node/basicNode/BasicNode'; import SwitchNode from '@/components/FlowEditor/node/switchNode/SwitchNode'; import ImageNode from '@/components/FlowEditor/node/imageNode/ImageNode'; import CodeNode from '@/components/FlowEditor/node/codeNode/CodeNode'; +import RestNode from '@/components/FlowEditor/node/restNode/RestNode'; import { updateCanvasDataMap } from '@/store/ideContainer'; import { Dispatch } from 'redux'; @@ -37,6 +38,8 @@ const getNodeComponent = (nodeType: string) => { return ImageNode; case 'CODE': return CodeNode; + case 'REST': + return RestNode; default: return LocalNode; } diff --git a/src/pages/flowEditor/components/nodeContentREST.tsx b/src/pages/flowEditor/components/nodeContentREST.tsx new file mode 100644 index 0000000..22797ec --- /dev/null +++ b/src/pages/flowEditor/components/nodeContentREST.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import styles from '@/components/FlowEditor/node/style/baseOther.module.less'; +import { Handle, Position, useStore } from '@xyflow/react'; + +interface NodeContentData { + parameters?: { + dataIns?: any[]; + dataOuts?: any[]; + apiIns?: any[]; + apiOuts?: any[]; + }; + showFooter?: boolean; + type?: string; + + [key: string]: any; +} + +// 定义通用的句柄样式 +const handleStyles = { + mainSource: { + background: '#2290f6', + width: '8px', + height: '8px', + border: '2px solid #fff', + boxShadow: '0 0 4px rgba(0,0,0,0.2)' + }, + mainTarget: { + background: '#2290f6', + width: '8px', + height: '8px', + border: '2px solid #fff', + boxShadow: '0 0 4px rgba(0,0,0,0.2)' + }, + data: { + background: '#555', + width: '6px', + height: '6px', + border: '1px solid #fff', + boxShadow: '0 0 2px rgba(0,0,0,0.2)' + } +}; + +// 渲染普通节点的句柄 +const renderRegularNodeHandles = (dataIns: any[], dataOuts: any[], apiIns: any[], apiOuts: any[]) => { + return ( + <> + {apiOuts.map((_, index) => ( + + ))} + {apiIns.map((_, index) => ( + + ))} + + {/* 输入参数连接端点 */} + {dataIns.map((_, index) => ( + + ))} + + {/* 输出参数连接端点 */} + {dataOuts.map((_, index) => ( + + ))} + + ); +}; + +const NodeContent = ({ data }: { data: NodeContentData }) => { + const apiIns = data.parameters?.apiIns || []; + const apiOuts = data.parameters?.apiOuts || []; + const dataIns = data.parameters?.dataIns || []; + const dataOuts = data.parameters?.dataOuts || []; + + // 判断节点类型 + const isStartNode = data.type === 'start'; + const isEndNode = data.type === 'end'; + + return ( + <> + {/*content栏-api部分*/} +
+
+ {apiIns.length > 0 && ( +
+ {apiIns.map((input, index) => ( +
+ {input.desc} +
+ ))} +
+ )} + + {apiOuts.length > 0 && ( +
+ {apiOuts.map((output, index) => ( +
+ {output.desc} +
+ ))} +
+ )} +
+
+ {(dataIns.length > 0 || dataOuts.length > 0) && ( + <> + {/*分割*/} +
+ +
+ + {/*content栏-data部分*/} +
+
+ {dataIns.length > 0 && !isStartNode && ( +
+ {dataIns.map((input, index) => ( +
+ {input.id || `输入${index + 1}`} +
+ ))} +
+ )} + + {dataOuts.length > 0 && !isEndNode && ( +
+ {dataOuts.map((output, index) => ( +
+ {`${output.id} ${output.dataType}` || `输出${index + 1}`} +
+ ))} +
+ )} +
+
+ + )} + + + {/* 根据节点类型渲染不同的句柄 */} + {renderRegularNodeHandles(dataIns, dataOuts, apiIns, apiOuts)} + + ); +}; + +export default NodeContent; \ No newline at end of file