856 lines
30 KiB
TypeScript
856 lines
30 KiB
TypeScript
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||
import { ReactFlowProvider, ReactFlow, Background, Node, Edge, Handle, Position, BackgroundVariant, NodeProps, Controls, MiniMap } from '@xyflow/react';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Loader2,
|
||
AlertCircle,
|
||
User,
|
||
Building2,
|
||
Layers,
|
||
Calendar,
|
||
Clock,
|
||
CheckCircle2,
|
||
XCircle,
|
||
AlertTriangle,
|
||
FileText,
|
||
ArrowRightLeft,
|
||
ArrowDownUp
|
||
} from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import '@xyflow/react/dist/style.css';
|
||
import dagre from 'dagre';
|
||
import { getDeployRecordFlowGraph } from '../service';
|
||
import type { DeployRecordFlowGraph, WorkflowNodeInstance } from '../types';
|
||
import {
|
||
getStatusIcon,
|
||
getStatusText,
|
||
getNodeStatusText,
|
||
getNodeStatusColor,
|
||
formatTime,
|
||
formatDuration,
|
||
calculateRunningDuration
|
||
} from '../utils/dashboardUtils';
|
||
import DeployNodeLogDialog from './DeployNodeLogDialog';
|
||
|
||
interface DeployFlowGraphModalProps {
|
||
open: boolean;
|
||
deployRecordId: number | null;
|
||
onOpenChange: (open: boolean) => void;
|
||
}
|
||
|
||
interface CustomNodeData {
|
||
nodeName: string;
|
||
nodeType: string;
|
||
nodeId: string;
|
||
status: string;
|
||
startTime?: string | null;
|
||
endTime?: string | null;
|
||
duration?: number | null;
|
||
errorMessage?: string | null;
|
||
processInstanceId?: string;
|
||
onViewLog?: (nodeId: string, nodeName: string) => void;
|
||
}
|
||
|
||
type LayoutDirection = 'TB' | 'LR';
|
||
|
||
/**
|
||
* 使用 dagre 进行自动布局
|
||
*/
|
||
const getLayoutedElements = (
|
||
nodes: Node[],
|
||
edges: Edge[],
|
||
direction: LayoutDirection = 'TB'
|
||
) => {
|
||
const dagreGraph = new dagre.graphlib.Graph();
|
||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||
|
||
const nodeWidth = 180;
|
||
const nodeHeight = 120;
|
||
|
||
const isHorizontal = direction === 'LR';
|
||
dagreGraph.setGraph({
|
||
rankdir: direction,
|
||
nodesep: isHorizontal ? 90 : 120, // 同一层节点间距:横向80,纵向100
|
||
ranksep: isHorizontal ? 170 : 160, // 层级间距:横向150,纵向120
|
||
marginx: 40,
|
||
marginy: 40,
|
||
align: 'UL', // 对齐方式
|
||
});
|
||
|
||
nodes.forEach((node) => {
|
||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||
});
|
||
|
||
edges.forEach((edge) => {
|
||
dagreGraph.setEdge(edge.source, edge.target);
|
||
});
|
||
|
||
dagre.layout(dagreGraph);
|
||
|
||
const layoutedNodes = nodes.map((node) => {
|
||
const nodeWithPosition = dagreGraph.node(node.id);
|
||
return {
|
||
...node,
|
||
position: {
|
||
x: nodeWithPosition.x - nodeWidth / 2,
|
||
y: nodeWithPosition.y - nodeHeight / 2,
|
||
},
|
||
};
|
||
});
|
||
|
||
return { nodes: layoutedNodes, edges };
|
||
};
|
||
|
||
/**
|
||
* 自定义流程节点组件
|
||
*/
|
||
const CustomFlowNode: React.FC<any> = ({ data }) => {
|
||
const { nodeName, nodeType, status, startTime, endTime, duration, errorMessage } = data as CustomNodeData;
|
||
const statusColor = getNodeStatusColor(status);
|
||
const isNotStarted = status === 'NOT_STARTED';
|
||
const isRunning = status === 'RUNNING';
|
||
const hasFailed = status === 'FAILED';
|
||
|
||
// 判断是否可以查看日志(具有日志输出能力的节点类型)
|
||
const loggableNodeTypes = ['JENKINS_BUILD', 'ServiceTask'];
|
||
const canViewLog = loggableNodeTypes.includes(nodeType) && status !== 'NOT_STARTED';
|
||
|
||
// 计算显示的时长
|
||
const displayDuration = useMemo(() => {
|
||
if (duration !== null && duration !== undefined) {
|
||
// 后端返回的 duration 单位已经是毫秒
|
||
return formatDuration(duration);
|
||
}
|
||
if (isRunning && startTime) {
|
||
const runningDuration = calculateRunningDuration(startTime);
|
||
return formatDuration(runningDuration);
|
||
}
|
||
return null;
|
||
}, [duration, isRunning, startTime]);
|
||
|
||
const nodeContent = (
|
||
<div
|
||
className={cn(
|
||
'px-4 py-3 rounded-lg w-[180px] min-h-[120px] transition-all duration-200 relative overflow-hidden flex flex-col',
|
||
isNotStarted && 'border-2 border-dashed bg-gradient-to-br from-slate-50 to-gray-50',
|
||
!isNotStarted && 'border-2 border-solid shadow-lg hover:shadow-2xl bg-white',
|
||
isRunning && 'ring-2 ring-blue-400 ring-opacity-60 shadow-blue-200',
|
||
canViewLog && 'cursor-pointer hover:shadow-2xl hover:scale-105 active:scale-100'
|
||
)}
|
||
style={{
|
||
borderColor: statusColor,
|
||
borderWidth: '3px',
|
||
}}
|
||
>
|
||
{/* 顶部状态指示条 */}
|
||
{!isNotStarted && (
|
||
<div
|
||
className="absolute top-0 left-0 right-0 h-1"
|
||
style={{ backgroundColor: statusColor, opacity: 0.6 }}
|
||
/>
|
||
)}
|
||
|
||
{/* 节点名称 */}
|
||
<div className="flex items-center justify-between gap-2 mb-2 mt-0.5">
|
||
<div className="font-semibold text-sm truncate text-gray-900">{nodeName}</div>
|
||
{canViewLog && (
|
||
<FileText className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||
)}
|
||
</div>
|
||
|
||
{/* 节点状态 - 使用徽章样式 */}
|
||
<div
|
||
className={cn(
|
||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold mb-1",
|
||
isRunning && "animate-pulse"
|
||
)}
|
||
style={{
|
||
backgroundColor: `${statusColor}20`,
|
||
color: statusColor,
|
||
border: `1.5px solid ${statusColor}`
|
||
}}
|
||
>
|
||
{getNodeStatusText(status)}
|
||
</div>
|
||
|
||
{/* 时间信息 - 更紧凑的显示 */}
|
||
{!isNotStarted && displayDuration && (
|
||
<div className="text-xs text-muted-foreground mb-1">
|
||
<div className="font-medium text-blue-600">
|
||
⏱️ {displayDuration}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 弹性空间,让底部提示始终在底部 */}
|
||
<div className="flex-1 min-h-[2px]" />
|
||
|
||
{/* 错误提示 - 增强样式 */}
|
||
{hasFailed && errorMessage && (
|
||
<div className="flex items-center gap-1 text-xs text-red-600">
|
||
<AlertCircle className="h-3.5 w-3.5 text-red-600 flex-shrink-0" />
|
||
<span className="font-medium">查看错误</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 查看日志提示 - 增强样式 */}
|
||
{canViewLog && !hasFailed && (
|
||
<div className="flex items-center gap-1 text-xs text-blue-600">
|
||
<FileText className="h-3.5 w-3.5 text-blue-600 flex-shrink-0" />
|
||
<span className="font-medium">查看日志</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{/* 输入连接点 */}
|
||
{nodeType !== 'START_EVENT' && (
|
||
<Handle type="target" position={Position.Left} />
|
||
)}
|
||
|
||
{/* 节点内容 - 如果有错误信息,包装在 Tooltip 中 */}
|
||
{hasFailed && errorMessage ? (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
{nodeContent}
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top" className="max-w-sm">
|
||
<p className="font-medium mb-1">错误信息:</p>
|
||
<p className="text-xs">{errorMessage}</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
) : (
|
||
nodeContent
|
||
)}
|
||
|
||
{/* 输出连接点 */}
|
||
{nodeType !== 'END_EVENT' && (
|
||
<Handle type="source" position={Position.Right} />
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
const nodeTypes = {
|
||
custom: CustomFlowNode,
|
||
};
|
||
|
||
/**
|
||
* 业务信息卡片
|
||
*/
|
||
const BusinessInfoCard: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
<Building2 className="h-4 w-4" />
|
||
业务信息
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">应用名称:</span>
|
||
<span className="font-medium">{flowData.applicationName}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">应用编码:</span>
|
||
<span className="font-mono text-xs">{flowData.applicationCode}</span>
|
||
</div>
|
||
<Separator />
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">部署环境:</span>
|
||
<span className="font-medium">{flowData.environmentName}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">所属团队:</span>
|
||
<span className="font-medium">{flowData.teamName}</span>
|
||
</div>
|
||
<Separator />
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground flex items-center gap-1">
|
||
<User className="h-3 w-3" />
|
||
部署人:
|
||
</span>
|
||
<span className="font-medium">{flowData.deployBy}</span>
|
||
</div>
|
||
{flowData.deployRemark && (
|
||
<>
|
||
<Separator />
|
||
<div>
|
||
<span className="text-muted-foreground">备注:</span>
|
||
<p className="text-xs mt-1 text-muted-foreground">{flowData.deployRemark}</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 进度信息卡片
|
||
*/
|
||
const ProgressCard: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
|
||
const { executedNodeCount, totalNodeCount, successNodeCount, failedNodeCount, runningNodeCount } = flowData;
|
||
const progress = totalNodeCount > 0 ? (executedNodeCount / totalNodeCount) * 100 : 0;
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
<Layers className="h-4 w-4" />
|
||
执行进度
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div>
|
||
<div className="flex justify-between items-center mb-3">
|
||
<span className="text-sm font-semibold text-blue-600">已执行</span>
|
||
<span className="text-xl font-bold text-blue-600">
|
||
{executedNodeCount} / {totalNodeCount}
|
||
</span>
|
||
</div>
|
||
<div className="relative">
|
||
<Progress value={progress} className="h-3 bg-blue-100" />
|
||
<div className="absolute inset-0 flex items-center justify-end pr-2">
|
||
<span className="text-xs font-bold text-white drop-shadow">
|
||
{Math.round(progress)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||
<div className="flex flex-col items-center p-3 rounded-lg bg-gradient-to-br from-green-50 to-emerald-50 border border-green-100">
|
||
<CheckCircle2 className="h-5 w-5 text-green-600 mb-1.5" />
|
||
<span className="text-muted-foreground text-[10px]">成功</span>
|
||
<span className="font-bold text-lg text-green-600">{successNodeCount}</span>
|
||
</div>
|
||
<div className="flex flex-col items-center p-3 rounded-lg bg-gradient-to-br from-red-50 to-rose-50 border border-red-100">
|
||
<XCircle className="h-5 w-5 text-red-600 mb-1.5" />
|
||
<span className="text-muted-foreground text-[10px]">失败</span>
|
||
<span className="font-bold text-lg text-red-600">{failedNodeCount}</span>
|
||
</div>
|
||
<div className="flex flex-col items-center p-3 rounded-lg bg-gradient-to-br from-blue-50 to-cyan-50 border border-blue-100">
|
||
<Loader2 className={cn("h-5 w-5 text-blue-600 mb-1.5", runningNodeCount > 0 && "animate-spin")} />
|
||
<span className="text-muted-foreground text-[10px]">运行中</span>
|
||
<span className="font-bold text-lg text-blue-600">{runningNodeCount}</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 时长统计卡片
|
||
*/
|
||
const DurationCard: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
|
||
const { deployStartTime, deployDuration, nodeInstances, runningNodeCount } = flowData;
|
||
const isRunning = runningNodeCount > 0;
|
||
|
||
// 计算当前总时长
|
||
const currentDuration = useMemo(() => {
|
||
if (deployDuration) return deployDuration; // 后端返回的已经是毫秒
|
||
if (isRunning) return calculateRunningDuration(deployStartTime);
|
||
return 0;
|
||
}, [deployDuration, isRunning, deployStartTime]);
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
<Clock className="h-4 w-4" />
|
||
时长统计
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground flex items-center gap-1">
|
||
<Calendar className="h-3 w-3" />
|
||
开始时间:
|
||
</span>
|
||
<span className="font-medium">
|
||
{deployStartTime ? formatTime(deployStartTime) : '-'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">总时长:</span>
|
||
<span className="font-medium">
|
||
{formatDuration(currentDuration)}
|
||
{isRunning && <span className="text-blue-600 ml-1">(运行中...)</span>}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div>
|
||
<div className="text-xs text-muted-foreground mb-2">节点执行时长:</div>
|
||
<div className="space-y-1.5 text-xs max-h-32 overflow-y-auto">
|
||
{nodeInstances
|
||
.filter(ni => ni.status !== 'NOT_STARTED')
|
||
.map((ni) => {
|
||
// 后端返回的 duration 单位已经是毫秒
|
||
const nodeDuration = ni.duration
|
||
? ni.duration
|
||
: (ni.status === 'RUNNING' ? calculateRunningDuration(ni.startTime) : 0);
|
||
return (
|
||
<div key={ni.nodeId} className="flex justify-between items-center">
|
||
<span className="truncate flex-1" title={ni.nodeName}>
|
||
{ni.nodeName}
|
||
</span>
|
||
<span className="font-mono ml-2" style={{ color: getNodeStatusColor(ni.status) }}>
|
||
{formatDuration(nodeDuration)}
|
||
{ni.status === 'RUNNING' && '...'}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 实时监控提示
|
||
*/
|
||
const MonitoringAlert: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
|
||
const { runningNodeCount } = flowData;
|
||
|
||
if (runningNodeCount === 0) return null;
|
||
|
||
return (
|
||
<Alert className="border-blue-200 bg-blue-50">
|
||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||
<AlertDescription className="text-sm text-blue-900">
|
||
<span className="font-medium">部署正在进行中</span>
|
||
<span className="block text-xs mt-1">
|
||
当前有 {runningNodeCount} 个节点正在执行,请稍候...
|
||
</span>
|
||
</AlertDescription>
|
||
</Alert>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 信息面板(左侧边栏)
|
||
*/
|
||
const DeployInfoPanel: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
|
||
return (
|
||
<div className="w-80 flex-shrink-0 border-r-2 border-border bg-gradient-to-b from-muted/20 to-muted/40 p-4 space-y-4 overflow-y-auto shadow-[2px_0_8px_rgba(0,0,0,0.05)]">
|
||
<MonitoringAlert flowData={flowData} />
|
||
<BusinessInfoCard flowData={flowData} />
|
||
<ProgressCard flowData={flowData} />
|
||
<DurationCard flowData={flowData} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 部署流程图模态框(主组件)
|
||
*/
|
||
export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
|
||
open,
|
||
deployRecordId,
|
||
onOpenChange,
|
||
}) => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [flowData, setFlowData] = useState<DeployRecordFlowGraph | null>(null);
|
||
const [layoutDirection, setLayoutDirection] = useState<LayoutDirection>('TB');
|
||
|
||
// 日志对话框状态
|
||
const [logDialogOpen, setLogDialogOpen] = useState(false);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string>('');
|
||
const [selectedNodeName, setSelectedNodeName] = useState<string>('');
|
||
|
||
// 查看日志处理函数
|
||
const handleViewLog = (nodeId: string, nodeName: string) => {
|
||
setSelectedNodeId(nodeId);
|
||
setSelectedNodeName(nodeName);
|
||
setLogDialogOpen(true);
|
||
};
|
||
|
||
// 切换布局方向
|
||
const toggleLayoutDirection = () => {
|
||
setLayoutDirection(prev => prev === 'TB' ? 'LR' : 'TB');
|
||
};
|
||
|
||
// ReactFlow 节点点击事件处理
|
||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||
const nodeData = node.data as unknown as CustomNodeData;
|
||
|
||
// 可以查看日志的节点类型
|
||
const loggableNodeTypes = ['JENKINS_BUILD', 'ServiceTask'];
|
||
const canViewLog = loggableNodeTypes.includes(nodeData.nodeType) && nodeData.status !== 'NOT_STARTED';
|
||
|
||
if (canViewLog) {
|
||
console.log('Node clicked, opening log dialog:', nodeData.nodeId, nodeData.nodeName);
|
||
handleViewLog(nodeData.nodeId, nodeData.nodeName);
|
||
} else {
|
||
console.log('Node clicked but cannot view log:', nodeData.nodeType, nodeData.status);
|
||
}
|
||
}, []);
|
||
|
||
// 加载流程图数据
|
||
useEffect(() => {
|
||
if (open && deployRecordId) {
|
||
setLoading(true);
|
||
getDeployRecordFlowGraph(deployRecordId)
|
||
.then((data) => {
|
||
setFlowData(data);
|
||
})
|
||
.catch((error) => {
|
||
console.error('加载部署流程图失败:', error);
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
} else {
|
||
setFlowData(null);
|
||
}
|
||
}, [open, deployRecordId]);
|
||
|
||
// 创建节点ID到实例的映射
|
||
const nodeInstanceMap = useMemo(() => {
|
||
if (!flowData?.nodeInstances) return new Map<string, WorkflowNodeInstance>();
|
||
const map = new Map<string, WorkflowNodeInstance>();
|
||
flowData.nodeInstances.forEach((instance) => {
|
||
map.set(instance.nodeId, instance);
|
||
});
|
||
return map;
|
||
}, [flowData]);
|
||
|
||
// 智能过滤:只显示实际执行路径 + 到终点的路径
|
||
const visibleNodeIds = useMemo(() => {
|
||
if (!flowData?.graph?.nodes || !flowData?.graph?.edges || !flowData?.nodeInstances) {
|
||
return new Set<string>();
|
||
}
|
||
|
||
const visible = new Set<string>();
|
||
|
||
// 1. 收集所有已执行的节点(id !== null 表示后端创建了实例)
|
||
const executedNodes = flowData.nodeInstances.filter(ni => ni.id !== null);
|
||
executedNodes.forEach(ni => visible.add(ni.nodeId));
|
||
|
||
// 如果没有执行任何节点,显示所有节点(初始状态)
|
||
if (executedNodes.length === 0) {
|
||
flowData.graph.nodes.forEach(n => visible.add(n.id));
|
||
return visible;
|
||
}
|
||
|
||
// 2. 找出最后执行的节点(按时间排序)
|
||
const lastExecutedNode = [...executedNodes].sort((a, b) => {
|
||
const timeA = a.startTime ? new Date(a.startTime.replace(' ', 'T')).getTime() : 0;
|
||
const timeB = b.startTime ? new Date(b.startTime.replace(' ', 'T')).getTime() : 0;
|
||
return timeB - timeA; // 降序
|
||
})[0];
|
||
|
||
// 3. 找出所有终点节点(没有出边的节点,通常是 END_EVENT)
|
||
const allNodeIds = new Set(flowData.graph.nodes.map(n => n.id));
|
||
const nodesWithOutgoingEdges = new Set(flowData.graph.edges.map(e => e.from));
|
||
const endNodes = flowData.graph.nodes.filter(n => !nodesWithOutgoingEdges.has(n.id));
|
||
|
||
// 4. BFS 从最后执行的节点到所有终点节点的路径
|
||
if (lastExecutedNode && endNodes.length > 0) {
|
||
const queue = [lastExecutedNode.nodeId];
|
||
const visited = new Set<string>([lastExecutedNode.nodeId]);
|
||
|
||
while (queue.length > 0) {
|
||
const currentId = queue.shift()!;
|
||
const outgoingEdges = flowData.graph.edges.filter(e => e.from === currentId);
|
||
|
||
for (const edge of outgoingEdges) {
|
||
if (!visited.has(edge.to)) {
|
||
visited.add(edge.to);
|
||
visible.add(edge.to); // 添加到可见集合
|
||
queue.push(edge.to);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return visible;
|
||
}, [flowData]);
|
||
|
||
// 转换为 React Flow 节点(只显示可见节点)
|
||
const flowNodes: Node[] = useMemo(() => {
|
||
if (!flowData?.graph?.nodes || !flowData?.nodeInstances) return [];
|
||
|
||
// 过滤并转换为 React Flow 节点
|
||
const nodes = flowData.graph.nodes
|
||
.filter(node => visibleNodeIds.has(node.id)) // 只显示可见节点
|
||
.map((node) => {
|
||
const instance = nodeInstanceMap.get(node.id);
|
||
|
||
return {
|
||
id: node.id,
|
||
type: 'custom',
|
||
position: { x: 0, y: 0 }, // 忽略设计器坐标,使用dagre自动布局
|
||
data: {
|
||
nodeName: node.nodeName,
|
||
nodeType: instance?.nodeType || node.nodeType, // 优先使用运行时的 nodeType
|
||
nodeId: node.id,
|
||
status: instance?.status || 'NOT_STARTED',
|
||
startTime: instance?.startTime,
|
||
endTime: instance?.endTime,
|
||
duration: instance?.duration,
|
||
errorMessage: instance?.errorMessage,
|
||
processInstanceId: flowData.processInstanceId,
|
||
},
|
||
};
|
||
});
|
||
|
||
return nodes;
|
||
}, [flowData, nodeInstanceMap, visibleNodeIds]);
|
||
|
||
// 转换为 React Flow 边(只显示连接可见节点的边)
|
||
const flowEdges: Edge[] = useMemo(() => {
|
||
if (!flowData?.graph?.edges) return [];
|
||
|
||
return flowData.graph.edges
|
||
.filter(edge => {
|
||
// 只显示连接可见节点的边
|
||
return visibleNodeIds.has(edge.from) && visibleNodeIds.has(edge.to);
|
||
})
|
||
.map((edge, index) => {
|
||
const source = edge.from;
|
||
const target = edge.to;
|
||
const sourceInstance = nodeInstanceMap.get(source);
|
||
const targetInstance = nodeInstanceMap.get(target);
|
||
const sourceStatus = sourceInstance?.status || 'NOT_STARTED';
|
||
const targetStatus = targetInstance?.status || 'NOT_STARTED';
|
||
|
||
// 根据节点状态确定边的样式
|
||
let strokeColor = '#cbd5e1'; // 默认浅灰色
|
||
let strokeWidth = 3; // 增加连线粗细
|
||
let animated = false;
|
||
let strokeDasharray: string | undefined = undefined;
|
||
|
||
// 源节点已完成 + 目标节点也已完成/运行中 = 绿色实线
|
||
if (sourceStatus === 'COMPLETED' && (targetStatus === 'COMPLETED' || targetStatus === 'RUNNING')) {
|
||
strokeColor = '#10b981'; // 绿色
|
||
strokeWidth = 3.5;
|
||
}
|
||
// 源节点 TERMINATED = 橙色实线
|
||
else if (sourceStatus === 'TERMINATED') {
|
||
strokeColor = '#f59e0b'; // 橙色
|
||
strokeWidth = 3.5;
|
||
}
|
||
// 源节点失败 = 红色实线
|
||
else if (sourceStatus === 'FAILED') {
|
||
strokeColor = '#ef4444'; // 红色
|
||
strokeWidth = 3.5;
|
||
}
|
||
// 源节点运行中 = 蓝色动画
|
||
else if (sourceStatus === 'RUNNING') {
|
||
strokeColor = '#3b82f6'; // 蓝色
|
||
strokeWidth = 4; // 运行中的线更粗
|
||
animated = true;
|
||
}
|
||
// 源节点完成 + 目标节点未开始 = 虚线(即将执行的路径)
|
||
else if ((sourceStatus === 'COMPLETED' || sourceStatus === 'TERMINATED') && targetStatus === 'NOT_STARTED') {
|
||
strokeColor = '#94a3b8'; // 稍深的灰色
|
||
strokeDasharray = '8,4';
|
||
strokeWidth = 2.5;
|
||
}
|
||
|
||
return {
|
||
id: edge.id || `edge-${source}-${target}-${index}`,
|
||
source,
|
||
target,
|
||
type: layoutDirection === 'TB' ? 'step' : 'smoothstep', // TB用直角折线,LR用平滑曲线
|
||
animated,
|
||
style: {
|
||
stroke: strokeColor,
|
||
strokeWidth,
|
||
strokeDasharray,
|
||
},
|
||
markerEnd: {
|
||
type: 'arrowclosed',
|
||
color: strokeColor,
|
||
width: 24,
|
||
height: 24,
|
||
},
|
||
};
|
||
});
|
||
}, [flowData, nodeInstanceMap, visibleNodeIds, layoutDirection]);
|
||
|
||
// 应用自动布局
|
||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||
if (flowNodes.length === 0) {
|
||
return { nodes: [], edges: [] };
|
||
}
|
||
return getLayoutedElements(flowNodes, flowEdges, layoutDirection);
|
||
}, [flowNodes, flowEdges, layoutDirection]);
|
||
|
||
// 获取部署状态信息
|
||
const deployStatusInfo = flowData
|
||
? (() => {
|
||
const { icon: StatusIcon, color } = getStatusIcon(flowData.deployStatus);
|
||
return {
|
||
icon: StatusIcon,
|
||
color,
|
||
text: getStatusText(flowData.deployStatus),
|
||
};
|
||
})()
|
||
: null;
|
||
|
||
return (
|
||
<>
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="!max-w-7xl w-[90vw] h-[85vh] flex flex-col p-0 overflow-hidden">
|
||
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
|
||
<DialogTitle className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
{flowData && (
|
||
<span className="text-muted-foreground">
|
||
#{flowData.deployRecordId}
|
||
</span>
|
||
)}
|
||
<span>部署流程图</span>
|
||
{deployStatusInfo && (
|
||
<Badge
|
||
variant="outline"
|
||
className={cn('flex items-center gap-1', deployStatusInfo.color)}
|
||
>
|
||
<deployStatusInfo.icon
|
||
className={cn(
|
||
'h-3 w-3',
|
||
flowData?.deployStatus === 'RUNNING' && 'animate-spin'
|
||
)}
|
||
/>
|
||
{deployStatusInfo.text}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* 布局切换按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={toggleLayoutDirection}
|
||
className="flex items-center gap-2"
|
||
>
|
||
{layoutDirection === 'TB' ? (
|
||
<>
|
||
<ArrowDownUp className="h-4 w-4" />
|
||
<span>垂直布局</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ArrowRightLeft className="h-4 w-4" />
|
||
<span>水平布局</span>
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="flex flex-1 overflow-hidden min-h-0">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-full w-full">
|
||
<div className="text-center space-y-4">
|
||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||
<p className="text-sm text-muted-foreground">加载流程图数据...</p>
|
||
</div>
|
||
</div>
|
||
) : flowData ? (
|
||
<>
|
||
{/* 左侧信息面板 */}
|
||
<DeployInfoPanel flowData={flowData} />
|
||
|
||
{/* 右侧流程图可视化 */}
|
||
<div className="flex-1 relative">
|
||
<ReactFlowProvider>
|
||
<ReactFlow
|
||
nodes={layoutedNodes}
|
||
edges={layoutedEdges}
|
||
nodeTypes={nodeTypes}
|
||
onNodeClick={onNodeClick}
|
||
fitView
|
||
className="bg-muted/10"
|
||
fitViewOptions={{ padding: 0.22, maxZoom: 1.2, minZoom: 0.3 }}
|
||
nodesDraggable={false}
|
||
nodesConnectable={false}
|
||
elementsSelectable={true}
|
||
panOnScroll={true}
|
||
zoomOnScroll={true}
|
||
zoomOnPinch={true}
|
||
preventScrolling={false}
|
||
minZoom={0.1}
|
||
maxZoom={2}
|
||
>
|
||
<Background
|
||
variant={BackgroundVariant.Dots}
|
||
gap={20}
|
||
size={1.5}
|
||
color="#cbd5e1"
|
||
className="opacity-40"
|
||
/>
|
||
<Controls
|
||
position="bottom-right"
|
||
showZoom={true}
|
||
showFitView={true}
|
||
showInteractive={false}
|
||
className="!shadow-xl !border-2 !border-border !rounded-lg !overflow-hidden"
|
||
/>
|
||
<MiniMap
|
||
position="bottom-left"
|
||
nodeColor={(node: Node) => {
|
||
const nodeData = node.data as unknown as CustomNodeData;
|
||
return getNodeStatusColor(nodeData.status);
|
||
}}
|
||
maskColor="rgba(0, 0, 0, 0.1)"
|
||
className="!shadow-lg !border !border-border"
|
||
style={{
|
||
height: 100,
|
||
width: 150,
|
||
}}
|
||
/>
|
||
</ReactFlow>
|
||
</ReactFlowProvider>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full w-full">
|
||
<div className="text-center">
|
||
<AlertTriangle className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
|
||
<p className="text-sm text-muted-foreground">暂无流程图数据</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 节点日志对话框 */}
|
||
{flowData && (
|
||
<DeployNodeLogDialog
|
||
open={logDialogOpen}
|
||
onOpenChange={setLogDialogOpen}
|
||
processInstanceId={flowData.processInstanceId}
|
||
nodeId={selectedNodeId}
|
||
nodeName={selectedNodeName}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
};
|