|
|
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;
|