更换变量显示组件
This commit is contained in:
parent
ed19478379
commit
d0997f9e9f
60
frontend/src/components/ui/alert.tsx
Normal file
60
frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||
|
||||
@ -21,11 +21,12 @@ import {
|
||||
import { formatDuration, formatTime, getStatusIcon, getStatusText } from '../utils/dashboardUtils';
|
||||
import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types';
|
||||
import { DeployFlowGraphModal } from './DeployFlowGraphModal';
|
||||
import { DeployConfirmDialog } from './DeployConfirmDialog';
|
||||
|
||||
interface ApplicationCardProps {
|
||||
app: ApplicationConfig;
|
||||
environment: DeployEnvironment;
|
||||
onDeploy: (app: ApplicationConfig) => void;
|
||||
onDeploy: (app: ApplicationConfig, remark: string) => void;
|
||||
isDeploying: boolean;
|
||||
}
|
||||
|
||||
@ -37,6 +38,7 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
}) => {
|
||||
const [selectedDeployRecordId, setSelectedDeployRecordId] = useState<number | null>(null);
|
||||
const [flowModalOpen, setFlowModalOpen] = useState(false);
|
||||
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
|
||||
|
||||
const handleDeployRecordClick = (record: DeployRecord) => {
|
||||
setSelectedDeployRecordId(record.id);
|
||||
@ -330,7 +332,7 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
|
||||
{/* 部署按钮 */}
|
||||
<Button
|
||||
onClick={() => onDeploy(app)}
|
||||
onClick={() => setDeployDialogOpen(true)}
|
||||
disabled={!environment.enabled || isDeploying || app.isDeploying}
|
||||
size="sm"
|
||||
className={cn(
|
||||
@ -351,6 +353,16 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 部署确认对话框 */}
|
||||
<DeployConfirmDialog
|
||||
open={deployDialogOpen}
|
||||
onOpenChange={setDeployDialogOpen}
|
||||
onConfirm={(remark) => onDeploy(app, remark)}
|
||||
applicationName={app.applicationName}
|
||||
environmentName={environment.environmentName}
|
||||
loading={isDeploying || app.isDeploying}
|
||||
/>
|
||||
|
||||
{/* 部署流程图模态框 */}
|
||||
<DeployFlowGraphModal
|
||||
open={flowModalOpen}
|
||||
|
||||
150
frontend/src/pages/Dashboard/components/DeployConfirmDialog.tsx
Normal file
150
frontend/src/pages/Dashboard/components/DeployConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 '@xyflow/react/dist/style.css';
|
||||
import { getDeployRecordFlowGraph } from '../service';
|
||||
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 {
|
||||
open: boolean;
|
||||
@ -15,104 +39,328 @@ interface DeployFlowGraphModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// 节点状态颜色映射
|
||||
const nodeStatusColorMap: Record<string, { bg: string; border: string; text: string }> = {
|
||||
NOT_STARTED: {
|
||||
bg: '#fafafa',
|
||||
border: '#d9d9d9',
|
||||
text: '#666'
|
||||
},
|
||||
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 CustomFlowNode: React.FC<any> = ({ data }) => {
|
||||
const { nodeName, nodeType, status, startTime, endTime, duration, errorMessage } = data;
|
||||
const statusColor = getNodeStatusColor(status);
|
||||
const isNotStarted = status === 'NOT_STARTED';
|
||||
const isRunning = status === 'RUNNING';
|
||||
const nodeType = data.nodeType;
|
||||
const hasFailed = status === 'FAILED';
|
||||
|
||||
// 计算显示的时长
|
||||
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-md min-w-[160px] transition-all',
|
||||
isNotStarted && 'border-2 border-dashed',
|
||||
!isNotStarted && 'border-2 border-solid shadow-sm',
|
||||
isRunning && 'animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
borderColor: statusColor,
|
||||
backgroundColor: isNotStarted ? '#f9fafb' : '#ffffff',
|
||||
}}
|
||||
>
|
||||
{/* 节点名称 */}
|
||||
<div className="font-medium text-sm mb-1">{nodeName}</div>
|
||||
|
||||
{/* 节点状态 */}
|
||||
<div
|
||||
className="text-xs font-medium mb-1"
|
||||
style={{ color: statusColor }}
|
||||
>
|
||||
{getNodeStatusText(status)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{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 (
|
||||
<div className="relative group">
|
||||
<>
|
||||
{/* 输入连接点 */}
|
||||
{nodeType !== 'START_EVENT' && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-2 !h-2 !rounded-full !border-2 !border-white"
|
||||
style={{ background: colors.border }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
)}
|
||||
|
||||
{/* 节点内容 - 简化版 */}
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[80px] max-w-[120px] transition-all duration-200 rounded-md p-2',
|
||||
selected ? 'ring-1 ring-primary' : '',
|
||||
isNotStarted ? 'border-dashed border' : 'shadow-sm',
|
||||
)}
|
||||
style={{
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-xs mb-0.5 leading-tight">{data.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>
|
||||
</div>
|
||||
{/* 节点内容 - 如果有错误信息,包装在 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}
|
||||
className="!w-2 !h-2 !rounded-full !border-2 !border-white"
|
||||
style={{ background: colors.border }}
|
||||
/>
|
||||
<Handle type="source" position={Position.Right} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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> = ({
|
||||
open,
|
||||
@ -141,7 +389,7 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
|
||||
}
|
||||
}, [open, deployRecordId]);
|
||||
|
||||
// 创建节点状态映射(用于快速查找)
|
||||
// 创建节点ID到实例的映射
|
||||
const nodeInstanceMap = useMemo(() => {
|
||||
if (!flowData?.nodeInstances) return new Map<string, WorkflowNodeInstance>();
|
||||
const map = new Map<string, WorkflowNodeInstance>();
|
||||
@ -151,196 +399,102 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
|
||||
return map;
|
||||
}, [flowData]);
|
||||
|
||||
// 获取节点状态(通过匹配 nodeInstances)
|
||||
const getNodeStatus = (nodeId: string): string => {
|
||||
const instance = nodeInstanceMap.get(nodeId);
|
||||
return instance ? instance.status : 'NOT_STARTED';
|
||||
};
|
||||
// 转换为 React Flow 节点(按执行顺序重新布局)
|
||||
const flowNodes: Node[] = useMemo(() => {
|
||||
if (!flowData?.graph?.nodes || !flowData?.nodeInstances) return [];
|
||||
|
||||
// 计算节点拓扑顺序(用于自动布局)
|
||||
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);
|
||||
}
|
||||
});
|
||||
// 按执行顺序排序已执行的节点
|
||||
const executedInstances = flowData.nodeInstances
|
||||
.filter(ni => ni.status !== 'NOT_STARTED')
|
||||
.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 timeA - timeB;
|
||||
});
|
||||
|
||||
currentLevelNodes = nextLevelNodes;
|
||||
}
|
||||
// 创建节点位置映射(已执行的节点)
|
||||
const nodePositionMap = new Map<string, { x: number; y: number }>();
|
||||
const horizontalSpacing = 250;
|
||||
const startX = 50;
|
||||
const startY = 150;
|
||||
|
||||
// 处理剩余节点
|
||||
nodes.forEach(node => {
|
||||
if (!processed.has(node.id)) {
|
||||
topoOrder.push(node.id);
|
||||
}
|
||||
executedInstances.forEach((instance, index) => {
|
||||
nodePositionMap.set(instance.nodeId, {
|
||||
x: startX + index * horizontalSpacing,
|
||||
y: startY,
|
||||
});
|
||||
});
|
||||
|
||||
// 创建顺序映射
|
||||
const orderMap = new Map<string, number>();
|
||||
topoOrder.forEach((nodeId, index) => {
|
||||
orderMap.set(nodeId, index);
|
||||
});
|
||||
|
||||
return orderMap;
|
||||
}, [flowData]);
|
||||
|
||||
// 转换为 React Flow 节点(自动水平布局,简化版)
|
||||
const flowNodes: Node[] = useMemo(() => {
|
||||
if (!flowData?.graph?.nodes) return [];
|
||||
|
||||
// 按拓扑顺序排序节点
|
||||
const sortedNodes = Array.from(flowData.graph.nodes).sort((a, b) => {
|
||||
const orderA = calculateNodeOrder.get(a.id) ?? Infinity;
|
||||
const orderB = calculateNodeOrder.get(b.id) ?? Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// 自动水平排列(紧凑布局)
|
||||
const horizontalSpacing = 150; // 缩小间距
|
||||
const startX = 100;
|
||||
const startY = 200; // 固定Y坐标,所有节点在同一行
|
||||
|
||||
return sortedNodes.map((node, index) => {
|
||||
// 1. 匹配执行状态
|
||||
// 为所有节点生成位置(包括未执行的)
|
||||
return flowData.graph.nodes.map((node) => {
|
||||
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 {
|
||||
id: node.id,
|
||||
type: 'default',
|
||||
// 自动水平布局
|
||||
position: {
|
||||
x: startX + index * horizontalSpacing,
|
||||
y: startY,
|
||||
},
|
||||
type: 'custom',
|
||||
position,
|
||||
data: {
|
||||
nodeName: node.nodeName,
|
||||
nodeType: node.nodeType,
|
||||
status: status,
|
||||
},
|
||||
style: {
|
||||
background: colors.bg,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
status: instance?.status || 'NOT_STARTED',
|
||||
startTime: instance?.startTime,
|
||||
endTime: instance?.endTime,
|
||||
duration: instance?.duration,
|
||||
errorMessage: instance?.errorMessage,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [flowData, nodeInstanceMap, calculateNodeOrder]);
|
||||
}, [flowData, nodeInstanceMap]);
|
||||
|
||||
// 判断边是否已执行
|
||||
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)
|
||||
// 转换为 React Flow 边
|
||||
const flowEdges: Edge[] = useMemo(() => {
|
||||
if (!flowData?.graph?.edges) return [];
|
||||
|
||||
const edges: Edge[] = [];
|
||||
flowData.graph.edges.forEach((edge, index) => {
|
||||
// 使用 edge.from 和 edge.to
|
||||
return flowData.graph.edges.map((edge, index) => {
|
||||
const source = edge.from;
|
||||
const target = edge.to;
|
||||
|
||||
if (!source || !target) {
|
||||
console.warn('边数据不完整:', edge);
|
||||
return;
|
||||
const sourceInstance = nodeInstanceMap.get(source);
|
||||
const sourceStatus = sourceInstance?.status || 'NOT_STARTED';
|
||||
|
||||
// 根据源节点状态确定边的颜色
|
||||
let strokeColor = '#d1d5db'; // 默认灰色
|
||||
if (sourceStatus === 'COMPLETED') {
|
||||
strokeColor = '#10b981'; // 绿色
|
||||
} else if (sourceStatus === 'FAILED') {
|
||||
strokeColor = '#ef4444'; // 红色
|
||||
} else if (sourceStatus === 'RUNNING') {
|
||||
strokeColor = '#3b82f6'; // 蓝色
|
||||
}
|
||||
|
||||
const executed = isEdgeExecuted(source);
|
||||
const interrupted = isEdgeInterrupted(source);
|
||||
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}`,
|
||||
return {
|
||||
id: edge.id || `edge-${source}-${target}-${index}`,
|
||||
source,
|
||||
target,
|
||||
type: 'smoothstep' as const,
|
||||
animated: executed && targetStatus === 'RUNNING',
|
||||
type: 'smoothstep',
|
||||
// 不显示边的标签(条件表达式等)
|
||||
animated: sourceStatus === 'RUNNING',
|
||||
style: {
|
||||
stroke: strokeColor,
|
||||
strokeWidth: executed ? 3 : 2,
|
||||
strokeDasharray: isDashed ? '5,5' : undefined,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrowclosed' as const,
|
||||
type: 'arrowclosed',
|
||||
color: strokeColor,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return edges;
|
||||
}, [flowData, nodeInstanceMap]);
|
||||
|
||||
// 获取部署状态信息
|
||||
@ -357,9 +511,14 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{flowData && (
|
||||
<span className="text-muted-foreground">
|
||||
#{flowData.deployRecordId}
|
||||
</span>
|
||||
)}
|
||||
<span>部署流程图</span>
|
||||
{deployStatusInfo && (
|
||||
<Badge
|
||||
@ -375,46 +534,64 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
|
||||
{deployStatusInfo.text}
|
||||
</Badge>
|
||||
)}
|
||||
{flowData && (
|
||||
<span className="text-sm text-muted-foreground font-normal ml-auto">
|
||||
记录ID: #{flowData.deployRecordId}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden relative min-h-0">
|
||||
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||
{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">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载流程图数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : flowData ? (
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-background"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node: any) => {
|
||||
const status = node.data?.status || 'NOT_STARTED';
|
||||
const colors = nodeStatusColorMap[status] || nodeStatusColorMap.NOT_STARTED;
|
||||
return colors.border;
|
||||
}}
|
||||
className="bg-background border"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
<>
|
||||
{/* 左侧信息面板 */}
|
||||
<DeployInfoPanel flowData={flowData} />
|
||||
|
||||
{/* 右侧流程图可视化 */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-muted/10"
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.2 }}
|
||||
>
|
||||
<Background
|
||||
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
|
||||
nodeColor={(node: any) => {
|
||||
const status = node.data?.status || 'NOT_STARTED';
|
||||
return getNodeStatusColor(status);
|
||||
}}
|
||||
className="bg-background/80 backdrop-blur-sm border shadow-sm"
|
||||
pannable={true}
|
||||
zoomable={true}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">暂无流程图数据</p>
|
||||
<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>
|
||||
@ -422,4 +599,3 @@ export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
383
frontend/src/pages/Dashboard/components/PendingApprovalModal.tsx
Normal file
383
frontend/src/pages/Dashboard/components/PendingApprovalModal.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Package,
|
||||
Shield,
|
||||
@ -9,10 +10,14 @@ import {
|
||||
Users,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
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 { PendingApprovalModal } from './components/PendingApprovalModal';
|
||||
import type { DeployTeam, ApplicationConfig } from './types';
|
||||
|
||||
const LoadingState = () => (
|
||||
@ -32,8 +37,26 @@ const Dashboard: React.FC = () => {
|
||||
const [currentEnvId, setCurrentEnvId] = useState<number | null>(null);
|
||||
const [deploying, setDeploying] = useState<Set<number>>(new Set());
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [approvalModalOpen, setApprovalModalOpen] = useState(false);
|
||||
const [pendingApprovalCount, setPendingApprovalCount] = useState(0);
|
||||
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) => {
|
||||
try {
|
||||
@ -131,6 +154,7 @@ const Dashboard: React.FC = () => {
|
||||
// 首次加载数据
|
||||
useEffect(() => {
|
||||
loadData(true);
|
||||
loadPendingApprovalCount();
|
||||
}, []);
|
||||
|
||||
// 定时刷新数据(每5秒)
|
||||
@ -146,9 +170,11 @@ const Dashboard: React.FC = () => {
|
||||
}
|
||||
// 立即刷新一次
|
||||
loadData(false);
|
||||
loadPendingApprovalCount();
|
||||
// 设置新的定时器
|
||||
intervalRef.current = setInterval(() => {
|
||||
loadData(false);
|
||||
loadPendingApprovalCount();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
@ -195,14 +221,14 @@ const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
// 处理部署
|
||||
const handleDeploy = async (app: ApplicationConfig) => {
|
||||
const handleDeploy = async (app: ApplicationConfig, remark: string) => {
|
||||
if (!currentEnv) return;
|
||||
|
||||
// 立即显示部署中状态
|
||||
setDeploying((prev) => new Set(prev).add(app.teamApplicationId));
|
||||
|
||||
try {
|
||||
await startDeployment(app.teamApplicationId);
|
||||
await startDeployment(app.teamApplicationId, remark);
|
||||
|
||||
toast({
|
||||
title: currentEnv.requiresApproval ? '部署申请已提交' : '部署任务已创建',
|
||||
@ -262,7 +288,24 @@ const Dashboard: React.FC = () => {
|
||||
</p>
|
||||
</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">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">当前团队:</span>
|
||||
@ -409,6 +452,12 @@ const Dashboard: React.FC = () => {
|
||||
</Tabs>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 待审批列表弹窗 */}
|
||||
<PendingApprovalModal
|
||||
open={approvalModalOpen}
|
||||
onOpenChange={setApprovalModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@ -12,8 +12,8 @@ export const getDeployEnvironments = () =>
|
||||
/**
|
||||
* 发起部署
|
||||
*/
|
||||
export const startDeployment = (teamApplicationId: number) =>
|
||||
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, { teamApplicationId });
|
||||
export const startDeployment = (teamApplicationId: number, remark?: string) =>
|
||||
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, { teamApplicationId, remark });
|
||||
|
||||
/**
|
||||
* 获取部署流程图数据
|
||||
@ -21,3 +21,15 @@ export const startDeployment = (teamApplicationId: number) =>
|
||||
*/
|
||||
export const getDeployRecordFlowGraph = (deployRecordId: number) =>
|
||||
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);
|
||||
|
||||
@ -98,16 +98,74 @@ export interface StartDeploymentResponse {
|
||||
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 {
|
||||
id: number;
|
||||
id: number | null;
|
||||
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;
|
||||
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 {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
name?: string;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
@ -148,10 +208,38 @@ export interface WorkflowDefinitionGraph {
|
||||
* 部署流程图数据
|
||||
*/
|
||||
export interface DeployRecordFlowGraph {
|
||||
// 部署基本信息
|
||||
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;
|
||||
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;
|
||||
nodeInstances: WorkflowNodeInstance[];
|
||||
}
|
||||
|
||||
@ -15,16 +15,65 @@ import {
|
||||
* @param ms 毫秒数
|
||||
* @returns 格式化后的时间字符串,如 "2分30秒" 或 "30秒"
|
||||
*/
|
||||
export const formatDuration = (ms?: number): string => {
|
||||
if (!ms) return '-';
|
||||
export const formatDuration = (ms?: number | null): string => {
|
||||
// 正确处理 0 值:只有 null 或 undefined 才返回 '-'
|
||||
if (ms === null || ms === undefined) return '-';
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes % 60}分${seconds % 60}秒`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${seconds % 60}秒`;
|
||||
}
|
||||
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 部署状态
|
||||
* @returns 状态的中文描述
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user