You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

449 lines
15 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useState, useRef, useEffect, useMemo, useCallback, memo } from 'react';
import { ResizeBox, Tabs, Message, Spin, Table, TableColumnProps, Button } from '@arco-design/web-react';
import { IconCheckCircleFill, IconLoading, IconCloseCircleFill, IconLeft } from '@arco-design/web-react/icon';
import FlowEditor from '@/pages/flowEditor/index';
import { getInstanceDefinition } from '@/api/appIns';
import { formatSeconds } from '@/utils/common';
import dayjs from 'dayjs';
import styles from './index.module.less';
const TabPane = Tabs.TabPane;
// 使用 memo 包装 FlowEditor防止不必要的重新渲染
const MemoizedFlowEditor = memo(({ flowData, instanceId }: { flowData: any; instanceId: string }) => {
return (
<FlowEditor
key={instanceId}
initialData={flowData}
useDefault={true}
readOnly={true}
/>
);
}, (prevProps, nextProps) => {
// 自定义比较函数:只有当 instanceId 变化时才重新渲染
return prevProps.instanceId === nextProps.instanceId;
});
interface LogMessage {
id: number;
type: string;
message: string;
timestamp: string;
}
interface RuntimeData {
[key: string]: any;
}
interface InstanceCanvasProps {
instanceData: any; // 实例数据
title?: string; // 实例标题
onBack?: () => void; // 返回回调
}
const InstanceCanvas: React.FC<InstanceCanvasProps> = ({ instanceData, title, onBack }) => {
const [activeTab, setActiveTab] = useState('1');
const [logBarExpanded, setLogBarExpanded] = useState(true);
const [logContainerHeight, setLogContainerHeight] = useState('250px');
const [runtimeLogs, setRuntimeLogs] = useState<LogMessage[]>([]);
const [runtimeData, setRuntimeData] = useState<RuntimeData>({});
const [flowData, setFlowData] = useState<any>(null); // 流程数据
const [nodeStatusMap, setNodeStatusMap] = useState<Record<string, string>>({}); // 节点状态映射
const [loading, setLoading] = useState(false); // 加载状态
const resizeBoxRef = useRef<HTMLDivElement>(null);
const flowDataRef = useRef<any>(null); // 使用 ref 来存储 flowData避免重复设置
// 获取实例定义数据
const fetchInstanceDefinition = useCallback(async () => {
if (!instanceData?.id) return;
setLoading(true);
try {
const res: any = await getInstanceDefinition(instanceData.id);
if (res.code === 200 && res.data) {
const components = res.data.main?.flow?.components;
// 包装数据格式,使其符合 FlowEditor 的期望
// FlowEditor 使用 useDefault=true 时,会调用 projectFlowHandle
// projectFlowHandle 期望 initialData?.main?.components 或 initialData?.components
if (components && typeof components === 'object') {
// 转换连接格式:将单 $ 转换为 $$
const convertedComponents = Object.entries(components).reduce((acc, [nodeId, nodeConfig]: [string, any]) => {
const converted = { ...nodeConfig };
// 转换 apiDownstream
if (converted.apiDownstream && Array.isArray(converted.apiDownstream)) {
converted.apiDownstream = converted.apiDownstream.map((targetArray: string[]) => {
if (Array.isArray(targetArray)) {
return targetArray.map((target: string) => {
if (typeof target === 'string' && target.includes('$') && !target.includes('$$')) {
return target.replace('$', '$$');
}
return target;
});
}
return targetArray;
});
}
// 转换 apiUpstream
if (converted.apiUpstream && Array.isArray(converted.apiUpstream)) {
converted.apiUpstream = converted.apiUpstream.map((sourceArray: string[]) => {
if (Array.isArray(sourceArray)) {
return sourceArray.map((source: string) => {
if (typeof source === 'string' && source.includes('$') && !source.includes('$$')) {
return source.replace('$', '$$');
}
return source;
});
}
return sourceArray;
});
}
acc[nodeId] = converted;
return acc;
}, {} as any);
const newFlowData = {
components: convertedComponents,
main: {
components: convertedComponents
}
};
// 只在数据真正变化时才更新
if (!flowDataRef.current) {
flowDataRef.current = newFlowData;
setFlowData(newFlowData);
}
}
// 处理运行日志和节点状态
if (res.data.main?.nodeLogs && Array.isArray(res.data.main.nodeLogs) && res.data.main.nodeLogs.length > 0) {
const logs = res.data.main.nodeLogs.map((log: any, index: number) => ({
id: index,
type: 'runtime',
message: log.runLog || '',
timestamp: new Date(log.startTime).toLocaleString('zh-CN'),
nodeId: log.nodeId,
nodeName: log.name,
duration: log.duration,
state: log.state
}));
setRuntimeLogs(logs);
// 构建节点状态映射
const statusMap: Record<string, string> = {};
res.data.main.nodeLogs.forEach((log: any) => {
// state: 1=成功(success), 0=运行中(running), -1=失败(failed)
if (log.state === 1) {
statusMap[log.nodeId] = 'success';
} else if (log.state === -1) {
statusMap[log.nodeId] = 'failed';
} else if (log.state === 0) {
statusMap[log.nodeId] = 'running';
}
});
setNodeStatusMap(statusMap);
}
// 处理运行数据input/output
if (res.data.main?.nodeLogs && Array.isArray(res.data.main.nodeLogs) && res.data.main.nodeLogs.length > 0) {
const dataMap: RuntimeData = {};
res.data.main.nodeLogs.forEach((log: any) => {
if (log.input || log.output) {
dataMap[log.nodeId] = {
nodeName: log.name,
input: log.input || {},
output: log.output || {},
duration: log.duration,
startTime: log.startTime,
endTime: log.endTime,
state: log.state === 1 ? '成功' : log.state === -1 ? '失败' : '运行中'
};
}
});
setRuntimeData(dataMap);
}
}
else {
Message.error(res.msg || '获取实例数据失败');
}
} catch (error) {
console.error('获取实例定义失败:', error);
Message.error('获取实例数据失败');
} finally {
setLoading(false);
}
}, [instanceData?.id]);
// 当 instanceData 变化时,重新获取数据
useEffect(() => {
flowDataRef.current = null; // 重置 ref
fetchInstanceDefinition();
}, [instanceData?.id, fetchInstanceDefinition]);
// 处理 Tab 点击事件
const handleTabClick = (key: string) => {
if (key === activeTab) {
setLogBarExpanded(!logBarExpanded);
}
else {
setActiveTab(key);
setLogBarExpanded(true);
}
};
// 当展开/收起状态改变时,更新元素样式
useEffect(() => {
if (resizeBoxRef.current) {
resizeBoxRef.current.style.height = logBarExpanded ? logContainerHeight : '0px';
}
}, [logBarExpanded, logContainerHeight]);
// 处理 ResizeBox 手动调整大小事件
const handleResize = useCallback((e: MouseEvent, size: { width: number; height: number }) => {
if (size.height <= 40) {
setLogBarExpanded(false);
}
else {
setLogBarExpanded(true);
setLogContainerHeight(`${size.height}px`);
}
}, []);
// 使用 useMemo 创建带有节点状态的 flowData
const flowDataWithStatus = useMemo(() => {
if (!flowData || Object.keys(nodeStatusMap).length === 0) {
return flowData;
}
// 深拷贝 flowData 并注入节点状态
const componentsWithStatus = { ...flowData.components };
Object.keys(nodeStatusMap).forEach((nodeId) => {
if (componentsWithStatus[nodeId]) {
componentsWithStatus[nodeId] = {
...componentsWithStatus[nodeId],
status: nodeStatusMap[nodeId],
isStatusVisible: true // 显示状态指示器
};
}
});
const result = {
...flowData,
components: componentsWithStatus,
main: {
...flowData.main,
components: componentsWithStatus
}
};
return result;
}, [flowData, nodeStatusMap]);
// 渲染运行日志内容
const renderRuntimeLogs = () => {
return (
<div style={{ padding: '10px', height: 'calc(100% - 40px)', overflowY: 'auto' }}>
{runtimeLogs.length === 0 ? (
<p></p>
) : (
runtimeLogs.map((log: any) => (
<div key={log.id} style={{ marginBottom: '8px', padding: '4px', borderBottom: '1px solid #eee' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{log.timestamp}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{log.message}
</div>
</div>
))
)}
</div>
);
};
// 定义运行数据表格列
const dataColumns: TableColumnProps[] = [
{
title: '节点',
dataIndex: 'nodeName',
width: 120
},
{
title: '时间',
width: 160,
render: (_: any, record: any) => (
<>
<span> {dayjs(record.startTime).format('MM-DD HH:mm:ss')}</span>
<br />
{record.endTime > 0 && <span> {dayjs(record.endTime).format('MM-DD HH:mm:ss')}</span>}
</>
)
},
{
title: '耗时',
dataIndex: 'duration',
width: 80,
render: (_: any, record: any) => (
record.duration > 1000 ?
<span>{formatSeconds((record.duration / 1000).toString())}</span> :
<span>{record.duration}ms</span>
)
},
{
title: '状态',
dataIndex: 'state',
width: 60,
align: 'center',
render: (_: any, record: any) => (
<>
{record.state === 1 && <IconCheckCircleFill fontSize={24} style={{ color: 'rgb(var(--green-6))' }} />}
{record.state === 0 && <IconLoading fontSize={24} style={{ color: 'rgb(var(--arcoblue-4))' }} />}
{record.state === -1 && <IconCloseCircleFill fontSize={24} style={{ color: 'rgb(var(--red-6))' }} />}
</>
)
},
{
title: '输入参数',
dataIndex: 'input',
width: 200,
render: (_: any, record: any) => (
<div style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{JSON.stringify(record.input)}</div>
)
},
{
title: '输出参数',
dataIndex: 'output',
width: 200,
render: (_: any, record: any) => (
<div style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{JSON.stringify(record.output)}</div>
)
}
];
// 渲染运行数据内容
const renderRuntimeData = () => {
// 将 runtimeData 对象转换为表格数据数组
const tableData = Object.entries(runtimeData).map(([nodeId, data]: [string, any]) => ({
key: nodeId,
nodeId,
nodeName: data.nodeName,
startTime: data.startTime || 0,
endTime: data.endTime || 0,
duration: data.duration,
state: data.state === '成功' ? 1 : data.state === '失败' ? -1 : 0,
input: data.input,
output: data.output
}));
return (
<div style={{ padding: '10px', height: 'calc(100% - 40px)', overflowY: 'auto' }}>
{tableData.length === 0 ? (
<p></p>
) : (
<Table
columns={dataColumns}
data={tableData}
tableLayoutFixed
scroll={{ x: 'max-content' }}
pagination={false}
/>
)}
</div>
);
};
return (
<div className={styles.instanceCanvas}>
{/* 顶部标题栏 */}
{title && (
<div className={styles.header}>
{onBack && (
<Button
type="text"
icon={<IconLeft />}
onClick={onBack}
className={styles.backButton}
>
</Button>
)}
<h2 className={styles.title}>{title}</h2>
</div>
)}
{/* 主内容区域 */}
<div className={styles.mainContent}>
{/* 流程画布 */}
<div className={styles.canvasArea}>
{loading ? (
<div className={styles.loadingContainer}>
<Spin size={40} />
<span style={{ marginTop: '16px', color: 'var(--color-text-3)' }}>...</span>
</div>
) : flowDataWithStatus ? (
<MemoizedFlowEditor
flowData={flowDataWithStatus}
instanceId={instanceData?.id || 'instance-canvas'}
/>
) : (
<div className={styles.emptyContainer}>
<span></span>
</div>
)}
</div>
{/* 底部日志栏 */}
<div className={styles.logBarContainer}>
<ResizeBox
directions={['top']}
onMoving={handleResize}
style={{
minHeight: '0px',
maxHeight: '80vh',
height: logBarExpanded ? logContainerHeight : '0px',
transition: logBarExpanded ? 'none' : 'height 0.3s ease'
}}
>
<div ref={resizeBoxRef} className={styles.logBar}>
<Tabs
activeTab={activeTab}
type="line"
className={styles.logTabs}
onClickTab={handleTabClick}
>
<TabPane key="1" title="运行日志">
{renderRuntimeLogs()}
</TabPane>
<TabPane key="2" title="运行数据">
{renderRuntimeData()}
</TabPane>
</Tabs>
</div>
</ResizeBox>
{/* Tab 标签栏(始终可见) */}
{!logBarExpanded && (
<div className={styles.collapsedTabBar}>
<Tabs
activeTab={activeTab}
type="line"
className={styles.collapsedTabs}
onClickTab={handleTabClick}
>
<TabPane key="1" title="运行日志" />
<TabPane key="2" title="运行数据" />
</Tabs>
</div>
)}
</div>
</div>
</div>
);
};
export default InstanceCanvas;