增加审批组件

This commit is contained in:
dengqichen 2025-10-24 20:25:20 +08:00
parent d131634b49
commit 6acc7f339f
10 changed files with 2357 additions and 1512 deletions

View File

@ -0,0 +1,109 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { AlertCircle } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { deleteApplication } from '../service';
import type { Application, DevelopmentLanguageTypeEnum } from '../types';
interface DeleteDialogProps {
open: boolean;
record?: Application;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
const getLanguageLabel = (language: DevelopmentLanguageTypeEnum) => {
const languageMap = {
JAVA: 'Java',
NODE_JS: 'NodeJS',
PYTHON: 'Python',
GO: 'Go',
};
return languageMap[language] || language;
};
/**
*
*/
const DeleteDialog: React.FC<DeleteDialogProps> = ({
open,
record,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const handleConfirm = async () => {
if (!record) return;
try {
await deleteApplication(record.id);
toast({ title: '删除成功', description: `应用 "${record.appName}" 已删除` });
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
if (!record) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="space-y-3 pt-4">
<p></p>
<div className="bg-muted p-3 rounded-md space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.appCode}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.appName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<Badge variant="outline">{getLanguageLabel(record.language)}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.projectGroup?.projectGroupName || '-'}</span>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteDialog;

View File

@ -15,8 +15,8 @@ import {getProjectGroupList} from '../../ProjectGroup/List/service';
import type { Application, ApplicationQuery } from './types'; import type { Application, ApplicationQuery } from './types';
import { DevelopmentLanguageTypeEnum } from './types'; import { DevelopmentLanguageTypeEnum } from './types';
import type { ProjectGroup } from '../../ProjectGroup/List/types'; import type { ProjectGroup } from '../../ProjectGroup/List/types';
import {getProjectTypeInfo} from '../../ProjectGroup/List/utils';
import ApplicationModal from './components/ApplicationModal'; import ApplicationModal from './components/ApplicationModal';
import DeleteDialog from './components/DeleteDialog';
import { import {
Table, Table,
TableHeader, TableHeader,
@ -24,39 +24,29 @@ import {
TableHead, TableHead,
TableRow, TableRow,
TableCell, TableCell,
} from "@/components/ui/table"; } from '@/components/ui/table';
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from '@/components/ui/card';
import {Button} from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import {Input} from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from '@/components/ui/select';
import {Badge} from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import {useToast} from "@/components/ui/use-toast"; import { useToast } from '@/components/ui/use-toast';
import { import { useForm } from 'react-hook-form';
AlertDialog, import { zodResolver } from '@hookform/resolvers/zod';
AlertDialogAction, import { searchFormSchema, type SearchFormValues } from './schema';
AlertDialogCancel, import { DataTablePagination } from '@/components/ui/pagination';
AlertDialogContent, import { Plus, Edit, Trash2, Server, Activity, Database, ExternalLink } from 'lucide-react';
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import {searchFormSchema, type SearchFormValues} from "./schema";
import {DataTablePagination} from "@/components/ui/pagination";
interface Column { interface Column {
accessorKey?: keyof Application; accessorKey?: keyof Application;
@ -66,25 +56,29 @@ interface Column {
cell?: (props: { row: { original: Application } }) => React.ReactNode; cell?: (props: { row: { original: Application } }) => React.ReactNode;
} }
const DEFAULT_PAGE_SIZE = 10;
const ApplicationList: React.FC = () => { const ApplicationList: React.FC = () => {
const [projectGroups, setProjects] = useState<ProjectGroup[]>([]); const [projectGroups, setProjects] = useState<ProjectGroup[]>([]);
const [selectedProjectGroupId, setSelectedProjectGroupId] = useState<number>(); const [selectedProjectGroupId, setSelectedProjectGroupId] = useState<number>();
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [currentApplication, setCurrentApplication] = useState<Application>(); const [currentApplication, setCurrentApplication] = useState<Application>();
const [list, setList] = useState<Application[]>([]); const [list, setList] = useState<Application[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: DEFAULT_PAGE_SIZE,
totalElements: 0, totalElements: 0,
}); });
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<SearchFormValues>({ const form = useForm<SearchFormValues>({
resolver: zodResolver(searchFormSchema), resolver: zodResolver(searchFormSchema),
defaultValues: { defaultValues: {
appCode: "", appCode: '',
appName: "", appName: '',
language: undefined, language: undefined,
enabled: undefined, enabled: undefined,
}, },
@ -100,8 +94,8 @@ const ApplicationList: React.FC = () => {
} }
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: 'destructive',
title: "获取项目组列表失败", title: '获取项目组列表失败',
duration: 3000, duration: 3000,
}); });
} }
@ -130,10 +124,17 @@ const ApplicationList: React.FC = () => {
...pagination, ...pagination,
totalElements: data.totalElements, totalElements: data.totalElements,
}); });
// 计算统计数据
const all = data.content || [];
setStats({
total: all.length,
enabled: all.filter((item) => item.enabled).length,
disabled: all.filter((item) => !item.enabled).length,
});
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: 'destructive',
title: "获取应用列表失败", title: '获取应用列表失败',
duration: 3000, duration: 3000,
}); });
} finally { } finally {
@ -144,7 +145,7 @@ const ApplicationList: React.FC = () => {
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setPagination({ setPagination({
...pagination, ...pagination,
pageNum: page, // 后端页码从1开始 pageNum: page,
}); });
}; };
@ -152,28 +153,11 @@ const ApplicationList: React.FC = () => {
loadData(form.getValues()); loadData(form.getValues());
}, [selectedProjectGroupId, pagination.pageNum, pagination.pageSize]); }, [selectedProjectGroupId, pagination.pageNum, pagination.pageSize]);
const handleDelete = async (id: number) => {
try {
await deleteApplication(id);
toast({
title: "删除成功",
duration: 3000,
});
loadData(form.getValues());
} catch (error) {
toast({
variant: "destructive",
title: "删除失败",
duration: 3000,
});
}
};
const handleAdd = () => { const handleAdd = () => {
if (!selectedProjectGroupId) { if (!selectedProjectGroupId) {
toast({ toast({
variant: "destructive", variant: 'destructive',
title: "请先选择项目组", title: '请先选择项目组',
duration: 3000, duration: 3000,
}); });
return; return;
@ -209,31 +193,31 @@ const ApplicationList: React.FC = () => {
return { return {
label: 'Java', label: 'Java',
icon: <JavaOutlined />, icon: <JavaOutlined />,
color: '#E76F00' color: '#E76F00',
}; };
case DevelopmentLanguageTypeEnum.NODE_JS: case DevelopmentLanguageTypeEnum.NODE_JS:
return { return {
label: 'NodeJS', label: 'NodeJS',
icon: <NodeIndexOutlined />, icon: <NodeIndexOutlined />,
color: '#339933' color: '#339933',
}; };
case DevelopmentLanguageTypeEnum.PYTHON: case DevelopmentLanguageTypeEnum.PYTHON:
return { return {
label: 'Python', label: 'Python',
icon: <PythonOutlined />, icon: <PythonOutlined />,
color: '#3776AB' color: '#3776AB',
}; };
case DevelopmentLanguageTypeEnum.GO: case DevelopmentLanguageTypeEnum.GO:
return { return {
label: 'Go', label: 'Go',
icon: <CodeOutlined />, icon: <CodeOutlined />,
color: '#00ADD8' color: '#00ADD8',
}; };
default: default:
return { return {
label: language || '未知', label: language || '未知',
icon: <CodeOutlined />, icon: <CodeOutlined />,
color: '#666666' color: '#666666',
}; };
} }
}; };
@ -283,11 +267,19 @@ const ApplicationList: React.FC = () => {
return project ? ( return project ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GithubOutlined /> <GithubOutlined />
<a href={project.webUrl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:text-blue-700"> <a
href={project.webUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 flex items-center gap-1"
>
{project.name} {project.name}
<ExternalLink className="h-3 w-3" />
</a> </a>
</div> </div>
) : '-'; ) : (
'-'
);
}, },
}, },
{ {
@ -297,7 +289,7 @@ const ApplicationList: React.FC = () => {
cell: ({ row }) => { cell: ({ row }) => {
const langInfo = getLanguageInfo(row.original.language); const langInfo = getLanguageInfo(row.original.language);
return ( return (
<Badge variant="outline" className="flex items-center gap-1"> <Badge variant="outline" className="flex items-center gap-1 inline-flex">
{langInfo.icon} {langInfo.icon}
{langInfo.label} {langInfo.label}
</Badge> </Badge>
@ -309,7 +301,7 @@ const ApplicationList: React.FC = () => {
header: '状态', header: '状态',
size: 100, size: 100,
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant={row.original.enabled ? "outline" : "secondary"}> <Badge variant={row.original.enabled ? 'default' : 'secondary'} className="inline-flex">
{row.original.enabled ? '启用' : '禁用'} {row.original.enabled ? '启用' : '禁用'}
</Badge> </Badge>
), ),
@ -325,42 +317,22 @@ const ApplicationList: React.FC = () => {
size: 180, size: 180,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button variant="ghost" size="sm" onClick={() => handleEdit(row.original)}>
variant="ghost" <Edit className="h-4 w-4 mr-1" />
size="sm"
onClick={() => handleEdit(row.original)}
>
<EditOutlined className="mr-1"/>
</Button> </Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-destructive" onClick={() => {
setCurrentApplication(row.original);
setDeleteDialogOpen(true);
}}
className="text-destructive hover:text-destructive"
> >
<DeleteOutlined className="mr-1"/> <Trash2 className="h-4 w-4 mr-1" />
</Button> </Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(row.original.id)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
), ),
}, },
@ -368,6 +340,7 @@ const ApplicationList: React.FC = () => {
return ( return (
<PageContainer> <PageContainer>
{/* 页面标题 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight"></h2> <h2 className="text-3xl font-bold tracking-tight"></h2>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -387,12 +360,47 @@ const ApplicationList: React.FC = () => {
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={handleAdd} disabled={!selectedProjectGroupId}> <Button onClick={handleAdd} disabled={!selectedProjectGroupId}>
<PlusOutlined className="mr-1"/> <Plus className="h-4 w-4 mr-2" />
</Button> </Button>
</div> </div>
</div> </div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
{/* 搜索过滤 */}
<Card> <Card>
<div className="p-6"> <div className="p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -410,7 +418,9 @@ const ApplicationList: React.FC = () => {
/> />
<Select <Select
value={form.watch('language')} value={form.watch('language')}
onValueChange={(value) => form.setValue('language', value as DevelopmentLanguageTypeEnum)} onValueChange={(value) =>
form.setValue('language', value as DevelopmentLanguageTypeEnum)
}
> >
<SelectTrigger className="max-w-[200px]"> <SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="开发语言" /> <SelectValue placeholder="开发语言" />
@ -437,13 +447,12 @@ const ApplicationList: React.FC = () => {
<Button variant="outline" onClick={() => form.reset()}> <Button variant="outline" onClick={() => form.reset()}>
</Button> </Button>
<Button variant="ghost" onClick={() => loadData(form.getValues())}> <Button onClick={() => loadData(form.getValues())}></Button>
</Button>
</div> </div>
</div> </div>
</Card> </Card>
{/* 数据表格 */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
@ -464,19 +473,25 @@ const ApplicationList: React.FC = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{list.map((item) => ( {list.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
list.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
{columns.map((column) => ( {columns.map((column) => (
<TableCell <TableCell key={column.accessorKey || column.id}>
key={column.accessorKey || column.id}
>
{column.cell {column.cell
? column.cell({ row: { original: item } }) ? column.cell({ row: { original: item } })
: item[column.accessorKey!]} : item[column.accessorKey!]}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
<div className="flex justify-end border-t border-border bg-muted/40"> <div className="flex justify-end border-t border-border bg-muted/40">
@ -491,6 +506,7 @@ const ApplicationList: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
{/* 对话框 */}
{modalVisible && selectedProjectGroupId && ( {modalVisible && selectedProjectGroupId && (
<ApplicationModal <ApplicationModal
open={modalVisible} open={modalVisible}
@ -500,6 +516,12 @@ const ApplicationList: React.FC = () => {
projectGroupId={selectedProjectGroupId} projectGroupId={selectedProjectGroupId}
/> />
)} )}
<DeleteDialog
open={deleteDialogOpen}
record={currentApplication}
onOpenChange={setDeleteDialogOpen}
onSuccess={handleSuccess}
/>
</PageContainer> </PageContainer>
); );
}; };

View File

@ -0,0 +1,103 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { AlertCircle } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { deleteEnvironment } from '../service';
import type { Environment } from '../types';
import { getBuildTypeInfo, getDeployTypeInfo } from '../utils';
interface DeleteDialogProps {
open: boolean;
record?: Environment;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/**
*
*/
const DeleteDialog: React.FC<DeleteDialogProps> = ({
open,
record,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const handleConfirm = async () => {
if (!record) return;
try {
await deleteEnvironment(record.id);
toast({ title: '删除成功', description: `环境 "${record.envName}" 已删除` });
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
if (!record) return null;
const buildInfo = getBuildTypeInfo(record.buildType);
const deployInfo = getDeployTypeInfo(record.deployType);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="space-y-3 pt-4">
<p></p>
<div className="bg-muted p-3 rounded-md space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.envCode}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.envName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<Badge variant="outline">{buildInfo.label}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<Badge variant="secondary">{deployInfo.label}</Badge>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteDialog;

View File

@ -0,0 +1,220 @@
import React, { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { createEnvironment, updateEnvironment } from '../service';
import type { Environment, CreateEnvironmentRequest, BuildTypeEnum, DeployTypeEnum } from '../types';
interface EditDialogProps {
open: boolean;
record?: Environment;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/**
*
*/
const EditDialog: React.FC<EditDialogProps> = ({
open,
record,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const [formData, setFormData] = useState<Partial<CreateEnvironmentRequest>>({
enabled: true,
sort: 1,
});
useEffect(() => {
if (open) {
if (record) {
setFormData({
tenantCode: record.tenantCode,
envCode: record.envCode,
envName: record.envName,
envDesc: record.envDesc,
buildType: record.buildType,
deployType: record.deployType,
sort: record.sort,
enabled: record.enabled,
});
} else {
setFormData({ enabled: true, sort: 1 });
}
}
}, [open, record]);
const handleSubmit = async () => {
try {
// 验证
if (!formData.tenantCode?.trim()) {
toast({ title: '提示', description: '请输入租户代码', variant: 'destructive' });
return;
}
if (!formData.envCode?.trim()) {
toast({ title: '提示', description: '请输入环境编码', variant: 'destructive' });
return;
}
if (!formData.envName?.trim()) {
toast({ title: '提示', description: '请输入环境名称', variant: 'destructive' });
return;
}
if (!formData.buildType) {
toast({ title: '提示', description: '请选择构建类型', variant: 'destructive' });
return;
}
if (!formData.deployType) {
toast({ title: '提示', description: '请选择部署类型', variant: 'destructive' });
return;
}
if (record) {
await updateEnvironment({ ...formData, id: record.id } as any);
toast({ title: '更新成功', description: `环境 "${formData.envName}" 已更新` });
} else {
await createEnvironment(formData as CreateEnvironmentRequest);
toast({ title: '创建成功', description: `环境 "${formData.envName}" 已创建` });
}
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('保存失败:', error);
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{record ? '编辑环境' : '新建环境'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="tenantCode"> *</Label>
<Input
id="tenantCode"
value={formData.tenantCode || ''}
onChange={(e) => setFormData({ ...formData, tenantCode: e.target.value })}
placeholder="请输入租户代码"
disabled={!!record}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="envCode"> *</Label>
<Input
id="envCode"
value={formData.envCode || ''}
onChange={(e) => setFormData({ ...formData, envCode: e.target.value })}
placeholder="请输入环境编码"
disabled={!!record}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="envName"> *</Label>
<Input
id="envName"
value={formData.envName || ''}
onChange={(e) => setFormData({ ...formData, envName: e.target.value })}
placeholder="请输入环境名称"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="envDesc"></Label>
<Textarea
id="envDesc"
value={formData.envDesc || ''}
onChange={(e) => setFormData({ ...formData, envDesc: e.target.value })}
placeholder="请输入环境描述"
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="buildType"> *</Label>
<Select
value={formData.buildType}
onValueChange={(value) => setFormData({ ...formData, buildType: value as BuildTypeEnum })}
>
<SelectTrigger>
<SelectValue placeholder="请选择构建类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="JENKINS">Jenkins构建</SelectItem>
<SelectItem value="GITLAB_RUNNER">GitLab Runner构建</SelectItem>
<SelectItem value="GITHUB_ACTION">GitHub Action构建</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="deployType"> *</Label>
<Select
value={formData.deployType}
onValueChange={(value) => setFormData({ ...formData, deployType: value as DeployTypeEnum })}
>
<SelectTrigger>
<SelectValue placeholder="请选择部署类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="K8S">Kubernetes集群部署</SelectItem>
<SelectItem value="DOCKER">Docker容器部署</SelectItem>
<SelectItem value="VM"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="sort"> *</Label>
<Input
id="sort"
type="number"
value={formData.sort || 1}
onChange={(e) => setFormData({ ...formData, sort: Number(e.target.value) })}
placeholder="请输入显示排序"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="enabled"></Label>
<Switch
id="enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default EditDialog;

View File

@ -1,243 +1,301 @@
import React, {useState} from 'react'; import React, { useState, useEffect } from 'react';
import {PageContainer} from '@ant-design/pro-components'; import { PageContainer } from '@/components/ui/page-container';
import {Button, Space, Popconfirm, Tag, App, Select} from 'antd'; import {
import {PlusOutlined, EditOutlined, DeleteOutlined} from '@ant-design/icons'; Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { DataTablePagination } from '@/components/ui/pagination';
import { Plus, Edit, Trash2, Server, Activity, Database } from 'lucide-react';
import EditDialog from './components/EditDialog';
import DeleteDialog from './components/DeleteDialog';
import { getEnvironmentPage, deleteEnvironment } from './service'; import { getEnvironmentPage, deleteEnvironment } from './service';
import type {Environment, EnvironmentQueryParams} from './types'; import type { Environment, EnvironmentQueryParams, BuildTypeEnum, DeployTypeEnum } from './types';
import {BuildTypeEnum, DeployTypeEnum} from './types';
import { getBuildTypeInfo, getDeployTypeInfo } from './utils'; import { getBuildTypeInfo, getDeployTypeInfo } from './utils';
import EnvironmentModal from './components/EnvironmentModal';
import {ProTable} from '@ant-design/pro-components'; const DEFAULT_PAGE_SIZE = 10;
import type {ProColumns, ActionType} from '@ant-design/pro-components';
const EnvironmentList: React.FC = () => { const EnvironmentList: React.FC = () => {
const [modalVisible, setModalVisible] = useState(false); const { toast } = useToast();
const [currentEnvironment, setCurrentEnvironment] = useState<Environment>(); const [list, setList] = useState<Environment[]>([]);
const actionRef = React.useRef<ActionType>(); const [loading, setLoading] = useState(false);
const {message: messageApi} = App.useApp(); const [query, setQuery] = useState<EnvironmentQueryParams>({
pageNum: 1,
pageSize: DEFAULT_PAGE_SIZE,
});
const [total, setTotal] = useState(0);
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
const handleDelete = async (id: number) => { // 对话框状态
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [currentRecord, setCurrentRecord] = useState<Environment>();
// 加载数据
const loadData = async () => {
setLoading(true);
try { try {
await deleteEnvironment(id); const response = await getEnvironmentPage(query);
messageApi.success('删除成功'); if (response) {
actionRef.current?.reload(); setList(response.content || []);
setTotal(response.totalElements || 0);
// 计算统计数据
const all = response.content || [];
setStats({
total: all.length,
enabled: all.filter((item) => item.enabled).length,
disabled: all.filter((item) => !item.enabled).length,
});
}
} catch (error) { } catch (error) {
messageApi.error('删除失败'); toast({ title: '加载失败', description: '无法加载环境列表', variant: 'destructive' });
} finally {
setLoading(false);
} }
}; };
const handleAdd = () => { useEffect(() => {
setCurrentEnvironment(undefined); loadData();
setModalVisible(true); }, [query.pageNum, query.pageSize]);
// 搜索
const handleSearch = () => {
setQuery({ ...query, pageNum: 1 });
loadData();
}; };
const handleEdit = (environment: Environment) => { // 重置
setCurrentEnvironment(environment); const handleReset = () => {
setModalVisible(true); setQuery({ pageNum: 1, pageSize: DEFAULT_PAGE_SIZE });
loadData();
}; };
const handleModalClose = () => {
setModalVisible(false);
setCurrentEnvironment(undefined);
};
const handleSuccess = () => {
setModalVisible(false);
setCurrentEnvironment(undefined);
actionRef.current?.reload();
};
const columns: ProColumns<Environment>[] = [
{
title: '环境编码',
dataIndex: 'envCode',
width: 120,
copyable: true,
ellipsis: true,
fixed: 'left',
},
{
title: '环境名称',
dataIndex: 'envName',
width: 150,
ellipsis: true,
},
{
title: '环境描述',
dataIndex: 'envDesc',
ellipsis: true,
},
{
title: '构建类型',
dataIndex: 'buildType',
width: 120,
render: (buildType) => {
const typeInfo = getBuildTypeInfo(buildType as BuildTypeEnum);
return (
<Tag color={typeInfo.color}>
<Space>
{typeInfo.icon}
{typeInfo.label}
</Space>
</Tag>
);
},
filters: [
{text: 'Jenkins构建', value: BuildTypeEnum.JENKINS},
{text: 'GitLab Runner构建', value: BuildTypeEnum.GITLAB_RUNNER},
{text: 'GitHub Action构建', value: BuildTypeEnum.GITHUB_ACTION},
],
filterMode: 'menu',
filtered: false,
},
{
title: '部署类型',
dataIndex: 'deployType',
width: 120,
render: (deployType) => {
const typeInfo = getDeployTypeInfo(deployType as DeployTypeEnum);
return (
<Tag color={typeInfo.color}>
<Space>
{typeInfo.icon}
{typeInfo.label}
</Space>
</Tag>
);
},
filters: [
{text: 'Kubernetes集群部署', value: DeployTypeEnum.K8S},
{text: 'Docker容器部署', value: DeployTypeEnum.DOCKER},
{text: '虚拟机部署', value: DeployTypeEnum.VM},
],
filterMode: 'menu',
filtered: false,
},
{
title: '状态',
dataIndex: 'enabled',
width: 100,
valueEnum: {
true: {text: '启用', status: 'Success'},
false: {text: '禁用', status: 'Default'},
},
},
{
title: '排序',
dataIndex: 'sort',
width: 80,
sorter: true,
},
{
title: '操作',
width: 180,
key: 'action',
valueType: 'option',
fixed: 'right',
render: (_, record) => [
<Button
key="edit"
type="link"
onClick={() => handleEdit(record)}
>
<Space>
<EditOutlined/>
</Space>
</Button>,
<Popconfirm
key="delete"
title="确定要删除该环境吗?"
description="删除后将无法恢复,请谨慎操作"
onConfirm={() => handleDelete(record.id)}
>
<Button
type="link"
danger
>
<Space>
<DeleteOutlined/>
</Space>
</Button>
</Popconfirm>
],
},
];
return ( return (
<PageContainer <PageContainer>
header={{ {/* 页面标题 */}
title: '环境管理' <div className="flex items-center justify-between">
}} <h2 className="text-3xl font-bold tracking-tight"></h2>
> <Button onClick={() => {
<ProTable<Environment> setCurrentRecord(undefined);
columns={columns} setEditDialogOpen(true);
actionRef={actionRef} }}>
scroll={{x: 'max-content'}} <Plus className="h-4 w-4 mr-2" />
cardBordered
rowKey="id"
search={false}
options={{
setting: false,
density: false,
fullScreen: false,
reload: false,
}}
toolbar={{
actions: [
<Button
key="add"
type="primary"
onClick={handleAdd}
icon={<PlusOutlined/>}
>
</Button> </Button>
], </div>
}}
form={{
syncToUrl: true,
}}
pagination={{
pageSize: 10,
showQuickJumper: true,
}}
request={async (params) => {
try {
const queryParams: EnvironmentQueryParams = {
pageSize: params.pageSize,
pageNum: params.current,
envCode: params.envCode as string,
envName: params.envName as string,
buildType: params.buildType as BuildTypeEnum,
deployType: params.deployType as DeployTypeEnum,
};
const data = await getEnvironmentPage(queryParams);
return {
data: data.content || [],
success: true,
total: data.totalElements || 0,
};
} catch (error) {
messageApi.error('获取环境列表失败');
return {
data: [],
success: false,
total: 0,
};
}
}}
/>
{modalVisible && ( {/* 统计卡片 */}
<EnvironmentModal <div className="grid gap-4 md:grid-cols-3">
open={modalVisible} <Card>
onCancel={handleModalClose} <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
onSuccess={handleSuccess} <CardTitle className="text-sm font-medium"></CardTitle>
initialValues={currentEnvironment} <Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
{/* 搜索过滤 */}
<Card>
<div className="p-6">
<div className="flex items-center gap-4">
<Input
placeholder="环境编码"
value={query.envCode || ''}
onChange={(e) => setQuery({ ...query, envCode: e.target.value })}
className="max-w-[200px]"
/> />
<Input
placeholder="环境名称"
value={query.envName || ''}
onChange={(e) => setQuery({ ...query, envName: e.target.value })}
className="max-w-[200px]"
/>
<Select
value={query.buildType}
onValueChange={(value) => setQuery({ ...query, buildType: value as BuildTypeEnum })}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="构建类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="JENKINS">Jenkins构建</SelectItem>
<SelectItem value="GITLAB_RUNNER">GitLab Runner构建</SelectItem>
<SelectItem value="GITHUB_ACTION">GitHub Action构建</SelectItem>
</SelectContent>
</Select>
<Select
value={query.deployType}
onValueChange={(value) => setQuery({ ...query, deployType: value as DeployTypeEnum })}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="部署类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="K8S">Kubernetes集群部署</SelectItem>
<SelectItem value="DOCKER">Docker容器部署</SelectItem>
<SelectItem value="VM"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleReset}>
</Button>
<Button onClick={handleSearch}>
</Button>
</div>
</div>
</Card>
{/* 数据表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
list.map((item) => {
const buildInfo = getBuildTypeInfo(item.buildType);
const deployInfo = getDeployTypeInfo(item.deployType);
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.envCode}</TableCell>
<TableCell>{item.envName}</TableCell>
<TableCell className="text-muted-foreground">{item.envDesc || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="inline-flex items-center gap-1">
{buildInfo.icon}
{buildInfo.label}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="inline-flex items-center gap-1">
{deployInfo.icon}
{deployInfo.label}
</Badge>
</TableCell>
<TableCell>{item.sort}</TableCell>
<TableCell>
<Badge variant={item.enabled ? "default" : "secondary"} className="inline-flex">
{item.enabled ? '启用' : '禁用'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentRecord(item);
setEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentRecord(item);
setDeleteDialogOpen(true);
}}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</TableCell>
</TableRow>
);
})
)} )}
</TableBody>
</Table>
<div className="flex justify-end border-t border-border bg-muted/40">
<DataTablePagination
pageIndex={query.pageNum || 1}
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
pageCount={Math.ceil(total / (query.pageSize || DEFAULT_PAGE_SIZE))}
onPageChange={(page) => setQuery({ ...query, pageNum: page })}
/>
</div>
</div>
</CardContent>
</Card>
{/* 对话框 */}
<EditDialog
open={editDialogOpen}
record={currentRecord}
onOpenChange={setEditDialogOpen}
onSuccess={loadData}
/>
<DeleteDialog
open={deleteDialogOpen}
record={currentRecord}
onOpenChange={setDeleteDialogOpen}
onSuccess={loadData}
/>
</PageContainer> </PageContainer>
); );
}; };

View File

@ -0,0 +1,104 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { AlertCircle } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { deleteExternalSystem } from '../service';
import type { ExternalSystemResponse, SystemType } from '../types';
interface DeleteDialogProps {
open: boolean;
record?: ExternalSystemResponse;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
const getSystemTypeLabel = (type: SystemType) => {
const typeMap = {
JENKINS: 'Jenkins',
GIT: 'Git',
ZENTAO: '禅道',
};
return typeMap[type];
};
/**
*
*/
const DeleteDialog: React.FC<DeleteDialogProps> = ({
open,
record,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const handleConfirm = async () => {
if (!record) return;
try {
await deleteExternalSystem(record.id);
toast({ title: '删除成功', description: `系统 "${record.name}" 已删除` });
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
if (!record) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="space-y-3 pt-4">
<p></p>
<div className="bg-muted p-3 rounded-md space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<Badge variant="outline">{getSystemTypeLabel(record.type)}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono text-xs truncate max-w-[250px]">{record.url}</span>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteDialog;

View File

@ -0,0 +1,265 @@
import React, { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { createExternalSystem, updateExternalSystem } from '../service';
import type { ExternalSystemResponse, ExternalSystemRequest, SystemType, AuthType } from '../types';
interface EditDialogProps {
open: boolean;
record?: ExternalSystemResponse;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/**
*
*/
const EditDialog: React.FC<EditDialogProps> = ({
open,
record,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const [formData, setFormData] = useState<Partial<ExternalSystemRequest>>({
enabled: true,
sort: 1,
authType: 'BASIC' as AuthType,
});
useEffect(() => {
if (open) {
if (record) {
setFormData({
name: record.name,
type: record.type,
url: record.url,
authType: record.authType,
username: record.username,
password: undefined, // 不显示密码
token: undefined,
sort: record.sort,
remark: record.remark,
enabled: record.enabled,
});
} else {
setFormData({ enabled: true, sort: 1, authType: 'BASIC' as AuthType });
}
}
}, [open, record]);
const handleSubmit = async () => {
try {
// 验证
if (!formData.name?.trim()) {
toast({ title: '提示', description: '请输入系统名称', variant: 'destructive' });
return;
}
if (!formData.type) {
toast({ title: '提示', description: '请选择系统类型', variant: 'destructive' });
return;
}
if (!formData.url?.trim()) {
toast({ title: '提示', description: '请输入系统地址', variant: 'destructive' });
return;
}
// URL 验证
try {
new URL(formData.url);
} catch {
toast({ title: '提示', description: '请输入有效的URL', variant: 'destructive' });
return;
}
if (formData.authType === 'BASIC') {
if (!formData.username?.trim()) {
toast({ title: '提示', description: '请输入用户名', variant: 'destructive' });
return;
}
if (!record && !formData.password) {
toast({ title: '提示', description: '请输入密码', variant: 'destructive' });
return;
}
}
if (formData.authType === 'TOKEN') {
if (!record && !formData.token) {
toast({ title: '提示', description: '请输入访问令牌', variant: 'destructive' });
return;
}
}
if (record) {
await updateExternalSystem(record.id, formData as ExternalSystemRequest);
toast({ title: '更新成功', description: `系统 "${formData.name}" 已更新` });
} else {
await createExternalSystem(formData as ExternalSystemRequest);
toast({ title: '创建成功', description: `系统 "${formData.name}" 已创建` });
}
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('保存失败:', error);
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{record ? '编辑系统' : '新增系统'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入系统名称"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="type"> *</Label>
<Select
value={formData.type}
onValueChange={(value) => setFormData({ ...formData, type: value as SystemType })}
>
<SelectTrigger>
<SelectValue placeholder="请选择系统类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="JENKINS">Jenkins</SelectItem>
<SelectItem value="GIT">Git</SelectItem>
<SelectItem value="ZENTAO"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="url"> *</Label>
<Input
id="url"
value={formData.url || ''}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
placeholder="请输入系统地址"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="authType"> *</Label>
<Select
value={formData.authType}
onValueChange={(value) => setFormData({ ...formData, authType: value as AuthType })}
>
<SelectTrigger>
<SelectValue placeholder="请选择认证方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="BASIC"></SelectItem>
<SelectItem value="TOKEN"></SelectItem>
<SelectItem value="OAUTH">OAuth2</SelectItem>
</SelectContent>
</Select>
</div>
{formData.authType === 'BASIC' && (
<>
<div className="grid gap-2">
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username || ''}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="请输入用户名"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password"> {!record && '*'}</Label>
<Input
id="password"
type="password"
value={formData.password || ''}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={record ? '不修改请留空' : '请输入密码'}
/>
</div>
</>
)}
{formData.authType === 'TOKEN' && (
<div className="grid gap-2">
<Label htmlFor="token">访 {!record && '*'}</Label>
<Input
id="token"
type="password"
value={formData.token || ''}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
placeholder={record ? '不修改请留空' : '请输入访问令牌'}
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="sort"> *</Label>
<Input
id="sort"
type="number"
value={formData.sort || 1}
onChange={(e) => setFormData({ ...formData, sort: Number(e.target.value) })}
placeholder="请输入显示排序"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="remark"></Label>
<Textarea
id="remark"
value={formData.remark || ''}
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="请输入备注"
rows={4}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="enabled"></Label>
<Switch
id="enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default EditDialog;

View File

@ -1,343 +1,349 @@
import React, {useState} from 'react'; import React, { useState, useEffect } from 'react';
import {Card, Table, Button, Space, Modal, Form, Input, message, Select, InputNumber, Switch, Tag, Tooltip} from 'antd'; import { PageContainer } from '@/components/ui/page-container';
import {PlusOutlined, EditOutlined, DeleteOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, LinkOutlined, MinusCircleOutlined} from '@ant-design/icons'; import {
import type {ColumnsType} from 'antd/es/table'; Card,
import {useTableData} from '@/hooks/useTableData'; CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useToast } from '@/components/ui/use-toast';
import { DataTablePagination } from '@/components/ui/pagination';
import { Plus, Edit, Trash2, Link, Server, Activity, Database } from 'lucide-react';
import EditDialog from './components/EditDialog';
import DeleteDialog from './components/DeleteDialog';
import * as service from './service'; import * as service from './service';
import {SystemType, AuthType, SyncStatus, ExternalSystemResponse, ExternalSystemRequest, ExternalSystemQuery} from './types'; import type { ExternalSystemResponse, ExternalSystemQuery, SystemType, AuthType } from './types';
const DEFAULT_PAGE_SIZE = 10;
const ExternalPage: React.FC = () => { const ExternalPage: React.FC = () => {
const [form] = Form.useForm(); const { toast } = useToast();
const [modalVisible, setModalVisible] = useState(false); const [list, setList] = useState<ExternalSystemResponse[]>([]);
const [editingSystem, setEditingSystem] = useState<ExternalSystemResponse | null>(null); const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<ExternalSystemQuery>({
const { pageNum: 1,
list, pageSize: DEFAULT_PAGE_SIZE,
loading,
pagination,
handleTableChange,
handleCreate,
handleUpdate,
handleDelete,
refresh
} = useTableData<ExternalSystemResponse, ExternalSystemQuery, ExternalSystemRequest, ExternalSystemRequest>({
service: {
list: service.getExternalSystemsPage,
create: service.createExternalSystem,
update: service.updateExternalSystem,
delete: service.deleteExternalSystem
},
config: {
message: {
createSuccess: '创建系统成功',
updateSuccess: '更新系统成功',
deleteSuccess: '删除系统成功'
}
}
}); });
const [total, setTotal] = useState(0);
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
const handleAdd = () => { // 对话框状态
setEditingSystem(null); const [editDialogOpen, setEditDialogOpen] = useState(false);
form.resetFields(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
form.setFieldsValue({ const [currentRecord, setCurrentRecord] = useState<ExternalSystemResponse>();
enabled: true,
sort: 1, // 加载数据
authType: AuthType.BASIC const loadData = async () => {
setLoading(true);
try {
const response = await service.getExternalSystemsPage(query);
if (response) {
setList(response.content || []);
setTotal(response.totalElements || 0);
// 计算统计数据
const all = response.content || [];
setStats({
total: all.length,
enabled: all.filter((item) => item.enabled).length,
disabled: all.filter((item) => !item.enabled).length,
}); });
setModalVisible(true); }
} catch (error) {
toast({ title: '加载失败', description: '无法加载外部系统列表', variant: 'destructive' });
} finally {
setLoading(false);
}
}; };
const handleEdit = (record: ExternalSystemResponse) => { useEffect(() => {
setEditingSystem(record); loadData();
form.setFieldsValue({ }, [query.pageNum, query.pageSize]);
...record,
password: undefined // 不显示密码 // 搜索
}); const handleSearch = () => {
setModalVisible(true); setQuery({ ...query, pageNum: 1 });
loadData();
}; };
// 重置
const handleReset = () => {
setQuery({ pageNum: 1, pageSize: DEFAULT_PAGE_SIZE });
loadData();
};
// 测试连接
const handleTestConnection = async (id: number) => { const handleTestConnection = async (id: number) => {
try { try {
const success = await service.testConnection(id); const success = await service.testConnection(id);
message.success(success ? '连接成功' : '连接失败'); toast({
title: success ? '连接成功' : '连接失败',
description: success ? '外部系统连接正常' : '无法连接到外部系统',
variant: success ? 'default' : 'destructive',
});
} catch (error) { } catch (error) {
message.error('测试连接失败'); toast({ title: '测试失败', description: '无法测试连接', variant: 'destructive' });
} }
}; };
// 切换状态
const handleStatusChange = async (id: number, enabled: boolean) => { const handleStatusChange = async (id: number, enabled: boolean) => {
try { try {
await service.updateStatus(id, enabled); await service.updateStatus(id, enabled);
message.success('更新状态成功'); toast({ title: '更新成功', description: `系统状态已${enabled ? '启用' : '禁用'}` });
refresh(); loadData();
} catch (error) { } catch (error) {
message.error('更新状态失败'); toast({ title: '更新失败', description: '无法更新系统状态', variant: 'destructive' });
} }
}; };
const handleSubmit = async () => { // 获取系统类型标签
try { const getSystemTypeLabel = (type: SystemType) => {
const values = await form.validateFields();
let success = false;
if (editingSystem) {
success = await handleUpdate(editingSystem.id, values);
} else {
success = await handleCreate(values);
}
if (success) {
setModalVisible(false);
}
} catch (error: any) {
// 如果是表单验证错误,不显示错误消息
if (!error.errorFields) {
// 如果是后端返回的错误,显示后端的错误消息
if (error.response?.data) {
message.error(error.response.data.message || '操作失败');
} else {
message.error(error.message || '操作失败');
}
}
}
};
const columns: ColumnsType<ExternalSystemResponse> = [
{
title: '系统名称',
dataIndex: 'name',
width: 200,
},
{
title: '系统类型',
dataIndex: 'type',
width: 120,
render: (type: SystemType) => {
const typeMap = { const typeMap = {
[SystemType.JENKINS]: 'Jenkins', JENKINS: { label: 'Jenkins', variant: 'default' as const },
[SystemType.GIT]: 'Git', GIT: { label: 'Git', variant: 'secondary' as const },
[SystemType.ZENTAO]: '禅道' ZENTAO: { label: '禅道', variant: 'outline' as const },
}; };
return typeMap[type]; return typeMap[type] || { label: type, variant: 'outline' as const };
} };
},
{ // 获取认证方式标签
title: '系统地址', const getAuthTypeLabel = (authType: AuthType) => {
dataIndex: 'url',
width: 300,
render: (url: string) => (
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
),
},
{
title: '认证方式',
dataIndex: 'authType',
width: 120,
render: (authType: AuthType) => {
const authTypeMap = { const authTypeMap = {
[AuthType.BASIC]: '用户名密码', BASIC: '用户名密码',
[AuthType.TOKEN]: '令牌', TOKEN: '令牌',
[AuthType.OAUTH]: 'OAuth2' OAUTH: 'OAuth2',
}; };
return authTypeMap[authType]; return authTypeMap[authType];
} };
},
{
title: '最后连接时间',
dataIndex: 'lastConnectTime',
width: 150,
render: (time: string) => time || '-'
},
{
title: '状态',
dataIndex: 'enabled',
width: 100,
render: (enabled: boolean, record) => (
<Switch
checked={enabled}
onChange={(checked) => handleStatusChange(record.id, checked)}
checkedChildren="否"
unCheckedChildren="是"
/>
)
},
{
title: '操作',
key: 'action',
width: 280,
render: (_, record) => (
<Space>
<Button
type="link"
icon={<EditOutlined/>}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="link"
icon={<LinkOutlined/>}
onClick={() => handleTestConnection(record.id)}
>
</Button>
<Button
type="link"
danger
icon={<DeleteOutlined/>}
onClick={() => handleDelete(record.id)}
>
</Button>
</Space>
),
},
];
return ( return (
<div> <PageContainer>
<Card> {/* 页面标题 */}
<div style={{marginBottom: 16}}> <div className="flex items-center justify-between">
<Button <h2 className="text-3xl font-bold tracking-tight"></h2>
type="primary" <Button onClick={() => {
icon={<PlusOutlined/>} setCurrentRecord(undefined);
onClick={handleAdd} setEditDialogOpen(true);
> }}>
<Plus className="h-4 w-4 mr-2" />
</Button> </Button>
</div> </div>
<Table
columns={columns} {/* 统计卡片 */}
dataSource={list} <div className="grid gap-4 md:grid-cols-3">
rowKey="id" <Card>
loading={loading} <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
pagination={pagination} <CardTitle className="text-sm font-medium"></CardTitle>
onChange={handleTableChange} <Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
{/* 搜索过滤 */}
<Card>
<div className="p-6">
<div className="flex items-center gap-4">
<Input
placeholder="系统名称"
value={query.name || ''}
onChange={(e) => setQuery({ ...query, name: e.target.value })}
className="max-w-[200px]"
/> />
<Select
value={query.type}
onValueChange={(value) => setQuery({ ...query, type: value as SystemType })}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="系统类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="JENKINS">Jenkins</SelectItem>
<SelectItem value="GIT">Git</SelectItem>
<SelectItem value="ZENTAO"></SelectItem>
</SelectContent>
</Select>
<Select
value={query.enabled?.toString()}
onValueChange={(value) => setQuery({ ...query, enabled: value === 'true' })}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleReset}>
</Button>
<Button onClick={handleSearch}>
</Button>
</div>
</div>
</Card> </Card>
<Modal {/* 数据表格 */}
title={editingSystem ? '编辑系统' : '新增系统'} <Card>
open={modalVisible} <CardHeader>
onOk={handleSubmit} <CardTitle></CardTitle>
onCancel={() => setModalVisible(false)} </CardHeader>
width={600} <CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[250px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[250px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
list.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>
<Badge variant={getSystemTypeLabel(item.type).variant}>
{getSystemTypeLabel(item.type).label}
</Badge>
</TableCell>
<TableCell>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
> >
<Form <Link className="h-3 w-3" />
form={form} {item.url}
layout="vertical" </a>
> </TableCell>
<Form.Item <TableCell>{getAuthTypeLabel(item.authType)}</TableCell>
name="name" <TableCell className="text-muted-foreground">
label="系统名称" {item.lastConnectTime || '-'}
rules={[{required: true, message: '请输入系统名称'}]} </TableCell>
> <TableCell>
<Input placeholder="请输入系统名称"/> <Switch
</Form.Item> checked={item.enabled}
onCheckedChange={(checked) => handleStatusChange(item.id, checked)}
<Form.Item />
name="type" </TableCell>
label="系统类型" <TableCell>
rules={[{required: true, message: '请选择系统类型'}]} <div className="flex items-center gap-2">
> <Button
<Select> variant="ghost"
<Select.Option value={SystemType.JENKINS}>Jenkins</Select.Option> size="sm"
<Select.Option value={SystemType.GIT}>Git</Select.Option> onClick={() => {
<Select.Option value={SystemType.ZENTAO}></Select.Option> setCurrentRecord(item);
</Select> setEditDialogOpen(true);
</Form.Item>
<Form.Item
name="url"
label="系统地址"
rules={[
{required: true, message: '请输入系统地址'},
{type: 'url', message: '请输入有效的URL'}
]}
>
<Input placeholder="请输入系统地址"/>
</Form.Item>
<Form.Item
name="authType"
label="认证方式"
rules={[{required: true, message: '请选择认证方式'}]}
>
<Select>
<Select.Option value={AuthType.BASIC}></Select.Option>
<Select.Option value={AuthType.TOKEN}></Select.Option>
<Select.Option value={AuthType.OAUTH}>OAuth2</Select.Option>
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.authType !== currentValues.authType}
>
{({getFieldValue}) => {
const authType = getFieldValue('authType');
if (authType === AuthType.BASIC) {
return (
<>
<Form.Item
name="username"
label="用户名"
rules={[{required: true, message: '请输入用户名'}]}
>
<Input placeholder="请输入用户名"/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{required: !editingSystem, message: '请输入密码'}]}
>
<Input.Password placeholder={editingSystem ? '不修改请留空' : '请输入密码'}/>
</Form.Item>
</>
);
}
if (authType === AuthType.TOKEN) {
return (
<Form.Item
name="token"
label="访问令牌"
rules={[{required: !editingSystem, message: '请输入访问令牌'}]}
>
<Input.Password placeholder={editingSystem ? '不修改请留空' : '请输入访问令牌'}/>
</Form.Item>
);
}
return null;
}} }}
</Form.Item>
<Form.Item
name="sort"
label="显示排序"
rules={[{required: true, message: '请输入显示排序'}]}
> >
<InputNumber style={{width: '100%'}} min={0} placeholder="请输入显示排序"/> <Edit className="h-4 w-4 mr-1" />
</Form.Item>
</Button>
<Form.Item <Button
name="remark" variant="ghost"
label="备注" size="sm"
onClick={() => handleTestConnection(item.id)}
> >
<Input.TextArea rows={4} placeholder="请输入备注"/> <Link className="h-4 w-4 mr-1" />
</Form.Item>
</Button>
<Form.Item <Button
name="enabled" variant="ghost"
label="是否禁用" size="sm"
valuePropName="checked" onClick={() => {
setCurrentRecord(item);
setDeleteDialogOpen(true);
}}
className="text-destructive hover:text-destructive"
> >
<Switch checkedChildren="否" unCheckedChildren="是"/> <Trash2 className="h-4 w-4 mr-1" />
</Form.Item>
</Form> </Button>
</Modal>
</div> </div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex justify-end border-t border-border bg-muted/40">
<DataTablePagination
pageIndex={query.pageNum || 1}
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
pageCount={Math.ceil(total / (query.pageSize || DEFAULT_PAGE_SIZE))}
onPageChange={(page) => setQuery({ ...query, pageNum: page })}
/>
</div>
</div>
</CardContent>
</Card>
{/* 对话框 */}
<EditDialog
open={editDialogOpen}
record={currentRecord}
onOpenChange={setEditDialogOpen}
onSuccess={loadData}
/>
<DeleteDialog
open={deleteDialogOpen}
record={currentRecord}
onOpenChange={setDeleteDialogOpen}
onSuccess={loadData}
/>
</PageContainer>
); );
}; };

View File

@ -0,0 +1,109 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { AlertCircle } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { deleteProjectGroup } from '../service';
import type { ProjectGroup } from '../types';
import { getProjectTypeInfo } from '../utils';
interface DeleteDialogProps {
open: boolean;
record?: ProjectGroup;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/**
*
*/
const DeleteDialog: React.FC<DeleteDialogProps> = ({
open,
record,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const handleConfirm = async () => {
if (!record) return;
try {
await deleteProjectGroup(record.id);
toast({ title: '删除成功', description: `项目组 "${record.projectGroupName}" 已删除` });
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
if (!record) return null;
const typeInfo = getProjectTypeInfo(record.type);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="space-y-3 pt-4">
<p></p>
<div className="bg-muted p-3 rounded-md space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.projectGroupCode}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.projectGroupName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className="inline-flex items-center gap-1">
{typeInfo.icon}
{typeInfo.label}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.totalEnvironments}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.totalApplications}</span>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteDialog;

View File

@ -1,18 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { PageContainer } from '@/components/ui/page-container'; import { PageContainer } from '@/components/ui/page-container';
import { import { getProjectGroupPage } from './service';
PlusOutlined,
EditOutlined,
DeleteOutlined,
TeamOutlined,
EnvironmentOutlined,
SearchOutlined,
} from '@ant-design/icons';
import {getProjectGroupPage, deleteProjectGroup} from './service';
import type { ProjectGroup, ProjectGroupQueryParams } from './types'; import type { ProjectGroup, ProjectGroupQueryParams } from './types';
import { ProjectGroupTypeEnum } from './types'; import { ProjectGroupTypeEnum } from './types';
import { getProjectTypeInfo } from './utils'; import { getProjectTypeInfo } from './utils';
import ProjectGroupModal from './components/ProjectGroupModal'; import ProjectGroupModal from './components/ProjectGroupModal';
import DeleteDialog from './components/DeleteDialog';
import { import {
Table, Table,
TableHeader, TableHeader,
@ -20,73 +13,51 @@ import {
TableHead, TableHead,
TableRow, TableRow,
TableCell, TableCell,
} from "@/components/ui/table"; } from '@/components/ui/table';
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from '@/components/ui/card';
import {Button} from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { import { Input } from '@/components/ui/input';
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from '@/components/ui/select';
import {Badge} from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import {useToast} from "@/components/ui/use-toast"; import { useToast } from '@/components/ui/use-toast';
import { import { useForm } from 'react-hook-form';
AlertDialog, import { zodResolver } from '@hookform/resolvers/zod';
AlertDialogAction, import { searchFormSchema, type SearchFormValues } from './schema';
AlertDialogCancel, import { DataTablePagination } from '@/components/ui/pagination';
AlertDialogContent, import { Plus, Edit, Trash2, FolderKanban, Activity, Database, Layers, Boxes } from 'lucide-react';
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import {searchFormSchema, type SearchFormValues} from "./schema";
import {DataTablePagination} from "@/components/ui/pagination";
interface Column { const DEFAULT_PAGE_SIZE = 10;
accessorKey?: keyof ProjectGroup;
id?: string;
header: string;
size: number;
cell?: (props: { row: { original: ProjectGroup } }) => React.ReactNode;
}
const ProjectGroupList: React.FC = () => { const ProjectGroupList: React.FC = () => {
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [currentProject, setCurrentProject] = useState<ProjectGroup>(); const [currentProject, setCurrentProject] = useState<ProjectGroup>();
const [list, setList] = useState<ProjectGroup[]>([]); const [list, setList] = useState<ProjectGroup[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<Record<string | number, boolean>>({});
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: DEFAULT_PAGE_SIZE,
totalElements: 0, totalElements: 0,
}); });
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<SearchFormValues>({ const form = useForm<SearchFormValues>({
resolver: zodResolver(searchFormSchema), resolver: zodResolver(searchFormSchema),
defaultValues: { defaultValues: {
projectGroupCode: "", projectGroupCode: '',
projectGroupName: "", projectGroupName: '',
type: undefined, type: undefined,
enabled: undefined, enabled: undefined,
}, },
@ -102,15 +73,22 @@ const ProjectGroupList: React.FC = () => {
}; };
const data = await getProjectGroupPage(queryParams); const data = await getProjectGroupPage(queryParams);
setList(data.content || []); setList(data.content || []);
setPagination(prev => ({ setPagination((prev) => ({
...prev, ...prev,
totalElements: data.totalElements, totalElements: data.totalElements,
})); }));
// 计算统计数据
const all = data.content || [];
setStats({
total: all.length,
enabled: all.filter((item) => item.enabled).length,
disabled: all.filter((item) => !item.enabled).length,
});
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: 'destructive',
title: "加载失败", title: '加载失败',
description: "加载数据失败,请稍后重试", description: '加载数据失败,请稍后重试',
duration: 3000, duration: 3000,
}); });
} finally { } finally {
@ -119,7 +97,7 @@ const ProjectGroupList: React.FC = () => {
}; };
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setPagination(prev => ({ setPagination((prev) => ({
...prev, ...prev,
pageNum: page, pageNum: page,
})); }));
@ -129,35 +107,6 @@ const ProjectGroupList: React.FC = () => {
loadData(form.getValues()); loadData(form.getValues());
}, [pagination.pageNum, pagination.pageSize]); }, [pagination.pageNum, pagination.pageSize]);
const handleDelete = async (id: number) => {
try {
await deleteProjectGroup(id);
toast({
variant: "default",
title: "操作成功",
description: "项目组删除成功",
duration: 3000,
});
loadData(form.getValues());
setDeleteDialogOpen(prev => ({...prev, [id]: false}));
} catch (error) {
toast({
variant: "destructive",
title: "操作失败",
description: "删除项目组失败,请稍后重试",
duration: 3000,
});
}
};
const openDeleteDialog = (id: number) => {
setDeleteDialogOpen(prev => ({...prev, [id]: true}));
};
const closeDeleteDialog = (id: number) => {
setDeleteDialogOpen(prev => ({...prev, [id]: false}));
};
const handleEdit = (project: ProjectGroup) => { const handleEdit = (project: ProjectGroup) => {
setCurrentProject(project); setCurrentProject(project);
setModalVisible(true); setModalVisible(true);
@ -174,142 +123,55 @@ const ProjectGroupList: React.FC = () => {
loadData(form.getValues()); loadData(form.getValues());
}; };
const handleSearch = async (values: SearchFormValues) => {
loadData(values);
};
const columns: Column[] = [
{
accessorKey: 'projectGroupCode',
header: '项目组编码',
size: 180,
},
{
accessorKey: 'projectGroupName',
header: '项目组名称',
size: 150,
},
{
accessorKey: 'projectGroupDesc',
header: '项目组描述',
size: 200,
},
{
id: 'type',
header: '项目组类型',
size: 120,
cell: ({row}) => {
const typeInfo = getProjectTypeInfo(row.original.type);
return (
<Badge variant="outline" className="flex items-center gap-1">
{typeInfo.icon}
{typeInfo.label}
</Badge>
);
},
},
{
accessorKey: 'totalEnvironments',
header: '环境数量',
size: 100,
cell: ({row}) => (
<div className="flex items-center gap-1">
<EnvironmentOutlined/>
<span>{row.original.totalEnvironments}</span>
</div>
),
},
{
accessorKey: 'totalApplications',
header: '项目数量',
size: 100,
cell: ({row}) => (
<div className="flex items-center gap-1">
<TeamOutlined/>
<span>{row.original.totalApplications}</span>
</div>
),
},
{
accessorKey: 'enabled',
header: '状态',
size: 100,
cell: ({row}) => (
<Badge variant={row.original.enabled ? "outline" : "secondary"}>
{row.original.enabled ? '启用' : '禁用'}
</Badge>
),
},
{
accessorKey: 'sort',
header: '排序',
size: 80,
},
{
id: 'actions',
header: '操作',
size: 180,
cell: ({row}) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(row.original)}
>
<EditOutlined className="mr-1"/>
</Button>
<AlertDialog
open={deleteDialogOpen[row.original.id] || false}
onOpenChange={(open) => {
if (!open) closeDeleteDialog(row.original.id);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => openDeleteDialog(row.original.id)}
>
<DeleteOutlined className="mr-1"/>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => closeDeleteDialog(row.original.id)}>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(row.original.id)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
),
},
];
return ( return (
<PageContainer> <PageContainer>
{/* 页面标题 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight"></h2> <h2 className="text-3xl font-bold tracking-tight"></h2>
<Button onClick={() => setModalVisible(true)}> <Button onClick={() => {
<PlusOutlined className="mr-1"/> setCurrentProject(undefined);
setModalVisible(true);
}}>
<Plus className="h-4 w-4 mr-2" />
</Button> </Button>
</div> </div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
{/* 搜索过滤 */}
<Card> <Card>
<div className="p-6"> <div className="p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -352,126 +214,106 @@ const ProjectGroupList: React.FC = () => {
<Button variant="outline" onClick={() => form.reset()}> <Button variant="outline" onClick={() => form.reset()}>
</Button> </Button>
<Button onClick={() => loadData(form.getValues())}> <Button onClick={() => loadData(form.getValues())}></Button>
</Button>
</div> </div>
</div> </div>
</Card> </Card>
{/* 数据表格 */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent>
<div className="relative rounded-md border"> <div className="rounded-md border">
<div className="overflow-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="min-w-[140px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
<TableHead className="min-w-[200px]"></TableHead> <TableHead className="w-[200px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead> <TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead> <TableHead className="w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead> <TableHead className="w-[80px]"></TableHead>
<TableHead <TableHead className="w-[180px]"></TableHead>
className="sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.2)] min-w-[160px]"
>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{list.map((item) => ( {list.length === 0 ? (
<TableRow key={item.id}> <TableRow>
<TableCell>{item.projectGroupCode}</TableCell> <TableCell colSpan={9} className="h-24 text-center">
<TableCell>{item.projectGroupName}</TableCell>
<TableCell>{item.projectGroupDesc}</TableCell> </TableCell>
<TableCell> </TableRow>
{(() => { ) : (
list.map((item) => {
const typeInfo = getProjectTypeInfo(item.type); const typeInfo = getProjectTypeInfo(item.type);
return ( return (
<Badge variant="outline" className="flex items-center gap-1"> <TableRow key={item.id}>
<TableCell className="font-medium">{item.projectGroupCode}</TableCell>
<TableCell>{item.projectGroupName}</TableCell>
<TableCell className="text-muted-foreground">
{item.projectGroupDesc}
</TableCell>
<TableCell>
<Badge variant="outline" className="inline-flex items-center gap-1">
{typeInfo.icon} {typeInfo.icon}
{typeInfo.label} {typeInfo.label}
</Badge> </Badge>
);
})()}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<EnvironmentOutlined/> <Layers className="h-3 w-3 text-muted-foreground" />
<span>{item.totalEnvironments}</span> <span>{item.totalEnvironments}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TeamOutlined/> <Boxes className="h-3 w-3 text-muted-foreground" />
<span>{item.totalApplications}</span> <span>{item.totalApplications}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={item.enabled ? "outline" : "secondary"}> <Badge
variant={item.enabled ? 'default' : 'secondary'}
className="inline-flex"
>
{item.enabled ? '启用' : '禁用'} {item.enabled ? '启用' : '禁用'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{item.sort}</TableCell> <TableCell>{item.sort}</TableCell>
<TableCell className="sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.2)]"> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleEdit(item)} onClick={() => handleEdit(item)}
> >
<EditOutlined className="mr-1"/> <Edit className="h-4 w-4 mr-1" />
</Button> </Button>
<AlertDialog
open={deleteDialogOpen[item.id] || false}
onOpenChange={(open) => {
if (!open) closeDeleteDialog(item.id);
}}
>
<AlertDialogTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-destructive" onClick={() => {
onClick={() => openDeleteDialog(item.id)} setCurrentProject(item);
setDeleteDialogOpen(true);
}}
className="text-destructive hover:text-destructive"
> >
<DeleteOutlined className="mr-1"/> <Trash2 className="h-4 w-4 mr-1" />
</Button> </Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => closeDeleteDialog(item.id)}>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(item.id)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})
)}
</TableBody> </TableBody>
</Table> </Table>
</div>
<div className="flex justify-end border-t border-border bg-muted/40"> <div className="flex justify-end border-t border-border bg-muted/40">
<DataTablePagination <DataTablePagination
pageIndex={pagination.pageNum} pageIndex={pagination.pageNum}
@ -484,12 +326,19 @@ const ProjectGroupList: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
{/* 对话框 */}
<ProjectGroupModal <ProjectGroupModal
open={modalVisible} open={modalVisible}
onCancel={handleModalClose} onCancel={handleModalClose}
onSuccess={handleSuccess} onSuccess={handleSuccess}
initialValues={currentProject} initialValues={currentProject}
/> />
<DeleteDialog
open={deleteDialogOpen}
record={currentProject}
onOpenChange={setDeleteDialogOpen}
onSuccess={handleSuccess}
/>
</PageContainer> </PageContainer>
); );
}; };