更换变量显示组件
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 { 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}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 状态的中文描述
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user