feat(flow): 添加REST节点编辑器和相关组件

- 实现REST节点编辑器,支持方法选择、URL输入和参数配置
- 添加URL格式校验功能,实时提示用户输入有效性
- 集成CodeMirror编辑器用于请求报文编辑和格式化- 创建KYTable组件用于键值对参数编辑
- 新增REST节点内容展示组件nodeContentREST.tsx
- 在useFlowCallbacks中注册REST节点类型- 调整LocalNodeEditor表单最小宽度以改善布局
master
钟良源 4 months ago
parent f53a57d0f8
commit e0bba0753e

@ -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 <DynamicIcon type="IconCloudDownload" style={{ fontSize: '16px', marginRight: '5px' }} />;
};
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 (
<div className={`${styles['node-container']} ${isSelected ? styles.selected : ''}`}>
<div className={styles['node-header']} style={{ backgroundColor: '#1890ff' }}>
{setIcon()}
{title}
</div>
<NodeContentREST data={data} />
</div>
);
};
export default CodeNode;

@ -79,7 +79,7 @@ const LocalNodeEditor: React.FC<NodeEditorProps> = ({
}; };
return ( return (
<Form layout="vertical"> <Form layout="vertical" style={{ minWidth: 200, maxWidth: 1100 }}>
{renderLocalNodeEditor()} {renderLocalNodeEditor()}
</Form> </Form>
); );

@ -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<ParamsTableProps> = ({
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) => (
<Input
value={record.paramsKey}
onChange={(value) => handleSave({ ...record, paramsKey: value })}
/>
)
},
{
title: 'value',
dataIndex: 'paramsValue',
render: (_: any, record: TableDataItem) => (
<Input
value={record.paramsValue}
onChange={(value) => handleSave({ ...record, paramsValue: value })}
/>
)
},
{
title: '操作',
dataIndex: 'op',
render: (_: any, record: TableDataItem) => (
record.id !== 'maxTime' && <Button onClick={() => removeRow(record.key)} type="text" status="danger">
<IconDelete />
</Button>
)
}
];
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 (
<>
<Table columns={columns} data={data} pagination={false} />
<Button
style={{ height: 45 }}
long
type="outline"
onClick={addRow}
>
+
</Button>
</>
);
};
export default ParamsTable;

@ -1,22 +1,269 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { NodeEditorProps } from '@/components/FlowEditor/nodeEditors'; 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 { IconUnorderedList } from '@arco-design/web-react/icon';
import ParamsTable from './ParamsTable'; 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<NodeEditorProps & {
onValidationChange?: (isValid: boolean) => 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<NodeEditorProps> = ({ nodeData, updateNodeData }) => {
return ( return (
<> <>
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} /></Typography.Title> <Form layout="vertical">
<ParamsTable <div style={{ padding: '0 16px' }}>
initialData={nodeData.parameters.dataIns || []} <Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} /></Typography.Title>
onUpdateData={(data) => { <Row gutter={10}>
updateNodeData('parameters', { <Col span={6}>
...nodeData.parameters, <FormItem label="方法:" required>
dataIns: data <Select
}); value={formData.method}
}} onChange={handleChangeMethod}
/> >
{methodList.map(method => (
<Option key={method} value={method}>
{method}
</Option>
))}
</Select>
</FormItem>
</Col>
<Col span={12}>
<FormItem label="URL地址:" required validateStatus={formData.url && !isValidUrl ? 'error' : 'success'}>
<Input
value={formData.url}
onChange={handleUrlChange}
onBlur={handleUrlBlur}
placeholder="请输入URL地址例如: https://api.example.com/endpoint"
/>
{formData.url && !isValidUrl && (
<div style={{ color: '#ff0000', fontSize: '12px', marginTop: '4px' }}>
URL
</div>
)}
</FormItem>
</Col>
<Col span={6}>
<FormItem label="参数类型:">
<Select
value={formData.inputType}
onChange={inputType => handleFormChange({ ...formData, inputType })}
>
{dataTypeList.map(item => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
</FormItem>
</Col>
</Row>
</div>
<div style={{ padding: '0 16px' }}>
<Row gutter={24}>
<Col span={24}>
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} />
{!isShowHeaders ? (
<Link onClick={() => setIsShowHeaders(true)} style={{ marginLeft: 8 }}>
</Link>
) : null}
</Typography.Title>
<FormItem>
{isShowHeaders ? (
<div>
{/* 这里需要实现KV编辑器暂时用简单输入框替代 */}
<KYTable
initialData={formData.headers}
onChange={(data) => {
handleFormChange({ ...formData, headers: data });
}} />
</div>
) : null}
</FormItem>
</Col>
<Col span={24}>
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} />
<Link onClick={formatJSON} style={{ marginLeft: 8 }}>
</Link>
</Typography.Title>
<FormItem>
<CodeMirror
value={value}
height="300px"
extensions={[json()]}
onChange={data => {
setValue(data);
handleFormChange({ ...formData, defaultInput: data });
}} />
</FormItem>
</Col>
<Col span={24}>
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} />
{!isShowDataIns ? (
<Link onClick={() => setIsShowDataIns(true)} style={{ marginLeft: 8 }}>
</Link>
) : null}
</Typography.Title>
<FormItem>
{isShowDataIns ? (
<div>
<ParamsTable
initialData={formData.dataIns}
onUpdateData={(dataIns) => {
handleFormChange({ ...formData, dataIns });
// 同时更新parameters.dataIns
updateNodeData('parameters', {
...nodeData.parameters,
dataIns
});
}}
/>
</div>
) : null}
</FormItem>
</Col>
</Row>
</div>
</Form>
</> </>
); );
}; };

@ -22,6 +22,7 @@ import BasicNode from '@/components/FlowEditor/node/basicNode/BasicNode';
import SwitchNode from '@/components/FlowEditor/node/switchNode/SwitchNode'; import SwitchNode from '@/components/FlowEditor/node/switchNode/SwitchNode';
import ImageNode from '@/components/FlowEditor/node/imageNode/ImageNode'; import ImageNode from '@/components/FlowEditor/node/imageNode/ImageNode';
import CodeNode from '@/components/FlowEditor/node/codeNode/CodeNode'; import CodeNode from '@/components/FlowEditor/node/codeNode/CodeNode';
import RestNode from '@/components/FlowEditor/node/restNode/RestNode';
import { updateCanvasDataMap } from '@/store/ideContainer'; import { updateCanvasDataMap } from '@/store/ideContainer';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@ -37,6 +38,8 @@ const getNodeComponent = (nodeType: string) => {
return ImageNode; return ImageNode;
case 'CODE': case 'CODE':
return CodeNode; return CodeNode;
case 'REST':
return RestNode;
default: default:
return LocalNode; return LocalNode;
} }

@ -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) => (
<Handle
key={`api-output-handle-${index}`}
type="source"
position={Position.Right}
id={apiOuts[index].name || apiOuts[index].id || `output-${index}`}
style={{
...handleStyles.mainSource,
top: `${35 + index * 20}px`
}}
/>
))}
{apiIns.map((_, index) => (
<Handle
key={`api-input-handle-${index}`}
type="target"
position={Position.Left}
id={apiIns[index].name || apiIns[index].id || `input-${index}`}
style={{
...handleStyles.mainTarget,
top: `${35 + index * 20}px`
}}
/>
))}
{/* 输入参数连接端点 */}
{dataIns.map((_, index) => (
<Handle
key={`data-input-handle-${index}`}
type="target"
position={Position.Left}
id={dataIns[index].name || dataIns[index].id || `input-${index}`}
style={{
...handleStyles.data,
top: `${70 + (apiIns.length + index) * 20}px`
}}
/>
))}
{/* 输出参数连接端点 */}
{dataOuts.map((_, index) => (
<Handle
key={`data-output-handle-${index}`}
type="source"
position={Position.Right}
id={dataOuts[index].name || dataOuts[index].id || `output-${index}`}
style={{
...handleStyles.data,
top: `${70 + (apiOuts.length + index) * 20}px`
}}
/>
))}
</>
);
};
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部分*/}
<div className={styles['node-api-box']}>
<div className={styles['node-content-api']}>
{apiIns.length > 0 && (
<div className={styles['node-inputs']}>
{apiIns.map((input, index) => (
<div key={input.id || `input-${index}`} className={styles['node-input-label']}>
{input.desc}
</div>
))}
</div>
)}
{apiOuts.length > 0 && (
<div className={styles['node-outputs-api']}>
{apiOuts.map((output, index) => (
<div key={output.id || `output-${index}`} className={styles['node-input-label']}>
{output.desc}
</div>
))}
</div>
)}
</div>
</div>
{(dataIns.length > 0 || dataOuts.length > 0) && (
<>
{/*分割*/}
<div
className={styles['node-split-line']}
>
</div>
{/*content栏-data部分*/}
<div className={styles['node-data-box']}>
<div className={styles['node-content']}>
{dataIns.length > 0 && !isStartNode && (
<div className={styles['node-inputs']}>
{dataIns.map((input, index) => (
<div key={input.id || `input-${index}`} className={styles['node-input-label']}>
{input.id || `输入${index + 1}`}
</div>
))}
</div>
)}
{dataOuts.length > 0 && !isEndNode && (
<div className={styles['node-outputs']}>
{dataOuts.map((output, index) => (
<div key={output.id || `output-${index}`} className={styles['node-input-label']}>
{`${output.id} ${output.dataType}` || `输出${index + 1}`}
</div>
))}
</div>
)}
</div>
</div>
</>
)}
{/* 根据节点类型渲染不同的句柄 */}
{renderRegularNodeHandles(dataIns, dataOuts, apiIns, apiOuts)}
</>
);
};
export default NodeContent;
Loading…
Cancel
Save