更换变量显示组件

This commit is contained in:
dengqichen 2025-11-05 16:22:27 +08:00
parent ed19478379
commit d0997f9e9f
9 changed files with 1291 additions and 288 deletions

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -21,11 +21,12 @@ import {
import { formatDuration, formatTime, getStatusIcon, getStatusText } from '../utils/dashboardUtils'; import { formatDuration, formatTime, getStatusIcon, getStatusText } from '../utils/dashboardUtils';
import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types'; import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types';
import { DeployFlowGraphModal } from './DeployFlowGraphModal'; import { DeployFlowGraphModal } from './DeployFlowGraphModal';
import { DeployConfirmDialog } from './DeployConfirmDialog';
interface ApplicationCardProps { interface ApplicationCardProps {
app: ApplicationConfig; app: ApplicationConfig;
environment: DeployEnvironment; environment: DeployEnvironment;
onDeploy: (app: ApplicationConfig) => void; onDeploy: (app: ApplicationConfig, remark: string) => void;
isDeploying: boolean; isDeploying: boolean;
} }
@ -37,6 +38,7 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
}) => { }) => {
const [selectedDeployRecordId, setSelectedDeployRecordId] = useState<number | null>(null); const [selectedDeployRecordId, setSelectedDeployRecordId] = useState<number | null>(null);
const [flowModalOpen, setFlowModalOpen] = useState(false); const [flowModalOpen, setFlowModalOpen] = useState(false);
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
const handleDeployRecordClick = (record: DeployRecord) => { const handleDeployRecordClick = (record: DeployRecord) => {
setSelectedDeployRecordId(record.id); setSelectedDeployRecordId(record.id);
@ -330,7 +332,7 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
{/* 部署按钮 */} {/* 部署按钮 */}
<Button <Button
onClick={() => onDeploy(app)} onClick={() => setDeployDialogOpen(true)}
disabled={!environment.enabled || isDeploying || app.isDeploying} disabled={!environment.enabled || isDeploying || app.isDeploying}
size="sm" size="sm"
className={cn( className={cn(
@ -351,6 +353,16 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
)} )}
</Button> </Button>
{/* 部署确认对话框 */}
<DeployConfirmDialog
open={deployDialogOpen}
onOpenChange={setDeployDialogOpen}
onConfirm={(remark) => onDeploy(app, remark)}
applicationName={app.applicationName}
environmentName={environment.environmentName}
loading={isDeploying || app.isDeploying}
/>
{/* 部署流程图模态框 */} {/* 部署流程图模态框 */}
<DeployFlowGraphModal <DeployFlowGraphModal
open={flowModalOpen} open={flowModalOpen}

View File

@ -0,0 +1,150 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { Rocket } from 'lucide-react';
// 表单验证规则
const formSchema = z.object({
remark: z.string()
.min(1, '请填写部署任务号')
.regex(
/^\[(task|bugfix)-\d+\].+/,
'格式必须为 [task-数字]任务描述 或 [bugfix-数字]任务描述,例如:[task-001]修复登录问题'
),
});
type FormValues = z.infer<typeof formSchema>;
interface DeployConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (remark: string) => void;
applicationName: string;
environmentName: string;
loading?: boolean;
}
export const DeployConfirmDialog: React.FC<DeployConfirmDialogProps> = ({
open,
onOpenChange,
onConfirm,
applicationName,
environmentName,
loading = false,
}) => {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
remark: '',
},
});
// 当对话框打开/关闭时重置表单
React.useEffect(() => {
if (open) {
form.reset();
}
}, [open, form]);
const onSubmit = (values: FormValues) => {
onConfirm(values.remark);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5 text-primary" />
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogBody className="space-y-4">
{/* 部署信息 */}
<div className="p-3 bg-muted/50 rounded-lg space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[56px]"></span>
<span className="font-medium">{applicationName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[56px]"></span>
<span className="font-medium">{environmentName}</span>
</div>
</div>
{/* 任务号输入 */}
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base">
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="例如:[task-001]修复登录问题"
{...field}
disabled={loading}
/>
</FormControl>
<FormDescription className="text-xs leading-tight">
<code className="px-1.5 py-0.5 bg-muted rounded">[task-]</code> <code className="px-1.5 py-0.5 bg-muted rounded">[bugfix-]</code> +
</FormDescription>
{/* 错误信息容器 - 固定高度避免布局抖动 */}
<div className="min-h-[3rem]">
<FormMessage />
</div>
</FormItem>
)}
/>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
className="bg-primary hover:bg-primary/90"
>
{loading ? '提交中...' : '确认部署'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -2,12 +2,36 @@ import React, { useEffect, useState, useMemo } from 'react';
import { ReactFlowProvider, ReactFlow, Background, Controls, MiniMap, Node, Edge, Handle, Position, BackgroundVariant } from '@xyflow/react'; import { ReactFlowProvider, ReactFlow, Background, Controls, MiniMap, Node, Edge, Handle, Position, BackgroundVariant } from '@xyflow/react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react'; 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 {
Loader2,
AlertCircle,
User,
Building2,
Layers,
Calendar,
Clock,
CheckCircle2,
XCircle,
AlertTriangle
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { getDeployRecordFlowGraph } from '../service'; import { getDeployRecordFlowGraph } from '../service';
import type { DeployRecordFlowGraph, WorkflowNodeInstance } from '../types'; import type { DeployRecordFlowGraph, WorkflowNodeInstance } from '../types';
import { getStatusIcon, getStatusText } from '../utils/dashboardUtils'; import {
getStatusIcon,
getStatusText,
getNodeStatusText,
getNodeStatusColor,
formatTime,
formatDuration,
calculateRunningDuration
} from '../utils/dashboardUtils';
interface DeployFlowGraphModalProps { interface DeployFlowGraphModalProps {
open: boolean; open: boolean;
@ -15,104 +39,328 @@ interface DeployFlowGraphModalProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
// 节点状态颜色映射 /**
const nodeStatusColorMap: Record<string, { bg: string; border: string; text: string }> = { *
NOT_STARTED: { */
bg: '#fafafa', const CustomFlowNode: React.FC<any> = ({ data }) => {
border: '#d9d9d9', const { nodeName, nodeType, status, startTime, endTime, duration, errorMessage } = data;
text: '#666' const statusColor = getNodeStatusColor(status);
},
RUNNING: {
bg: 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)',
border: '#1890ff',
text: '#fff'
},
COMPLETED: {
bg: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
border: '#52c41a',
text: '#fff'
},
FAILED: {
bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)',
border: '#ff4d4f',
text: '#fff'
},
REJECTED: {
bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)',
border: '#ff4d4f',
text: '#fff'
},
CANCELLED: {
bg: '#d9d9d9',
border: '#bfbfbf',
text: '#666'
},
TERMINATED: {
bg: 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
border: '#faad14',
text: '#fff'
}
};
// 简版自定义节点组件(只显示节点名和状态)
const CustomFlowNode: React.FC<any> = ({ data, selected }) => {
const status = data.status || 'NOT_STARTED';
const colors = nodeStatusColorMap[status] || nodeStatusColorMap.NOT_STARTED;
const isNotStarted = status === 'NOT_STARTED'; const isNotStarted = status === 'NOT_STARTED';
const isRunning = status === 'RUNNING'; const isRunning = status === 'RUNNING';
const nodeType = data.nodeType; const hasFailed = status === 'FAILED';
return ( // 计算显示的时长
<div className="relative group"> const displayDuration = useMemo(() => {
{/* 输入连接点 */} if (duration !== null && duration !== undefined) {
{nodeType !== 'START_EVENT' && ( // 后端返回的 duration 单位已经是毫秒
<Handle return formatDuration(duration);
type="target" }
position={Position.Left} if (isRunning && startTime) {
className="!w-2 !h-2 !rounded-full !border-2 !border-white" const runningDuration = calculateRunningDuration(startTime);
style={{ background: colors.border }} return formatDuration(runningDuration);
/> }
)} return null;
}, [duration, isRunning, startTime]);
{/* 节点内容 - 简化版 */} const nodeContent = (
<div <div
className={cn( className={cn(
'min-w-[80px] max-w-[120px] transition-all duration-200 rounded-md p-2', 'px-4 py-3 rounded-md min-w-[160px] transition-all',
selected ? 'ring-1 ring-primary' : '', isNotStarted && 'border-2 border-dashed',
isNotStarted ? 'border-dashed border' : 'shadow-sm', !isNotStarted && 'border-2 border-solid shadow-sm',
isRunning && 'animate-pulse'
)} )}
style={{ style={{
background: colors.bg, borderColor: statusColor,
color: colors.text, backgroundColor: isNotStarted ? '#f9fafb' : '#ffffff',
borderColor: colors.border,
}} }}
> >
<div className="text-center"> {/* 节点名称 */}
<div className="font-medium text-xs mb-0.5 leading-tight">{data.nodeName}</div> <div className="font-medium text-sm mb-1">{nodeName}</div>
<div className="text-[10px] opacity-80 leading-tight">{getStatusText(status)}</div>
{isRunning && <Loader2 className="h-3 w-3 animate-spin mx-auto mt-0.5" />} {/* 节点状态 */}
<div
className="text-xs font-medium mb-1"
style={{ color: statusColor }}
>
{getNodeStatusText(status)}
</div> </div>
{/* 时间信息 */}
{!isNotStarted && (
<div className="text-xs text-muted-foreground space-y-0.5">
{startTime && <div>: {formatTime(startTime)}</div>}
{endTime && <div>: {formatTime(endTime)}</div>}
{displayDuration && (
<div className="font-medium">
{isRunning ? '运行中: ' : '时长: '}
{displayDuration}
</div> </div>
)}
</div>
)}
{/* 错误提示 */}
{hasFailed && errorMessage && (
<div className="mt-2 flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="text-xs"></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' && ( {nodeType !== 'END_EVENT' && (
<Handle <Handle type="source" position={Position.Right} />
type="source"
position={Position.Right}
className="!w-2 !h-2 !rounded-full !border-2 !border-white"
style={{ background: colors.border }}
/>
)} )}
</div> </>
); );
}; };
const nodeTypes = { const nodeTypes = {
default: CustomFlowNode, 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 text-sm mb-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{executedNodeCount} / {totalNodeCount}
</span>
</div>
<Progress value={progress} className="h-2" />
<div className="text-xs text-muted-foreground mt-1 text-right">
{Math.round(progress)}%
</div>
</div>
<Separator />
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col items-center p-2 rounded-md bg-green-50">
<CheckCircle2 className="h-4 w-4 text-green-600 mb-1" />
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-600">{successNodeCount}</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-red-50">
<XCircle className="h-4 w-4 text-red-600 mb-1" />
<span className="text-muted-foreground"></span>
<span className="font-medium text-red-600">{failedNodeCount}</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-blue-50">
<Loader2 className="h-4 w-4 text-blue-600 mb-1" />
<span className="text-muted-foreground"></span>
<span className="font-medium 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 bg-muted/30 p-4 space-y-4 overflow-y-auto">
<MonitoringAlert flowData={flowData} />
<BusinessInfoCard flowData={flowData} />
<ProgressCard flowData={flowData} />
<DurationCard flowData={flowData} />
</div>
);
};
/**
*
*/ */
export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
open, open,
@ -141,7 +389,7 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
} }
}, [open, deployRecordId]); }, [open, deployRecordId]);
// 创建节点状态映射(用于快速查找) // 创建节点ID到实例的映射
const nodeInstanceMap = useMemo(() => { const nodeInstanceMap = useMemo(() => {
if (!flowData?.nodeInstances) return new Map<string, WorkflowNodeInstance>(); if (!flowData?.nodeInstances) return new Map<string, WorkflowNodeInstance>();
const map = new Map<string, WorkflowNodeInstance>(); const map = new Map<string, WorkflowNodeInstance>();
@ -151,196 +399,102 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
return map; return map;
}, [flowData]); }, [flowData]);
// 获取节点状态(通过匹配 nodeInstances // 转换为 React Flow 节点(按执行顺序重新布局)
const getNodeStatus = (nodeId: string): string => {
const instance = nodeInstanceMap.get(nodeId);
return instance ? instance.status : 'NOT_STARTED';
};
// 计算节点拓扑顺序(用于自动布局)
const calculateNodeOrder = useMemo(() => {
if (!flowData?.graph?.nodes || !flowData?.graph?.edges) {
return new Map<string, number>();
}
const edges = flowData.graph.edges;
const nodes = flowData.graph.nodes;
const nodeIds = new Set(nodes.map(n => n.id));
// 构建图的邻接表和入度
const adjacencyList = new Map<string, string[]>();
const inDegree = new Map<string, number>();
// 初始化
nodes.forEach(node => {
adjacencyList.set(node.id, []);
inDegree.set(node.id, 0);
});
// 构建图
edges.forEach(edge => {
const source = edge.from;
const target = edge.to;
if (nodeIds.has(source) && nodeIds.has(target)) {
adjacencyList.get(source)!.push(target);
inDegree.set(target, (inDegree.get(target) || 0) + 1);
}
});
// 拓扑排序:找到所有没有入边的节点(起始节点)
const queue: string[] = [];
nodes.forEach(node => {
if (inDegree.get(node.id) === 0) {
queue.push(node.id);
}
});
// 拓扑排序
const topoOrder: string[] = [];
const processed = new Set<string>();
let currentLevelNodes = [...queue];
while (currentLevelNodes.length > 0) {
const nextLevelNodes: string[] = [];
currentLevelNodes.forEach(nodeId => {
if (processed.has(nodeId)) return;
processed.add(nodeId);
topoOrder.push(nodeId);
// 找到所有从当前节点出发的边,减少目标节点的入度
adjacencyList.get(nodeId)?.forEach(targetId => {
const currentInDegree = (inDegree.get(targetId) || 0) - 1;
inDegree.set(targetId, currentInDegree);
if (currentInDegree === 0 && !processed.has(targetId)) {
nextLevelNodes.push(targetId);
}
});
});
currentLevelNodes = nextLevelNodes;
}
// 处理剩余节点
nodes.forEach(node => {
if (!processed.has(node.id)) {
topoOrder.push(node.id);
}
});
// 创建顺序映射
const orderMap = new Map<string, number>();
topoOrder.forEach((nodeId, index) => {
orderMap.set(nodeId, index);
});
return orderMap;
}, [flowData]);
// 转换为 React Flow 节点(自动水平布局,简化版)
const flowNodes: Node[] = useMemo(() => { const flowNodes: Node[] = useMemo(() => {
if (!flowData?.graph?.nodes) return []; if (!flowData?.graph?.nodes || !flowData?.nodeInstances) return [];
// 按拓扑顺序排序节点 // 按执行顺序排序已执行的节点
const sortedNodes = Array.from(flowData.graph.nodes).sort((a, b) => { const executedInstances = flowData.nodeInstances
const orderA = calculateNodeOrder.get(a.id) ?? Infinity; .filter(ni => ni.status !== 'NOT_STARTED')
const orderB = calculateNodeOrder.get(b.id) ?? Infinity; .sort((a, b) => {
return orderA - orderB; 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 timeA - timeB;
}); });
// 自动水平排列(紧凑布局) // 创建节点位置映射(已执行的节点)
const horizontalSpacing = 150; // 缩小间距 const nodePositionMap = new Map<string, { x: number; y: number }>();
const startX = 100; const horizontalSpacing = 250;
const startY = 200; // 固定Y坐标所有节点在同一行 const startX = 50;
const startY = 150;
return sortedNodes.map((node, index) => { executedInstances.forEach((instance, index) => {
// 1. 匹配执行状态 nodePositionMap.set(instance.nodeId, {
x: startX + index * horizontalSpacing,
y: startY,
});
});
// 为所有节点生成位置(包括未执行的)
return flowData.graph.nodes.map((node) => {
const instance = nodeInstanceMap.get(node.id); const instance = nodeInstanceMap.get(node.id);
const status = instance ? instance.status : 'NOT_STARTED';
const colors = nodeStatusColorMap[status] || nodeStatusColorMap.NOT_STARTED; // 如果节点已执行,使用计算的位置;否则使用原始位置但偏移到下方
let position = nodePositionMap.get(node.id);
if (!position) {
// 未执行的节点放在下方,使用原始相对位置
position = {
x: node.position.x + 500,
y: node.position.y + 400,
};
}
return { return {
id: node.id, id: node.id,
type: 'default', type: 'custom',
// 自动水平布局 position,
position: {
x: startX + index * horizontalSpacing,
y: startY,
},
data: { data: {
nodeName: node.nodeName, nodeName: node.nodeName,
nodeType: node.nodeType, nodeType: node.nodeType,
status: status, status: instance?.status || 'NOT_STARTED',
}, startTime: instance?.startTime,
style: { endTime: instance?.endTime,
background: colors.bg, duration: instance?.duration,
borderColor: colors.border, errorMessage: instance?.errorMessage,
color: colors.text,
}, },
}; };
}); });
}, [flowData, nodeInstanceMap, calculateNodeOrder]); }, [flowData, nodeInstanceMap]);
// 判断边是否已执行 // 转换为 React Flow 边
const isEdgeExecuted = (sourceNodeId: string): boolean => {
const sourceStatus = getNodeStatus(sourceNodeId);
return sourceStatus === 'COMPLETED' || sourceStatus === 'RUNNING';
};
// 判断边是否中断
const isEdgeInterrupted = (sourceNodeId: string): boolean => {
const sourceStatus = getNodeStatus(sourceNodeId);
return sourceStatus === 'FAILED' || sourceStatus === 'REJECTED';
};
// 渲染连线(使用 edge.from 和 edge.to
const flowEdges: Edge[] = useMemo(() => { const flowEdges: Edge[] = useMemo(() => {
if (!flowData?.graph?.edges) return []; if (!flowData?.graph?.edges) return [];
const edges: Edge[] = []; return flowData.graph.edges.map((edge, index) => {
flowData.graph.edges.forEach((edge, index) => {
// 使用 edge.from 和 edge.to
const source = edge.from; const source = edge.from;
const target = edge.to; const target = edge.to;
const sourceInstance = nodeInstanceMap.get(source);
const sourceStatus = sourceInstance?.status || 'NOT_STARTED';
if (!source || !target) { // 根据源节点状态确定边的颜色
console.warn('边数据不完整:', edge); let strokeColor = '#d1d5db'; // 默认灰色
return; if (sourceStatus === 'COMPLETED') {
strokeColor = '#10b981'; // 绿色
} else if (sourceStatus === 'FAILED') {
strokeColor = '#ef4444'; // 红色
} else if (sourceStatus === 'RUNNING') {
strokeColor = '#3b82f6'; // 蓝色
} }
const executed = isEdgeExecuted(source); return {
const interrupted = isEdgeInterrupted(source); id: edge.id || `edge-${source}-${target}-${index}`,
const targetStatus = getNodeStatus(target);
const isDashed = !executed || targetStatus === 'NOT_STARTED';
let strokeColor = '#d9d9d9';
if (interrupted) {
strokeColor = '#ff4d4f';
} else if (executed) {
strokeColor = '#52c41a';
}
edges.push({
id: `edge-${source}-${target}-${index}`,
source, source,
target, target,
type: 'smoothstep' as const, type: 'smoothstep',
animated: executed && targetStatus === 'RUNNING', // 不显示边的标签(条件表达式等)
animated: sourceStatus === 'RUNNING',
style: { style: {
stroke: strokeColor, stroke: strokeColor,
strokeWidth: executed ? 3 : 2, strokeWidth: 2,
strokeDasharray: isDashed ? '5,5' : undefined,
}, },
markerEnd: { markerEnd: {
type: 'arrowclosed' as const, type: 'arrowclosed',
color: strokeColor, color: strokeColor,
width: 20,
height: 20,
}, },
};
}); });
});
return edges;
}, [flowData, nodeInstanceMap]); }, [flowData, nodeInstanceMap]);
// 获取部署状态信息 // 获取部署状态信息
@ -357,9 +511,14 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-6xl w-[90vw] h-[90vh] flex flex-col p-0 overflow-hidden"> <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"> <DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{flowData && (
<span className="text-muted-foreground">
#{flowData.deployRecordId}
</span>
)}
<span></span> <span></span>
{deployStatusInfo && ( {deployStatusInfo && (
<Badge <Badge
@ -375,51 +534,68 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
{deployStatusInfo.text} {deployStatusInfo.text}
</Badge> </Badge>
)} )}
{flowData && (
<span className="text-sm text-muted-foreground font-normal ml-auto">
ID: #{flowData.deployRecordId}
</span>
)}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-hidden relative min-h-0"> <div className="flex flex-1 overflow-hidden min-h-0">
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full w-full">
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" /> <Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground">...</p> <p className="text-sm text-muted-foreground">...</p>
</div> </div>
</div> </div>
) : flowData ? ( ) : flowData ? (
<>
{/* 左侧信息面板 */}
<DeployInfoPanel flowData={flowData} />
{/* 右侧流程图可视化 */}
<div className="flex-1 relative">
<ReactFlowProvider> <ReactFlowProvider>
<ReactFlow <ReactFlow
nodes={flowNodes} nodes={flowNodes}
edges={flowEdges} edges={flowEdges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
className="bg-background" className="bg-muted/10"
fitViewOptions={{ padding: 0.15, maxZoom: 1.2 }}
> >
<Background variant={BackgroundVariant.Dots} gap={12} size={1} /> <Background
<Controls /> variant={BackgroundVariant.Dots}
gap={16}
size={1}
className="opacity-30"
/>
<Controls
className="bg-background/80 backdrop-blur-sm border shadow-sm"
showZoom={true}
showFitView={true}
showInteractive={true}
/>
<MiniMap <MiniMap
nodeColor={(node: any) => { nodeColor={(node: any) => {
const status = node.data?.status || 'NOT_STARTED'; const status = node.data?.status || 'NOT_STARTED';
const colors = nodeStatusColorMap[status] || nodeStatusColorMap.NOT_STARTED; return getNodeStatusColor(status);
return colors.border;
}} }}
className="bg-background border" className="bg-background/80 backdrop-blur-sm border shadow-sm"
pannable={true}
zoomable={true}
/> />
</ReactFlow> </ReactFlow>
</ReactFlowProvider> </ReactFlowProvider>
</div>
</>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <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> <p className="text-sm text-muted-foreground"></p>
</div> </div>
</div>
)} )}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View File

@ -0,0 +1,383 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogLoading,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
ClipboardCheck,
User,
Calendar,
FileText,
Package,
Clock,
CheckCircle,
XCircle,
} from "lucide-react";
import { useToast } from '@/components/ui/use-toast';
import { getMyApprovalTasks, completeApproval } from '../service';
import { formatTime, formatDuration } from '../utils/dashboardUtils';
import { ApprovalResult, type PendingApprovalTask } from '../types';
import { DeployFlowGraphModal } from './DeployFlowGraphModal';
interface PendingApprovalModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const PendingApprovalModal: React.FC<PendingApprovalModalProps> = ({
open,
onOpenChange,
}) => {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [approvalList, setApprovalList] = useState<PendingApprovalTask[]>([]);
const [selectedDeployRecordId, setSelectedDeployRecordId] = useState<number | null>(null);
const [flowModalOpen, setFlowModalOpen] = useState(false);
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<PendingApprovalTask | null>(null);
const [approvalResult, setApprovalResult] = useState<ApprovalResult | null>(null);
const [approvalComment, setApprovalComment] = useState('');
const [submitting, setSubmitting] = useState(false);
// 加载待审批列表
const loadApprovalList = async () => {
try {
setLoading(true);
const response = await getMyApprovalTasks();
if (response) {
setApprovalList(response || []);
}
} catch (error: any) {
toast({
variant: 'destructive',
title: '加载失败',
description: error.response?.data?.message || '获取待审批列表失败',
});
} finally {
setLoading(false);
}
};
// 当弹窗打开时加载数据
useEffect(() => {
if (open) {
loadApprovalList();
}
}, [open]);
// 处理查看详情
const handleViewDetail = (task: PendingApprovalTask) => {
setSelectedDeployRecordId(task.deployRecordId);
setFlowModalOpen(true);
};
// 打开审批对话框
const handleOpenApproval = (task: PendingApprovalTask, result: ApprovalResult) => {
setSelectedTask(task);
setApprovalResult(result);
setApprovalComment('');
setApprovalDialogOpen(true);
};
// 提交审批
const handleSubmitApproval = async () => {
if (!selectedTask || !approvalResult) return;
// 如果是必须填写意见且没有填写,提示用户
if (selectedTask.requireComment && !approvalComment.trim()) {
toast({
variant: 'destructive',
title: '请填写审批意见',
description: '该审批任务要求必须填写意见',
});
return;
}
try {
setSubmitting(true);
await completeApproval({
taskId: selectedTask.taskId,
result: approvalResult,
comment: approvalComment.trim() || undefined,
});
toast({
title: '审批成功',
description: approvalResult === ApprovalResult.APPROVED ? '已通过该部署申请' : '已拒绝该部署申请',
});
// 关闭审批对话框
setApprovalDialogOpen(false);
// 重新加载列表
await loadApprovalList();
} catch (error: any) {
toast({
variant: 'destructive',
title: '审批失败',
description: error.response?.data?.message || '提交审批失败,请稍后重试',
});
} finally {
setSubmitting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-orange-600" />
{!loading && (
<Badge variant="secondary" className="ml-2 bg-orange-50 text-orange-700 border-orange-200">
{approvalList.length}
</Badge>
)}
</DialogTitle>
</DialogHeader>
<DialogBody className="overflow-y-auto">
{loading ? (
<DialogLoading />
) : approvalList.length === 0 ? (
// 空状态
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="p-4 rounded-full bg-orange-50 mb-4">
<ClipboardCheck className="h-12 w-12 text-orange-400" />
</div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
// 列表内容
<div className="space-y-3">
{approvalList.map((task) => (
<div
key={task.taskId}
className="group relative p-4 border rounded-lg hover:border-primary/50 hover:shadow-md transition-all bg-card"
>
{/* 左侧内容区 */}
<div className="flex gap-4">
{/* 应用图标 */}
<div className="shrink-0">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
<Package className="h-6 w-6 text-primary" />
</div>
</div>
{/* 主要信息 */}
<div className="flex-1 min-w-0 space-y-2">
{/* 标题行 */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-bold text-foreground truncate">
{task.applicationName}
</h3>
<span className="shrink-0 text-xs px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded font-medium">
#{task.deployRecordId}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<code className="px-1.5 py-0.5 bg-muted rounded font-mono">
{task.applicationCode}
</code>
<span></span>
<span>{task.environmentName}</span>
<span></span>
<span>{task.teamName}</span>
</div>
</div>
{/* 右侧操作按钮 */}
<div className="flex gap-2 shrink-0">
<Button
size="sm"
className="h-8 bg-green-600 hover:bg-green-700 text-white shadow-sm"
onClick={() => handleOpenApproval(task, ApprovalResult.APPROVED)}
>
<CheckCircle className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
className="h-8 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
onClick={() => handleOpenApproval(task, ApprovalResult.REJECTED)}
>
<XCircle className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</div>
{/* 审批内容 */}
{(task.approvalTitle || task.approvalContent) && (
<div className="p-2.5 bg-orange-50/60 border border-orange-100 rounded text-sm">
{task.approvalTitle && (
<div className="font-medium text-foreground mb-0.5">
{task.approvalTitle}
</div>
)}
{task.approvalContent && (
<div className="text-xs text-muted-foreground">
{task.approvalContent}
</div>
)}
</div>
)}
{/* 底部信息栏 */}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{task.deployBy}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatTime(task.deployStartTime)}</span>
</div>
{task.pendingDuration && (
<div className="flex items-center gap-1 text-amber-600">
<Clock className="h-3 w-3" />
<span> {formatDuration(task.pendingDuration)}</span>
</div>
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleViewDetail(task)}
>
</Button>
</div>
{/* 备注(如果有) */}
{task.deployRemark && (
<div className="flex items-start gap-1.5 p-2 bg-muted/30 rounded text-xs">
<FileText className="h-3 w-3 text-muted-foreground shrink-0 mt-0.5" />
<span className="text-muted-foreground">{task.deployRemark}</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
{/* 审批确认对话框 */}
<AlertDialog open={approvalDialogOpen} onOpenChange={setApprovalDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{approvalResult === ApprovalResult.APPROVED ? (
<>
<CheckCircle className="h-5 w-5 text-green-600" />
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-600" />
</>
)}
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
{selectedTask && (
<>
<div className="text-sm text-foreground">
{approvalResult === ApprovalResult.APPROVED ? '通过' : '拒绝'}
</div>
<div className="p-3 bg-muted/50 rounded-md space-y-1.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[56px]">ID</span>
<span className="font-mono font-semibold">#{selectedTask.deployRecordId}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[56px]"></span>
<span className="font-medium">{selectedTask.applicationName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[56px]"></span>
<span className="font-medium">{selectedTask.environmentName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[56px]"></span>
<span className="font-medium">{selectedTask.deployBy}</span>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex items-center gap-1">
{selectedTask.requireComment && (
<span className="text-destructive text-xs">*</span>
)}
</label>
<Textarea
placeholder={selectedTask.requireComment ? "请填写审批意见(必填)" : "请填写审批意见(可选)"}
value={approvalComment}
onChange={(e) => setApprovalComment(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmitApproval}
disabled={submitting}
className={
approvalResult === ApprovalResult.APPROVED
? "bg-green-600 hover:bg-green-700"
: "bg-red-600 hover:bg-red-700"
}
>
{submitting ? '提交中...' : '确认'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 部署流程图弹窗 */}
<DeployFlowGraphModal
open={flowModalOpen}
deployRecordId={selectedDeployRecordId}
onOpenChange={setFlowModalOpen}
/>
</>
);
};

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import { import {
Package, Package,
Shield, Shield,
@ -9,10 +10,14 @@ import {
Users, Users,
Server, Server,
CheckCircle2, CheckCircle2,
ClipboardCheck,
} from "lucide-react"; } from "lucide-react";
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { getDeployEnvironments, startDeployment } from './service'; import { useSelector } from 'react-redux';
import type { RootState } from '@/store';
import { getDeployEnvironments, startDeployment, getMyApprovalTasks } from './service';
import { ApplicationCard } from './components/ApplicationCard'; import { ApplicationCard } from './components/ApplicationCard';
import { PendingApprovalModal } from './components/PendingApprovalModal';
import type { DeployTeam, ApplicationConfig } from './types'; import type { DeployTeam, ApplicationConfig } from './types';
const LoadingState = () => ( const LoadingState = () => (
@ -32,8 +37,26 @@ const Dashboard: React.FC = () => {
const [currentEnvId, setCurrentEnvId] = useState<number | null>(null); const [currentEnvId, setCurrentEnvId] = useState<number | null>(null);
const [deploying, setDeploying] = useState<Set<number>>(new Set()); const [deploying, setDeploying] = useState<Set<number>>(new Set());
const [isInitialLoad, setIsInitialLoad] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true);
const [approvalModalOpen, setApprovalModalOpen] = useState(false);
const [pendingApprovalCount, setPendingApprovalCount] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
// 从 Redux store 中获取当前登录用户信息
const currentUserId = useSelector((state: RootState) => state.user.userInfo?.id);
// 加载待审批数量
const loadPendingApprovalCount = React.useCallback(async () => {
try {
const response = await getMyApprovalTasks();
if (response) {
setPendingApprovalCount(response.length || 0);
}
} catch (error) {
// 静默失败,不影响主页面
console.error('Failed to load pending approval count:', error);
}
}, []);
// 加载部署环境数据 // 加载部署环境数据
const loadData = React.useCallback(async (showLoading = false) => { const loadData = React.useCallback(async (showLoading = false) => {
try { try {
@ -131,6 +154,7 @@ const Dashboard: React.FC = () => {
// 首次加载数据 // 首次加载数据
useEffect(() => { useEffect(() => {
loadData(true); loadData(true);
loadPendingApprovalCount();
}, []); }, []);
// 定时刷新数据每5秒 // 定时刷新数据每5秒
@ -146,9 +170,11 @@ const Dashboard: React.FC = () => {
} }
// 立即刷新一次 // 立即刷新一次
loadData(false); loadData(false);
loadPendingApprovalCount();
// 设置新的定时器 // 设置新的定时器
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
loadData(false); loadData(false);
loadPendingApprovalCount();
}, 5000); }, 5000);
}; };
@ -195,14 +221,14 @@ const Dashboard: React.FC = () => {
}; };
// 处理部署 // 处理部署
const handleDeploy = async (app: ApplicationConfig) => { const handleDeploy = async (app: ApplicationConfig, remark: string) => {
if (!currentEnv) return; if (!currentEnv) return;
// 立即显示部署中状态 // 立即显示部署中状态
setDeploying((prev) => new Set(prev).add(app.teamApplicationId)); setDeploying((prev) => new Set(prev).add(app.teamApplicationId));
try { try {
await startDeployment(app.teamApplicationId); await startDeployment(app.teamApplicationId, remark);
toast({ toast({
title: currentEnv.requiresApproval ? '部署申请已提交' : '部署任务已创建', title: currentEnv.requiresApproval ? '部署申请已提交' : '部署任务已创建',
@ -262,7 +288,24 @@ const Dashboard: React.FC = () => {
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
{/* 待审批按钮 */}
<Button
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50 hover:text-orange-700 hover:border-orange-600"
onClick={() => setApprovalModalOpen(true)}
>
<ClipboardCheck className="h-4 w-4 mr-2" />
{pendingApprovalCount > 0 && (
<span className="ml-2 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-semibold">
{pendingApprovalCount}
</span>
)}
</Button>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">:</span> <span className="text-sm text-muted-foreground">:</span>
@ -409,6 +452,12 @@ const Dashboard: React.FC = () => {
</Tabs> </Tabs>
) )
)} )}
{/* 待审批列表弹窗 */}
<PendingApprovalModal
open={approvalModalOpen}
onOpenChange={setApprovalModalOpen}
/>
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import request from '@/utils/request'; import request from '@/utils/request';
import type { DeployEnvironmentsResponse, StartDeploymentResponse } from './types'; import type { DeployEnvironmentsResponse, StartDeploymentResponse, PendingApprovalTask, CompleteApprovalRequest } from './types';
const DEPLOY_URL = '/api/v1/deploy'; const DEPLOY_URL = '/api/v1/deploy';
@ -12,8 +12,8 @@ export const getDeployEnvironments = () =>
/** /**
* *
*/ */
export const startDeployment = (teamApplicationId: number) => export const startDeployment = (teamApplicationId: number, remark?: string) =>
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, { teamApplicationId }); request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, { teamApplicationId, remark });
/** /**
* *
@ -21,3 +21,15 @@ export const startDeployment = (teamApplicationId: number) =>
*/ */
export const getDeployRecordFlowGraph = (deployRecordId: number) => export const getDeployRecordFlowGraph = (deployRecordId: number) =>
request.get<import('./types').DeployRecordFlowGraph>(`${DEPLOY_URL}/records/${deployRecordId}/flow-graph`); request.get<import('./types').DeployRecordFlowGraph>(`${DEPLOY_URL}/records/${deployRecordId}/flow-graph`);
/**
*
*/
export const getMyApprovalTasks = () =>
request.get<PendingApprovalTask[]>(`${DEPLOY_URL}/my-approval-tasks`);
/**
*
*/
export const completeApproval = (data: CompleteApprovalRequest) =>
request.post(`${DEPLOY_URL}/complete`, data);

View File

@ -98,16 +98,74 @@ export interface StartDeploymentResponse {
startTime?: string; startTime?: string;
} }
/**
*
*/
export interface PendingApprovalTask {
// ============ 审批任务基本信息 ============
taskId: string;
taskName: string;
taskDescription?: string;
processInstanceId: string;
processDefinitionId: string;
assignee: string;
createTime: string;
dueDate?: string;
approvalTitle?: string;
approvalContent?: string;
approvalMode: 'SINGLE' | 'MULTI' | 'OR';
allowDelegate?: boolean;
allowAddSign?: boolean;
requireComment?: boolean;
// ============ 部署业务上下文信息 ============
deployRecordId: number;
businessKey: string;
teamId: number;
teamName: string;
applicationId: number;
applicationCode: string;
applicationName: string;
environmentId: number;
environmentCode: string;
environmentName: string;
deployBy: string;
deployRemark?: string;
deployStartTime: string;
pendingDuration?: number; // 待审批时长(毫秒)
}
/**
*
*/
export enum ApprovalResult {
APPROVED = 'APPROVED', // 通过
REJECTED = 'REJECTED', // 拒绝
}
/**
*
*/
export interface CompleteApprovalRequest {
taskId: string; // Flowable任务ID
result: ApprovalResult; // 审批结果
comment?: string; // 审批意见
}
/** /**
* *
*/ */
export interface WorkflowNodeInstance { export interface WorkflowNodeInstance {
id: number; id: number | null;
nodeId: string; nodeId: string;
status: string; // NOT_STARTED/RUNNING/COMPLETED/FAILED/REJECTED等 nodeName: string;
nodeType: string;
status: string; // NOT_STARTED/RUNNING/COMPLETED/FAILED/SUSPENDED/TERMINATED
startTime?: string | null; startTime?: string | null;
endTime?: string | null; endTime?: string | null;
outputs?: Record<string, any>; duration?: number | null; // 执行时长(毫秒)
errorMessage?: string | null;
outputs?: Record<string, any> | null;
} }
/** /**
@ -131,8 +189,10 @@ export interface WorkflowDefinitionGraphNode {
* *
*/ */
export interface WorkflowDefinitionGraphEdge { export interface WorkflowDefinitionGraphEdge {
id: string;
from: string; from: string;
to: string; to: string;
name?: string;
config?: Record<string, any>; config?: Record<string, any>;
} }
@ -148,10 +208,38 @@ export interface WorkflowDefinitionGraph {
* *
*/ */
export interface DeployRecordFlowGraph { export interface DeployRecordFlowGraph {
// 部署基本信息
deployRecordId: number; deployRecordId: number;
businessKey: string;
deployStatus: DeployStatus;
deployBy: string;
deployRemark?: string | null;
deployStartTime: string;
deployEndTime?: string | null;
deployDuration?: number | null; // 毫秒
// 业务信息
applicationName: string;
applicationCode: string;
environmentName: string;
teamName: string;
// 工作流信息
workflowInstanceId: number; workflowInstanceId: number;
processInstanceId: string; processInstanceId: string;
deployStatus: DeployStatus; workflowStatus: string;
workflowStartTime: string;
workflowEndTime?: string | null;
workflowDuration?: number | null; // 毫秒
// 统计信息
totalNodeCount: number;
executedNodeCount: number;
successNodeCount: number;
failedNodeCount: number;
runningNodeCount: number;
// 流程图数据
graph: WorkflowDefinitionGraph; graph: WorkflowDefinitionGraph;
nodeInstances: WorkflowNodeInstance[]; nodeInstances: WorkflowNodeInstance[];
} }

View File

@ -15,16 +15,65 @@ import {
* @param ms * @param ms
* @returns "2分30秒" "30秒" * @returns "2分30秒" "30秒"
*/ */
export const formatDuration = (ms?: number): string => { export const formatDuration = (ms?: number | null): string => {
if (!ms) return '-'; // 正确处理 0 值:只有 null 或 undefined 才返回 '-'
if (ms === null || ms === undefined) return '-';
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}小时${minutes % 60}${seconds % 60}`;
}
if (minutes > 0) { if (minutes > 0) {
return `${minutes}${seconds % 60}`; return `${minutes}${seconds % 60}`;
} }
return `${seconds}`; return `${seconds}`;
}; };
/**
*
* @param startTime
* @returns
*/
export const calculateRunningDuration = (startTime?: string | null): number => {
if (!startTime) return 0;
try {
const normalizedTimeStr = startTime.includes(' ') && !startTime.includes('T')
? startTime.replace(' ', 'T')
: startTime;
const start = new Date(normalizedTimeStr).getTime();
const now = Date.now();
return now - start;
} catch {
return 0;
}
};
/**
*
* @param status
* @returns
*/
export const getNodeStatusColor = (status: string): string => {
switch (status) {
case 'COMPLETED':
return '#10b981'; // 绿色 - 已完成
case 'FAILED':
return '#ef4444'; // 红色 - 执行失败
case 'RUNNING':
return '#3b82f6'; // 蓝色 - 运行中
case 'SUSPENDED':
return '#f59e0b'; // 琥珀色 - 已暂停
case 'TERMINATED':
return '#9ca3af'; // 灰色 - 已终止
case 'NOT_STARTED':
default:
return '#d1d5db'; // 浅灰色 - 未开始
}
};
/** /**
* *
* *
@ -91,7 +140,31 @@ export const getStatusIcon = (status?: string): { icon: LucideIcon; color: strin
}; };
/** /**
* * NodeInstance
* @param status
* @returns
*/
export const getNodeStatusText = (status?: string): string => {
switch (status) {
case 'NOT_STARTED':
return '未开始';
case 'RUNNING':
return '运行中';
case 'SUSPENDED':
return '已暂停';
case 'COMPLETED':
return '已完成';
case 'TERMINATED':
return '已终止';
case 'FAILED':
return '执行失败';
default:
return '未知';
}
};
/**
* DeployStatus
* @param status * @param status
* @returns * @returns
*/ */