deploy-ease-platform/frontend/src/pages/Workflow/Definition/index.tsx
2025-10-24 14:28:25 +08:00

518 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { DataTablePagination } from '@/components/ui/pagination';
import {
Loader2, Plus, Search, Edit, Trash2, Play, CheckCircle2,
Clock, Activity, Workflow, MoreHorizontal, AlertCircle, Eye
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import * as service from './service';
import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategoryResponse } from './types';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import EditModal from './components/EditModal';
const WorkflowDefinitionList: React.FC = () => {
const navigate = useNavigate();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [pageData, setPageData] = useState<{
content: WorkflowDefinition[];
totalElements: number;
size: number;
number: number;
} | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>();
const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
const [deployRecord, setDeployRecord] = useState<WorkflowDefinition | null>(null);
const [query, setQuery] = useState<WorkflowDefinitionQuery>({
pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined,
status: undefined
});
const loadData = async (params: WorkflowDefinitionQuery) => {
setLoading(true);
try {
const data = await service.getDefinitions(params);
setPageData(data);
} catch (error) {
if (error instanceof Error) {
toast({
title: '加载失败',
description: error.message,
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
};
const loadCategories = async () => {
try {
const data = await service.getWorkflowCategoryList();
setCategories(data);
} catch (error) {
console.error('加载工作流分类失败:', error);
}
};
useEffect(() => {
loadCategories();
}, []);
useEffect(() => {
loadData(query);
}, [query]);
const handleCreateFlow = () => {
setCurrentRecord(undefined);
setModalVisible(true);
};
const handleEditFlow = (record: WorkflowDefinition) => {
setCurrentRecord(record);
setModalVisible(true);
};
const handleDesignFlow = (record: WorkflowDefinition) => {
navigate(`/workflow/design/${record.id}`);
};
const handleModalClose = () => {
setModalVisible(false);
setCurrentRecord(undefined);
};
const handleSearch = () => {
setQuery(prev => ({
...prev,
pageNum: 0,
}));
};
const handleReset = () => {
setQuery({
pageNum: 0,
pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined,
status: undefined
});
};
const handleDeploy = (record: WorkflowDefinition) => {
setDeployRecord(record);
setDeployDialogOpen(true);
};
const confirmDeploy = async () => {
if (!deployRecord) return;
try {
await service.publishDefinition(deployRecord.id);
toast({
title: '发布成功',
description: `工作流 "${deployRecord.name}" 已发布`,
});
loadData(query);
setDeployDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
toast({
title: '发布失败',
description: error.message,
variant: 'destructive'
});
}
}
};
const handleDelete = (record: WorkflowDefinition) => {
setDeleteRecord(record);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!deleteRecord) return;
try {
await service.deleteDefinition(deleteRecord.id);
toast({
title: '删除成功',
description: `工作流 "${deleteRecord.name}" 已删除`,
});
loadData(query);
setDeleteDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
toast({
title: '删除失败',
description: error.message,
variant: 'destructive'
});
}
}
};
const handleStartFlow = async (record: WorkflowDefinition) => {
try {
await service.startWorkflowInstance(record.key, record.categoryId);
toast({
title: '启动成功',
description: `工作流 "${record.name}" 已启动`,
});
} catch (error) {
if (error instanceof Error) {
toast({
title: '启动失败',
description: error.message,
variant: 'destructive'
});
}
}
};
// 状态徽章
const getStatusBadge = (status: string) => {
const statusMap: Record<string, {
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
text: string;
icon: React.ElementType
}> = {
DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 },
};
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
const Icon = statusInfo.icon;
return (
<Badge variant={statusInfo.variant} className="flex items-center gap-1">
<Icon className="h-3 w-3" />
{statusInfo.text}
</Badge>
);
};
// 统计数据
const stats = useMemo(() => {
const total = pageData?.totalElements || 0;
const draftCount = pageData?.content?.filter(d => d.status === 'DRAFT').length || 0;
const publishedCount = pageData?.content?.filter(d => d.status !== 'DRAFT').length || 0;
return { total, draftCount, publishedCount };
}, [pageData]);
const pageCount = pageData?.totalElements ? Math.ceil(pageData.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground"></h1>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-blue-700"></CardTitle>
<Activity className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-yellow-500/10 to-yellow-500/5 border-yellow-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-yellow-700">稿</CardTitle>
<Clock className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.draftCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-green-500/5 border-green-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-green-700"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.publishedCount}</div>
<p className="text-xs text-muted-foreground mt-1">使</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button onClick={handleCreateFlow}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</CardHeader>
<CardContent>
{/* 搜索栏 */}
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="flex-1 max-w-md">
<Input
placeholder="搜索工作流名称"
value={query.name}
onChange={(e) => setQuery(prev => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="h-9"
/>
</div>
<Select
value={query.categoryId?.toString() || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger className="w-[160px] h-9">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={query.status || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, status: value }))}
>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">稿</SelectItem>
<SelectItem value="PUBLISHED"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch} className="h-9">
<Search className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleReset} className="h-9">
</Button>
</div>
{/* 表格 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</TableCell>
</TableRow>
) : pageData?.content && pageData.content.length > 0 ? (
pageData.content.map((record) => {
const categoryInfo = categories.find(c => c.id === record.categoryId);
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{record.name}</TableCell>
<TableCell>
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{record.key}
</code>
</TableCell>
<TableCell>
{categoryInfo ? (
<Badge variant="outline">
{categoryInfo.name}
</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
<span className="font-mono text-sm">{record.flowVersion || 1}</span>
</TableCell>
<TableCell>{getStatusBadge(record.status || 'DRAFT')}</TableCell>
<TableCell>
<span className="text-sm line-clamp-1">{record.description || '-'}</span>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{record.status === 'DRAFT' && (
<>
<DropdownMenuItem onClick={() => handleEditFlow(record)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDesignFlow(record)}>
<Workflow className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeploy(record)}>
<CheckCircle2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</>
)}
{record.status !== 'DRAFT' && (
<>
<DropdownMenuItem onClick={() => handleStartFlow(record)}>
<Play className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDesignFlow(record)}>
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDelete(record)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Workflow className="w-16 h-16 mb-4 text-muted-foreground/50" />
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">"新建工作流"</div>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{pageCount > 1 && (
<DataTablePagination
pageIndex={(query.pageNum || 0) + 1}
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
pageCount={pageCount}
onPageChange={(page) => setQuery(prev => ({
...prev,
pageNum: page - 1
}))}
/>
)}
</CardContent>
</Card>
{/* EditModal */}
<EditModal
visible={modalVisible}
onClose={handleModalClose}
onSuccess={() => loadData(query)}
record={currentRecord}
/>
{/* 发布确认对话框 */}
{deployRecord && (
<Dialog open={deployDialogOpen} onOpenChange={setDeployDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
"<span className="font-semibold text-foreground">{deployRecord.name}</span>"
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeployDialogOpen(false)}>
</Button>
<Button onClick={confirmDeploy}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* 删除确认对话框 */}
{deleteRecord && (
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
"<span className="font-semibold text-foreground">{deleteRecord.name}</span>"
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
<span className="font-medium">:</span> <code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">{deleteRecord.key}</code>
</p>
<p>
<span className="font-medium">:</span> <span className="font-mono text-sm">{deleteRecord.flowVersion || 1}</span>
</p>
<p>
<span className="font-medium">:</span> {getStatusBadge(deleteRecord.status || 'DRAFT')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={confirmDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
};
export default WorkflowDefinitionList;