deploy-ease-platform/frontend/src/pages/Dashboard/components/DeployFlowGraphModal.tsx
2025-11-14 17:18:15 +08:00

856 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}
/>
)}
</>
);
};