feat(flow): 添加应用节点组件及内容渲染逻辑

- 新增 AppNode 组件,支持随机背景色和选中状态显示- 实现 NodeContentApp 组件,用于渲染节点的 API 和数据输入输出
- 添加句柄渲染逻辑,区分普通节点与起始/结束节点
- 支持节点底部信息展示,如等待时间、循环间隔和事件名称
- 集成 React Flow v12 的状态管理 API 获取节点选中状态- 使用 useMemo优化节点背景色生成逻辑,避免重复计算
master
钟良源 4 months ago
parent e0c489b4de
commit 996310d2fe

@ -0,0 +1,33 @@
import React, { useMemo } from 'react';
import styles from '@/components/FlowEditor/node/style/baseOther.module.less';
import NodeContentApp from '@/pages/flowEditor/components/nodeContentApp';
import { useStore } from '@xyflow/react';
import { defaultNodeTypes } from '@/components/FlowEditor/node/types/defaultType';
const AppNode = ({ data, id }: { data: defaultNodeTypes; id: string }) => {
const title = data.title || '基础节点';
// 生成随机背景色使用useMemo确保颜色只在节点首次创建时生成一次
const backgroundColor = useMemo(() => {
const colors = ['#e59428', '#4a90e2', '#7b68ee', '#50c878', '#ff6347', '#9370db', '#00bfff', '#ff8c00'];
return colors[Math.floor(Math.random() * colors.length)];
}, []);
// 获取节点选中状态 - 适配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 }}>
{title}
</div>
<NodeContentApp data={{ ...data, type: 'app' }} />
</div>
);
};
export default AppNode;

@ -0,0 +1,293 @@
import React from 'react';
import styles from '@/components/FlowEditor/node/style/baseOther.module.less';
import { Handle, Position, useStore } from '@xyflow/react';
import { deserializeValue, isJSON } from '@/utils/common';
import cronstrue from 'cronstrue/i18n';
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 renderSpecialNodeHandles = (isStartNode: boolean, isEndNode: boolean, dataIns: any[], dataOuts: any[], apiIns: any[], apiOuts: any[]) => {
const renderStartNodeHandles = () => {
if (!isStartNode) return null;
return (
<>
{apiOuts.map((_, index) => (
<Handle
key={`start-output-handle-${index}`}
type="source"
position={Position.Right}
id={apiOuts[index].name || `start-output-${index}`}
style={{
...handleStyles.mainSource,
top: `${35 + index * 20}px`
}}
/>
))}
{dataOuts.length > 0 && dataOuts.map((_, index) => (
<Handle
key={`output-handle-${index}`}
type="source"
position={Position.Right}
id={dataOuts[index].name || `output-${index}`}
style={{
...handleStyles.data,
top: `${70 + apiOuts.length * 20 + index * 20}px`
}}
/>
))}
</>
);
};
const renderEndNodeHandles = () => {
if (!isEndNode) return null;
return (
<>
{apiIns.map((_, index) => (
<Handle
key={`end-input-handle-${index}`}
type="target"
position={Position.Left}
id={apiIns[index].name || `end-input-${index}`}
style={{
...handleStyles.mainTarget,
top: `${35 + index * 20}px`
}}
/>
))}
{dataIns.length > 0 && dataIns.map((_, index) => (
<Handle
key={`input-handle-${index}`}
type="target"
position={Position.Left}
id={dataIns[index].name || `input-${index}`}
style={{
...handleStyles.data,
top: `${70 + apiIns.length * 20 + index * 20}px`
}}
/>
))}
</>
);
};
return (
<>
{renderStartNodeHandles()}
{renderEndNodeHandles()}
</>
);
};
// 渲染普通节点的句柄
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 formatFooter = (data: any) => {
try {
switch (data?.type) {
case 'WAIT':
const { duration } = deserializeValue(data.customDef);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
return `${hours}小时${minutes}分钟${seconds}`;
case 'CYCLE':
const { intervalSeconds } = deserializeValue(data.customDef);
return cronstrue.toString(intervalSeconds, { locale: 'zh_CN' });
case 'EVENTSEND':
case 'EVENTLISTENE':
const { name, topic } = isJSON(data.customDef) ? JSON.parse(data.customDef) : data.customDef;
if (topic.includes('**empty**')) return '';
return `事件: ${name}`;
default:
return '';
}
} catch (e) {
console.log(e);
}
};
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 showFooter = formatFooter(data.component) || false;
const footerData = (showFooter && data.component) || {};
// console.log(apiIns, apiOuts, dataIns, dataOuts);
// 判断节点类型
const isStartNode = data.type === 'start';
const isEndNode = data.type === 'end';
const isSpecialNode = isStartNode || isEndNode;
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.topic.includes('**empty') ? input.name : ''}
</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.topic.includes('**empty') ? output.name : ''}
</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 || `输出${index + 1}`}
</div>
))}
</div>
)}
</div>
</div>
</>
)}
{/*footer栏*/}
{/*{showFooter && (*/}
{/* <div className={styles['node-footer']}>*/}
{/* {formatFooter(footerData)}*/}
{/* </div>*/}
{/*)}*/}
{/* 根据节点类型渲染不同的句柄 */}
{isSpecialNode
? renderSpecialNodeHandles(isStartNode, isEndNode, dataIns, dataOuts, apiIns, apiOuts)
: renderRegularNodeHandles(dataIns, dataOuts, apiIns, apiOuts)}
</>
);
};
export default NodeContent;
Loading…
Cancel
Save