表单CRUD

This commit is contained in:
dengqichen 2025-10-28 09:59:20 +08:00
parent 26663ef9df
commit 2868e294d3
6 changed files with 830 additions and 311 deletions

View File

@ -77,6 +77,8 @@ export const useWorkflowSave = () => {
from: edge.source, // 后端使用from字段
to: edge.target, // 后端使用to字段
name: edge.data?.label || "", // 边的名称
sourceHandle: edge.sourceHandle, // 保存源连接点
targetHandle: edge.targetHandle, // 保存目标连接点
config: {
type: "sequence", // 固定为sequence类型
condition: edge.data?.condition // 保存边条件

View File

@ -1,15 +1,15 @@
import React, { useMemo } from 'react';
import React from 'react';
import { Dialog, DialogPortal, DialogOverlay, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { WorkflowHistoricalInstance, WorkflowInstanceStage } from '../types';
import ReactFlow, { Background, Controls, MiniMap, Node, Edge } from 'reactflow';
import dayjs from 'dayjs';
import 'reactflow/dist/style.css';
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X, Clock, CheckCircle2, XCircle, AlertCircle } from "lucide-react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { WorkflowHistoricalInstance } from '../types';
import { NodeType } from '../../Design/nodes/types';
import FlowGraphTab from './tabs/FlowGraphTab';
import TimelineTab from './tabs/TimelineTab';
import DetailInfoTab from './tabs/DetailInfoTab';
interface DetailModalProps {
visible: boolean;
@ -20,340 +20,135 @@ interface DetailModalProps {
const DetailModal: React.FC<DetailModalProps> = ({ visible, onCancel, instanceData }) => {
if (!instanceData) return null;
// 获取状态徽章
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success'; text: string }> = {
COMPLETED: { variant: 'success', text: '已完成' },
RUNNING: { variant: 'default', text: '运行中' },
FAILED: { variant: 'destructive', text: '失败' },
TERMINATED: { variant: 'secondary', text: '已终止' },
NOT_STARTED: { variant: 'outline', text: '未执行' }
const statusMap: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success'; text: string; className?: string }> = {
COMPLETED: {
variant: 'success',
text: '已完成',
className: 'bg-green-100 text-green-700 border-green-200'
},
RUNNING: {
variant: 'default',
text: '运行中',
className: 'bg-blue-100 text-blue-700 border-blue-200 animate-pulse'
},
FAILED: {
variant: 'destructive',
text: '失败',
className: 'bg-red-100 text-red-700 border-red-200'
},
TERMINATED: {
variant: 'secondary',
text: '已终止',
className: 'bg-orange-100 text-orange-700 border-orange-200'
},
NOT_STARTED: {
variant: 'outline',
text: '未执行',
className: 'bg-gray-50 text-gray-600 border-gray-300'
}
};
const statusInfo = statusMap[status] || { variant: 'outline', text: status };
return <Badge variant={statusInfo.variant}>{statusInfo.text}</Badge>;
const statusInfo = statusMap[status] || statusMap.NOT_STARTED;
return (
<Badge
variant={statusInfo.variant}
className={cn('font-medium', statusInfo.className)}
>
{statusInfo.text}
</Badge>
);
};
// 获取节点类型文本
const getNodeTypeText = (nodeType: string) => {
const nodeTypeMap: Record<string, string> = {
START_EVENT: '开始节点',
END_EVENT: '结束节点',
SERVICE_TASK: '服务任务',
USER_TASK: '用户任务',
SCRIPT_TASK: '脚本任务',
NOTIFICATION: '通知任务',
JENKINS_BUILD: 'Jenkins构建',
APPROVAL: '审批任务',
StartEvent: '开始节点',
EndEvent: '结束节点',
ServiceTask: '服务任务',
UserTask: '用户任务',
ScriptTask: '脚本任务'
const nodeTypeMap: Record<NodeType, string> = {
[NodeType.START_EVENT]: '开始节点',
[NodeType.END_EVENT]: '结束节点',
[NodeType.SERVICE_TASK]: '服务任务',
[NodeType.USER_TASK]: '用户任务',
[NodeType.SCRIPT_TASK]: '脚本任务',
[NodeType.NOTIFICATION]: '通知任务',
[NodeType.JENKINS_BUILD]: 'Jenkins构建',
[NodeType.APPROVAL]: '审批任务',
[NodeType.DEPLOY_NODE]: '部署节点',
[NodeType.GATEWAY_NODE]: '网关节点',
[NodeType.SUB_PROCESS]: '子流程',
[NodeType.CALL_ACTIVITY]: '调用活动',
};
return nodeTypeMap[nodeType] || nodeType;
return nodeTypeMap[nodeType as NodeType] || nodeType;
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'COMPLETED':
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'RUNNING':
return <Clock className="h-4 w-4 text-blue-500 animate-pulse" />;
case 'FAILED':
return <XCircle className="h-4 w-4 text-red-500" />;
default:
return <AlertCircle className="h-4 w-4 text-gray-400" />;
}
};
// 构建节点状态映射
const nodeStatusMap = useMemo(() => {
const map = new Map<string, WorkflowInstanceStage>();
instanceData.stages.forEach(stage => {
map.set(stage.nodeId, stage);
});
return map;
}, [instanceData.stages]);
// 获取节点状态
const getNodeStatus = (nodeId: string): string => {
const stage = nodeStatusMap.get(nodeId);
return stage?.status || 'NOT_STARTED';
};
// 获取节点颜色
const getNodeColor = (status: string) => {
const colorMap: Record<string, string> = {
COMPLETED: '#52c41a',
RUNNING: '#1890ff',
FAILED: '#ff4d4f',
TERMINATED: '#faad14',
NOT_STARTED: '#d9d9d9'
};
return colorMap[status] || '#d9d9d9';
};
// 转换为React Flow节点
const flowNodes: Node[] = useMemo(() => {
if (!instanceData.graph?.nodes) return [];
return instanceData.graph.nodes.map(node => {
const status = getNodeStatus(node.id);
const stage = nodeStatusMap.get(node.id);
return {
id: node.id,
type: 'default',
position: { x: node.position.x + 400, y: node.position.y + 200 },
data: {
label: (
<div style={{ textAlign: 'center', minWidth: '80px' }}>
<div style={{
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px'
}}>
{node.nodeName}
</div>
<div style={{ fontSize: '10px', color: '#666' }}>
{getNodeTypeText(node.nodeType)}
</div>
{stage && (
<div style={{ fontSize: '10px', marginTop: '4px' }}>
{getStatusBadge(status)}
</div>
)}
</div>
)
},
style: {
background: getNodeColor(status),
color: status === 'NOT_STARTED' ? '#333' : '#fff',
border: `2px solid ${getNodeColor(status)}`,
borderRadius: '8px',
padding: '10px',
minWidth: '120px',
boxShadow: status === 'RUNNING' ? '0 0 10px rgba(24, 144, 255, 0.5)' : undefined
}
};
});
}, [instanceData.graph, nodeStatusMap]);
// 判断连线是否已执行
const isEdgeExecuted = (fromNodeId: string, _toNodeId: string): boolean => {
const fromStage = nodeStatusMap.get(fromNodeId);
// 如果起始节点已完成,则连线已执行
return fromStage?.status === 'COMPLETED' || fromStage?.status === 'RUNNING';
};
// 转换为React Flow边
const flowEdges: Edge[] = useMemo(() => {
if (!instanceData.graph?.edges) return [];
return instanceData.graph.edges.map(edge => {
const executed = isEdgeExecuted(edge.from, edge.to);
return {
id: edge.id,
source: edge.from,
target: edge.to,
label: edge.name || undefined,
type: 'smoothstep',
animated: isEdgeExecuted(edge.from, edge.to) && getNodeStatus(edge.to) === 'RUNNING',
style: {
stroke: executed ? '#52c41a' : '#d9d9d9',
strokeWidth: 2
},
labelStyle: {
fontSize: 10,
fill: '#666'
}
};
});
}, [instanceData.graph, nodeStatusMap]);
// 描述信息组件
const DescriptionItem = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex items-start py-2 border-b last:border-b-0">
<div className="w-32 text-sm font-medium text-muted-foreground flex-shrink-0">{label}</div>
<div className="flex-1 text-sm">{value}</div>
</div>
);
return (
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogPortal>
<DialogOverlay className="z-[60]" />
<DialogOverlay className="z-[60] backdrop-blur-sm" />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-[60] grid w-full max-w-7xl translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[90vh] overflow-y-auto"
"fixed left-[50%] top-[50%] z-[60] grid w-full max-w-[90vw]",
"translate-x-[-50%] translate-y-[-50%]",
"gap-4 border-2 bg-background p-6 shadow-2xl",
"duration-300",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
"sm:rounded-xl",
"max-h-[90vh] overflow-y-auto"
)}
>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<DialogPrimitive.Close
className="absolute right-6 top-6 rounded-md opacity-70 ring-offset-background transition-all hover:opacity-100 hover:bg-accent hover:scale-110 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground p-1.5"
onClick={onCancel}
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle className="text-2xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="graph" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="graph"></TabsTrigger>
<TabsTrigger value="timeline">线</TabsTrigger>
<TabsTrigger value="info"></TabsTrigger>
<TabsList className="grid w-full grid-cols-3 h-12">
<TabsTrigger value="graph" className="text-base font-medium">
📊
</TabsTrigger>
<TabsTrigger value="timeline" className="text-base font-medium">
线
</TabsTrigger>
<TabsTrigger value="info" className="text-base font-medium">
📋
</TabsTrigger>
</TabsList>
{/* 流程图标签页 */}
<TabsContent value="graph" className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4">
<DescriptionItem label="业务标识" value={instanceData.businessKey} />
<DescriptionItem label="状态" value={getStatusBadge(instanceData.status)} />
<DescriptionItem
label="开始时间"
value={dayjs(instanceData.startTime).format('YYYY-MM-DD HH:mm:ss')}
/>
<DescriptionItem
label="结束时间"
value={instanceData.endTime ? dayjs(instanceData.endTime).format('YYYY-MM-DD HH:mm:ss') : '暂无'}
/>
</div>
</CardContent>
</Card>
{instanceData.graph ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div style={{ height: '400px', border: '1px solid hsl(var(--border))', borderRadius: '8px' }}>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
fitView
minZoom={0.5}
maxZoom={1.5}
defaultViewport={{ x: 0, y: 0, zoom: 0.8 }}
>
<Background />
<Controls />
<MiniMap
nodeColor={(node: Node) => {
const status = getNodeStatus(node.id);
return getNodeColor(status);
}}
/>
</ReactFlow>
</div>
<div className="mt-4 flex gap-4 justify-center text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-500 rounded" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-300 rounded" />
<span></span>
</div>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<div className="text-center py-10 text-muted-foreground">
</div>
</CardContent>
</Card>
)}
<TabsContent value="graph" className="mt-6">
<FlowGraphTab
instanceData={instanceData}
getStatusBadge={getStatusBadge}
getNodeTypeText={getNodeTypeText}
/>
</TabsContent>
{/* 执行时间线标签页 */}
<TabsContent value="timeline">
<Card>
<CardContent className="pt-6">
<div className="relative space-y-4">
{instanceData.stages.map((stage, index) => (
<div key={stage.id || index} className="flex gap-4">
{/* 时间线图标 */}
<div className="flex flex-col items-center">
<div className="flex items-center justify-center w-8 h-8 rounded-full border-2 bg-background z-10"
style={{
borderColor: stage.status === 'COMPLETED' ? '#52c41a' :
stage.status === 'FAILED' ? '#ff4d4f' :
stage.status === 'RUNNING' ? '#1890ff' : '#d9d9d9'
}}
>
{getStatusIcon(stage.status)}
</div>
{index < instanceData.stages.length - 1 && (
<div className="w-0.5 h-full bg-border mt-1" />
)}
</div>
{/* 时间线内容 */}
<div className="flex-1 pb-8">
<div className="font-medium mb-1 flex items-center gap-2">
{stage.nodeName}
<span className="text-xs text-muted-foreground font-normal">
{getNodeTypeText(stage.nodeType)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{stage.startTime && dayjs(stage.startTime).format('YYYY-MM-DD HH:mm:ss')}
{stage.endTime && `${dayjs(stage.endTime).format('HH:mm:ss')}`}
{stage.startTime && stage.endTime && (
<span className="ml-2">
(: {dayjs(stage.endTime).diff(dayjs(stage.startTime), 'second')})
</span>
)}
</div>
<div>{getStatusBadge(stage.status)}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<TabsContent value="timeline" className="mt-6">
<TimelineTab
instanceData={instanceData}
getStatusBadge={getStatusBadge}
getNodeTypeText={getNodeTypeText}
/>
</TabsContent>
{/* 详细信息标签页 */}
<TabsContent value="info">
<Card>
<CardContent className="pt-6">
<div className="space-y-0">
<DescriptionItem label="流程实例ID" value={instanceData.processInstanceId} />
<DescriptionItem label="流程定义ID" value={instanceData.processDefinitionId} />
<DescriptionItem label="业务标识" value={instanceData.businessKey} />
<DescriptionItem label="状态" value={getStatusBadge(instanceData.status)} />
<DescriptionItem
label="开始时间"
value={dayjs(instanceData.startTime).format('YYYY-MM-DD HH:mm:ss')}
/>
<DescriptionItem
label="结束时间"
value={instanceData.endTime ? dayjs(instanceData.endTime).format('YYYY-MM-DD HH:mm:ss') : '暂无'}
/>
{instanceData.startTime && instanceData.endTime && (
<DescriptionItem
label="总耗时"
value={`${dayjs(instanceData.endTime).diff(dayjs(instanceData.startTime), 'second')}`}
/>
)}
<DescriptionItem label="执行节点数" value={instanceData.stages.length} />
</div>
</CardContent>
</Card>
<TabsContent value="info" className="mt-6">
<DetailInfoTab
instanceData={instanceData}
getStatusBadge={getStatusBadge}
/>
</TabsContent>
</Tabs>
</DialogPrimitive.Content>

View File

@ -0,0 +1,219 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
FileText, Hash, Key, Calendar, Clock, Activity,
CheckCircle, List, Timer
} from 'lucide-react';
import dayjs from 'dayjs';
import { WorkflowHistoricalInstance } from '../../types';
interface DetailInfoTabProps {
instanceData: WorkflowHistoricalInstance;
getStatusBadge: (status: string) => React.ReactNode;
}
const DetailInfoTab: React.FC<DetailInfoTabProps> = ({
instanceData,
getStatusBadge
}) => {
// 信息项组件
const InfoItem = ({
icon: Icon,
label,
value,
valueClass = ''
}: {
icon: any;
label: string;
value: React.ReactNode;
valueClass?: string;
}) => (
<div className="flex items-start py-3 px-4 rounded-lg hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 w-48 flex-shrink-0">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">{label}</span>
</div>
<div className={`flex-1 text-sm ${valueClass}`}>
{value}
</div>
</div>
);
// 计算总耗时
const totalDuration = instanceData.startTime && instanceData.endTime
? dayjs(instanceData.endTime).diff(dayjs(instanceData.startTime), 'second')
: null;
// 格式化耗时
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}${remainingSeconds}`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}小时${remainingMinutes}${remainingSeconds}`;
};
return (
<div className="space-y-4">
{/* 基本信息 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-4 pb-2 border-b">
<FileText className="h-5 w-5 text-primary" />
<h3 className="font-semibold text-lg"></h3>
</div>
<div className="space-y-1">
<InfoItem
icon={Hash}
label="流程实例ID"
value={
<span className="font-mono text-xs break-all">
{instanceData.processInstanceId}
</span>
}
/>
<InfoItem
icon={Hash}
label="流程定义ID"
value={
<span className="font-mono text-xs break-all">
{instanceData.processDefinitionId}
</span>
}
/>
<InfoItem
icon={Key}
label="业务标识"
value={
<span className="font-mono font-semibold">
{instanceData.businessKey}
</span>
}
/>
<InfoItem
icon={CheckCircle}
label="执行状态"
value={getStatusBadge(instanceData.status)}
/>
</div>
</CardContent>
</Card>
{/* 时间信息 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-4 pb-2 border-b">
<Clock className="h-5 w-5 text-primary" />
<h3 className="font-semibold text-lg"></h3>
</div>
<div className="space-y-1">
<InfoItem
icon={Calendar}
label="开始时间"
value={
<span className="font-mono">
{dayjs(instanceData.startTime).format('YYYY-MM-DD HH:mm:ss')}
</span>
}
/>
<InfoItem
icon={Calendar}
label="结束时间"
value={
instanceData.endTime ? (
<span className="font-mono">
{dayjs(instanceData.endTime).format('YYYY-MM-DD HH:mm:ss')}
</span>
) : (
<span className="text-blue-500 font-medium flex items-center gap-2">
<Activity className="h-4 w-4 animate-pulse" />
...
</span>
)
}
/>
{totalDuration !== null && (
<InfoItem
icon={Timer}
label="总耗时"
value={
<span className="font-semibold text-primary text-base">
{formatDuration(totalDuration)}
</span>
}
/>
)}
</div>
</CardContent>
</Card>
{/* 执行统计 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-4 pb-2 border-b">
<List className="h-5 w-5 text-primary" />
<h3 className="font-semibold text-lg"></h3>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 总节点数 */}
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-blue-600 mb-1"></p>
<p className="text-2xl font-bold text-blue-700">
{instanceData.stages.length}
</p>
</div>
<List className="h-8 w-8 text-blue-300" />
</div>
</div>
{/* 已完成节点 */}
<div className="p-4 rounded-lg bg-green-50 border border-green-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-green-600 mb-1"></p>
<p className="text-2xl font-bold text-green-700">
{instanceData.stages.filter(s => s.status === 'COMPLETED').length}
</p>
</div>
<CheckCircle className="h-8 w-8 text-green-300" />
</div>
</div>
{/* 运行中节点 */}
<div className="p-4 rounded-lg bg-orange-50 border border-orange-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-orange-600 mb-1"></p>
<p className="text-2xl font-bold text-orange-700">
{instanceData.stages.filter(s => s.status === 'RUNNING').length}
</p>
</div>
<Activity className="h-8 w-8 text-orange-300 animate-pulse" />
</div>
</div>
{/* 失败节点 */}
<div className="p-4 rounded-lg bg-red-50 border border-red-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-red-600 mb-1"></p>
<p className="text-2xl font-bold text-red-700">
{instanceData.stages.filter(s => s.status === 'FAILED').length}
</p>
</div>
<activity className="h-8 w-8 text-red-300" />
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default DetailInfoTab;

View File

@ -0,0 +1,334 @@
import React, { useMemo } from 'react';
import { ReactFlow, Background, Controls, MiniMap, Node, Edge, Handle, Position, BackgroundVariant } from '@xyflow/react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import dayjs from 'dayjs';
import '@xyflow/react/dist/style.css';
import { WorkflowHistoricalInstance, WorkflowInstanceStage } from '../../types';
import { NodeType } from '../../../Design/nodes/types';
interface FlowGraphTabProps {
instanceData: WorkflowHistoricalInstance;
getStatusBadge: (status: string) => React.ReactNode;
getNodeTypeText: (nodeType: string) => string;
}
// 自定义节点组件 - 增强版
const EnhancedCustomNode: React.FC<any> = ({ data, selected }) => {
const nodeType = data.nodeType as NodeType;
const status = data.status;
// 根据状态判断是否需要虚线边框
const isNotStarted = status === 'NOT_STARTED';
return (
<div className="relative group">
{/* 输入连接点 */}
{nodeType !== NodeType.START_EVENT && (
<Handle
type="target"
position={Position.Left}
className="!w-3 !h-3 !rounded-full !border-2 !border-white !shadow-md transition-transform hover:!scale-125"
style={{ background: data.handleColor || '#1890ff' }}
/>
)}
{/* 节点内容 */}
<Card
className={`
min-w-[140px] max-w-[240px] transition-all duration-200
${selected ? 'ring-2 ring-primary ring-offset-2' : ''}
${isNotStarted ? 'border-dashed border-2' : 'shadow-md'}
hover:shadow-xl hover:scale-105
`}
style={{
background: data.background,
color: data.textColor,
borderColor: data.borderColor,
}}
>
<CardContent className="p-3">
{data.label}
</CardContent>
</Card>
{/* 输出连接点 */}
{nodeType !== NodeType.END_EVENT && (
<Handle
type="source"
position={Position.Right}
className="!w-3 !h-3 !rounded-full !border-2 !border-white !shadow-md transition-transform hover:!scale-125"
style={{ background: data.handleColor || '#1890ff' }}
/>
)}
</div>
);
};
const nodeTypes = {
enhanced: EnhancedCustomNode
};
const FlowGraphTab: React.FC<FlowGraphTabProps> = ({
instanceData,
getStatusBadge,
getNodeTypeText
}) => {
// 构建节点状态映射
const nodeStatusMap = useMemo(() => {
const map = new Map<string, WorkflowInstanceStage>();
instanceData.stages.forEach(stage => {
map.set(stage.nodeId, stage);
});
return map;
}, [instanceData.stages]);
// 获取节点状态
const getNodeStatus = (nodeId: string): string => {
const stage = nodeStatusMap.get(nodeId);
return stage?.status || 'NOT_STARTED';
};
// 获取节点颜色
const getNodeColor = (status: string) => {
const colorMap: Record<string, { bg: string; border: string; text: string }> = {
COMPLETED: {
bg: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
border: '#52c41a',
text: '#fff'
},
RUNNING: {
bg: 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)',
border: '#1890ff',
text: '#fff'
},
FAILED: {
bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)',
border: '#ff4d4f',
text: '#fff'
},
TERMINATED: {
bg: 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
border: '#faad14',
text: '#fff'
},
NOT_STARTED: {
bg: '#fafafa',
border: '#d9d9d9',
text: '#666'
}
};
return colorMap[status] || colorMap.NOT_STARTED;
};
// 转换为React Flow节点
const flowNodes: Node[] = useMemo(() => {
if (!instanceData.graph?.nodes) return [];
return instanceData.graph.nodes.map(node => {
const status = getNodeStatus(node.id);
const stage = nodeStatusMap.get(node.id);
const colors = getNodeColor(status);
return {
id: node.id,
type: 'enhanced',
position: { x: node.position.x, y: node.position.y },
data: {
nodeType: node.nodeType,
status: status,
handleColor: colors.border,
background: colors.bg,
borderColor: colors.border,
textColor: colors.text,
label: (
<div className="space-y-2">
<div className="font-semibold text-sm">
{node.nodeName}
</div>
<div className="text-xs opacity-80">
{getNodeTypeText(node.nodeType)}
</div>
{stage && (
<div className="flex justify-center">
{getStatusBadge(status)}
</div>
)}
{stage?.startTime && (
<div className="text-xs opacity-70 mt-1">
{dayjs(stage.startTime).format('HH:mm:ss')}
</div>
)}
</div>
)
}
};
});
}, [instanceData.graph, nodeStatusMap]);
// 判断连线是否已执行
const isEdgeExecuted = (fromNodeId: string): boolean => {
const fromStage = nodeStatusMap.get(fromNodeId);
return fromStage?.status === 'COMPLETED' || fromStage?.status === 'RUNNING';
};
// 转换为React Flow边
const flowEdges: Edge[] = useMemo(() => {
if (!instanceData.graph?.edges) return [];
return instanceData.graph.edges.map(edge => {
const executed = isEdgeExecuted(edge.from);
const fromStatus = getNodeStatus(edge.from);
const toStatus = getNodeStatus(edge.to);
// 判断是否应该使用虚线
const isDashed = !executed || toStatus === 'NOT_STARTED';
return {
id: edge.id,
source: edge.from,
target: edge.to,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.name || undefined,
type: 'smoothstep',
animated: executed && toStatus === 'RUNNING',
style: {
stroke: executed ? '#52c41a' : '#d9d9d9',
strokeWidth: executed ? 3 : 2,
strokeDasharray: isDashed ? '5,5' : undefined, // 虚线效果
},
markerEnd: {
type: 'arrowclosed' as const,
color: executed ? '#52c41a' : '#d9d9d9',
},
labelStyle: {
fontSize: 11,
fontWeight: 500,
fill: executed ? '#52c41a' : '#999',
},
labelBgStyle: {
fill: '#fff',
fillOpacity: 0.9,
}
};
});
}, [instanceData.graph, nodeStatusMap]);
return (
<div className="space-y-4">
{/* 基本信息卡片 */}
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start py-2">
<div className="w-24 text-sm font-medium text-muted-foreground flex-shrink-0"></div>
<div className="flex-1 text-sm font-mono">{instanceData.businessKey}</div>
</div>
<div className="flex items-start py-2">
<div className="w-24 text-sm font-medium text-muted-foreground flex-shrink-0"></div>
<div className="flex-1">{getStatusBadge(instanceData.status)}</div>
</div>
<div className="flex items-start py-2">
<div className="w-24 text-sm font-medium text-muted-foreground flex-shrink-0"></div>
<div className="flex-1 text-sm">{dayjs(instanceData.startTime).format('YYYY-MM-DD HH:mm:ss')}</div>
</div>
<div className="flex items-start py-2">
<div className="w-24 text-sm font-medium text-muted-foreground flex-shrink-0"></div>
<div className="flex-1 text-sm">
{instanceData.endTime ? dayjs(instanceData.endTime).format('YYYY-MM-DD HH:mm:ss') : '进行中...'}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 流程图卡片 */}
{instanceData.graph ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<span className="text-sm font-normal text-muted-foreground">
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg border-2 border-border overflow-hidden" style={{ height: '500px' }}>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
nodeTypes={nodeTypes}
fitView
minZoom={0.3}
maxZoom={2}
defaultViewport={{ x: 0, y: 0, zoom: 0.8 }}
attributionPosition="bottom-right"
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#e0e0e0"
/>
<Controls
showInteractive={false}
className="!shadow-lg !border !border-border"
/>
<MiniMap
nodeColor={(node: Node) => {
const status = getNodeStatus(node.id);
const colors = getNodeColor(status);
return colors.border;
}}
className="!shadow-lg !border !border-border"
maskColor="rgba(0, 0, 0, 0.05)"
/>
</ReactFlow>
</div>
{/* 图例 */}
<div className="mt-4 flex flex-wrap gap-4 justify-center text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gradient-to-br from-green-500 to-green-400 rounded shadow-sm" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gradient-to-br from-blue-500 to-blue-400 rounded shadow-sm animate-pulse" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gradient-to-br from-red-500 to-red-400 rounded shadow-sm" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-200 border-2 border-dashed border-gray-400 rounded" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-12 h-0.5 bg-green-500" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-12 h-0.5 border-t-2 border-dashed border-gray-400" />
<span></span>
</div>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<div className="text-center py-10 text-muted-foreground">
</div>
</CardContent>
</Card>
)}
</div>
);
};
export default FlowGraphTab;

View File

@ -0,0 +1,167 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Clock, CheckCircle2, XCircle, AlertCircle, Play } from 'lucide-react';
import dayjs from 'dayjs';
import { WorkflowHistoricalInstance } from '../../types';
interface TimelineTabProps {
instanceData: WorkflowHistoricalInstance;
getStatusBadge: (status: string) => React.ReactNode;
getNodeTypeText: (nodeType: string) => string;
}
const TimelineTab: React.FC<TimelineTabProps> = ({
instanceData,
getStatusBadge,
getNodeTypeText
}) => {
const getStatusIcon = (status: string) => {
switch (status) {
case 'COMPLETED':
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case 'RUNNING':
return <Play className="h-5 w-5 text-blue-500 animate-pulse" />;
case 'FAILED':
return <XCircle className="h-5 w-5 text-red-500" />;
default:
return <AlertCircle className="h-5 w-5 text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':
return {
border: 'border-green-500',
bg: 'bg-green-50',
line: 'bg-green-200'
};
case 'RUNNING':
return {
border: 'border-blue-500',
bg: 'bg-blue-50',
line: 'bg-blue-200'
};
case 'FAILED':
return {
border: 'border-red-500',
bg: 'bg-red-50',
line: 'bg-red-200'
};
default:
return {
border: 'border-gray-300',
bg: 'bg-gray-50',
line: 'bg-gray-200'
};
}
};
return (
<Card>
<CardContent className="pt-6">
<div className="relative space-y-6">
{instanceData.stages.map((stage, index) => {
const colors = getStatusColor(stage.status);
const duration = stage.startTime && stage.endTime
? dayjs(stage.endTime).diff(dayjs(stage.startTime), 'second')
: null;
return (
<div key={stage.id || index} className="flex gap-6 relative">
{/* 时间线左侧 */}
<div className="flex flex-col items-center w-16">
{/* 时间轴圆点 */}
<div
className={`
flex items-center justify-center
w-10 h-10 rounded-full border-2 ${colors.border} ${colors.bg}
shadow-md transition-all duration-200
hover:scale-110 hover:shadow-lg
z-10
`}
>
{getStatusIcon(stage.status)}
</div>
{/* 连接线 */}
{index < instanceData.stages.length - 1 && (
<div className={`w-0.5 flex-1 ${colors.line} mt-2`} />
)}
</div>
{/* 时间线内容 */}
<div className="flex-1 pb-6">
<Card className="shadow-sm hover:shadow-md transition-shadow duration-200">
<CardContent className="pt-4">
{/* 节点名称和类型 */}
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-semibold text-base mb-1">
{stage.nodeName}
</h4>
<p className="text-sm text-muted-foreground">
{getNodeTypeText(stage.nodeType)}
</p>
</div>
<div>
{getStatusBadge(stage.status)}
</div>
</div>
{/* 时间信息 */}
<div className="space-y-2">
{stage.startTime && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span className="font-mono">
{dayjs(stage.startTime).format('YYYY-MM-DD HH:mm:ss')}
</span>
</div>
)}
{stage.endTime && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span className="font-mono">
{dayjs(stage.endTime).format('YYYY-MM-DD HH:mm:ss')}
</span>
</div>
)}
{duration !== null && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground"></span>
<span className="font-semibold text-primary">
{duration < 60
? `${duration}`
: `${Math.floor(duration / 60)}${duration % 60}`
}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
})}
</div>
{/* 空状态 */}
{instanceData.stages.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
);
};
export default TimelineTab;

View File

@ -65,6 +65,8 @@ export interface WorkflowGraphEdge {
};
};
vertices: any[];
sourceHandle?: string; // 源节点连接点
targetHandle?: string; // 目标节点连接点
}
/**