增加应用分类

This commit is contained in:
dengqichen 2025-10-28 17:29:32 +08:00
parent 93551ef55f
commit d499d6b759
16 changed files with 839 additions and 1056 deletions

View File

@ -0,0 +1,46 @@
import request from '@/utils/request';
import type { Page } from '@/types/base';
import type {
ApplicationCategoryQuery,
ApplicationCategoryResponse,
ApplicationCategoryRequest,
} from './types';
const BASE_URL = '/api/v1/application-category';
/**
*
*/
export const getCategories = (params?: ApplicationCategoryQuery) =>
request.get<Page<ApplicationCategoryResponse>>(`${BASE_URL}/page`, { params });
/**
*
*/
export const getCategoryById = (id: number) =>
request.get<ApplicationCategoryResponse>(`${BASE_URL}/${id}`);
/**
*
*/
export const getEnabledCategories = () =>
request.get<ApplicationCategoryResponse[]>(`${BASE_URL}/enabled`);
/**
*
*/
export const createCategory = (data: ApplicationCategoryRequest) =>
request.post<ApplicationCategoryResponse>(BASE_URL, data);
/**
*
*/
export const updateCategory = (id: number, data: ApplicationCategoryRequest) =>
request.put<ApplicationCategoryResponse>(`${BASE_URL}/${id}`, data);
/**
*
*/
export const deleteCategory = (id: number) =>
request.delete(`${BASE_URL}/${id}`);

View File

@ -0,0 +1,40 @@
import { BaseQuery } from '@/types/base';
/**
*
*/
export interface ApplicationCategoryQuery extends BaseQuery {
name?: string;
code?: string;
enabled?: boolean;
}
/**
*
*/
export interface ApplicationCategoryResponse {
id: number;
name: string;
code: string;
description?: string;
icon?: string;
sort: number;
enabled: boolean;
createBy?: string;
createTime?: string;
updateBy?: string;
updateTime?: string;
}
/**
* /
*/
export interface ApplicationCategoryRequest {
name: string;
code: string;
description?: string;
icon?: string;
sort?: number;
enabled?: boolean;
}

View File

@ -5,6 +5,8 @@ import {createApplication, updateApplication, getRepositoryGroupList, getReposit
import type {ExternalSystemResponse} from '@/pages/Deploy/External/types'; import type {ExternalSystemResponse} from '@/pages/Deploy/External/types';
import {SystemType} from '@/pages/Deploy/External/types'; import {SystemType} from '@/pages/Deploy/External/types';
import {getExternalSystems} from '@/pages/Deploy/External/service'; import {getExternalSystems} from '@/pages/Deploy/External/service';
import {getEnabledCategories} from '../../Category/service';
import type {ApplicationCategoryResponse} from '../../Category/types';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -35,7 +37,6 @@ import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod"; import {zodResolver} from "@hookform/resolvers/zod";
import {applicationFormSchema, type ApplicationFormValues} from "../schema"; import {applicationFormSchema, type ApplicationFormValues} from "../schema";
import {Textarea} from "@/components/ui/textarea"; import {Textarea} from "@/components/ui/textarea";
import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem} from "@/components/ui/command";
import {ScrollArea} from "@/components/ui/scroll-area"; import {ScrollArea} from "@/components/ui/scroll-area";
import {Check, ChevronDown, Search} from "lucide-react"; import {Check, ChevronDown, Search} from "lucide-react";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
@ -46,7 +47,6 @@ interface ApplicationModalProps {
onCancel: () => void; onCancel: () => void;
onSuccess: () => void; onSuccess: () => void;
initialValues?: Application; initialValues?: Application;
projectGroupId: number;
} }
const ApplicationModal: React.FC<ApplicationModalProps> = ({ const ApplicationModal: React.FC<ApplicationModalProps> = ({
@ -54,14 +54,13 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
onCancel, onCancel,
onSuccess, onSuccess,
initialValues, initialValues,
projectGroupId,
}) => { }) => {
const [categories, setCategories] = useState<ApplicationCategoryResponse[]>([]);
const [externalSystems, setExternalSystems] = useState<ExternalSystemResponse[]>([]); const [externalSystems, setExternalSystems] = useState<ExternalSystemResponse[]>([]);
const [repositoryGroups, setRepositoryGroups] = useState<RepositoryGroup[]>([]); const [repositoryGroups, setRepositoryGroups] = useState<RepositoryGroup[]>([]);
const [repositoryProjects, setRepositoryProjects] = useState<RepositoryProject[]>([]); const [repositoryProjects, setRepositoryProjects] = useState<RepositoryProject[]>([]);
const {toast} = useToast(); const {toast} = useToast();
const isEdit = !!initialValues?.id; const isEdit = !!initialValues?.id;
const [selectedExternalSystem, setSelectedExternalSystem] = useState<ExternalSystemResponse>();
const form = useForm<ApplicationFormValues>({ const form = useForm<ApplicationFormValues>({
resolver: zodResolver(applicationFormSchema), resolver: zodResolver(applicationFormSchema),
@ -69,16 +68,31 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
appCode: "", appCode: "",
appName: "", appName: "",
appDesc: "", appDesc: "",
categoryId: undefined,
language: DevelopmentLanguageTypeEnum.JAVA, language: DevelopmentLanguageTypeEnum.JAVA,
enabled: true, enabled: true,
sort: 0, sort: 0,
externalSystemId: undefined, externalSystemId: undefined,
repoGroupId: undefined, repoGroupId: undefined,
repoProjectId: undefined, repoProjectId: undefined,
projectGroupId,
}, },
}); });
// 加载分类列表
const loadCategories = async () => {
try {
const data = await getEnabledCategories();
setCategories(data || []);
} catch (error) {
toast({
variant: "destructive",
title: "加载分类失败",
description: error instanceof Error ? error.message : undefined,
duration: 3000,
});
}
};
// 加载外部系统列表 // 加载外部系统列表
const loadExternalSystems = async () => { const loadExternalSystems = async () => {
try { try {
@ -100,6 +114,7 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
}; };
useEffect(() => { useEffect(() => {
loadCategories();
loadExternalSystems(); loadExternalSystems();
}, []); }, []);
@ -110,10 +125,10 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
appCode: initialValues.appCode, appCode: initialValues.appCode,
appName: initialValues.appName, appName: initialValues.appName,
appDesc: initialValues.appDesc || "", appDesc: initialValues.appDesc || "",
categoryId: initialValues.categoryId,
language: initialValues.language, language: initialValues.language,
enabled: initialValues.enabled, enabled: initialValues.enabled,
sort: initialValues.sort, sort: initialValues.sort,
projectGroupId: initialValues.projectGroupId,
externalSystemId: initialValues.externalSystemId, externalSystemId: initialValues.externalSystemId,
repoGroupId: initialValues.repoGroupId, repoGroupId: initialValues.repoGroupId,
repoProjectId: initialValues.repoProjectId repoProjectId: initialValues.repoProjectId
@ -132,19 +147,23 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
}, [initialValues]); }, [initialValues]);
// 当选择外部系统时,获取对应的仓库组列表 // 当选择外部系统时,获取对应的仓库组列表
const handleExternalSystemChange = (externalSystemId: number) => { const handleExternalSystemChange = (externalSystemId: number | undefined) => {
form.setValue('repoGroupId', undefined); form.setValue('repoGroupId', undefined);
form.setValue('repoProjectId', undefined); form.setValue('repoProjectId', undefined);
setRepositoryProjects([]); setRepositoryProjects([]);
if (externalSystemId) {
fetchRepositoryGroups(externalSystemId); fetchRepositoryGroups(externalSystemId);
}
}; };
const fetchRepositoryGroups = async (externalSystemId: number) => { const fetchRepositoryGroups = async (externalSystemId: number | undefined) => {
if (!externalSystemId) return;
const response = await getRepositoryGroupList(externalSystemId); const response = await getRepositoryGroupList(externalSystemId);
setRepositoryGroups(response || []); setRepositoryGroups(response || []);
}; };
const fetchRepositoryProjects = async (repoGroupId: number) => { const fetchRepositoryProjects = async (repoGroupId: number | undefined) => {
if (!repoGroupId) return;
const response = await getRepositoryProjects(repoGroupId); const response = await getRepositoryProjects(repoGroupId);
setRepositoryProjects(response || []); setRepositoryProjects(response || []);
}; };
@ -156,14 +175,14 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
await updateApplication({ await updateApplication({
...values, ...values,
id: initialValues!.id, id: initialValues!.id,
}); } as any);
toast({ toast({
title: "更新成功", title: "更新成功",
description: `应用 ${values.appName} 已更新`, description: `应用 ${values.appName} 已更新`,
duration: 3000, duration: 3000,
}); });
} else { } else {
await createApplication(values); await createApplication(values as any);
toast({ toast({
title: "创建成功", title: "创建成功",
description: `应用 ${values.appName} 已创建`, description: `应用 ${values.appName} 已创建`,
@ -229,6 +248,36 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
)} )}
/> />
<FormField
control={form.control}
name="categoryId"
render={({field}) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择应用分类"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="language" name="language"
@ -355,10 +404,11 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
group.repoGroupId === field.value && "bg-accent text-accent-foreground" group.repoGroupId === field.value && "bg-accent text-accent-foreground"
)} )}
onClick={() => { onClick={() => {
form.setValue("repoGroupId", group.repoGroupId); const groupId = group.repoGroupId;
form.setValue("repoProjectId", undefined); form.setValue("repoGroupId", groupId);
form.setValue("repoProjectId", undefined as any);
setRepositoryProjects([]); setRepositoryProjects([]);
fetchRepositoryProjects(group.repoGroupId); fetchRepositoryProjects(groupId);
setSearchValue(""); setSearchValue("");
setOpen(false); setOpen(false);
}} }}
@ -547,10 +597,10 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={() => { onClick={async () => {
console.log('Submit button clicked'); console.log('Submit button clicked');
console.log('Form values:', form.getValues()); console.log('Form values:', form.getValues());
const isValid = form.trigger(); const isValid = await form.trigger();
console.log('Form validation result:', isValid); console.log('Form validation result:', isValid);
if (isValid) { if (isValid) {
handleSubmit(form.getValues()); handleSubmit(form.getValues());

View File

@ -0,0 +1,562 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { useForm } from "react-hook-form";
import { DataTablePagination } from '@/components/ui/pagination';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import {
Plus,
Edit,
Trash2,
Search,
Loader2,
FolderKanban,
CheckCircle2,
XCircle,
} from "lucide-react";
import DynamicIcon from '@/components/DynamicIcon';
import LucideIconSelect from '@/components/LucideIconSelect';
import {
getCategories,
createCategory,
updateCategory,
deleteCategory
} from '../../Category/service';
import type {
ApplicationCategoryResponse,
ApplicationCategoryRequest,
ApplicationCategoryQuery
} from '../../Category/types';
import type { Page } from '@/types/base';
interface CategoryManageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
/**
*
*/
const CategoryManageDialog: React.FC<CategoryManageDialogProps> = ({
open,
onOpenChange,
onSuccess
}) => {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<ApplicationCategoryResponse> | null>(null);
const [searchText, setSearchText] = useState('');
const [editMode, setEditMode] = useState(false);
const [editRecord, setEditRecord] = useState<ApplicationCategoryResponse | null>(null);
const [iconSelectOpen, setIconSelectOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<ApplicationCategoryResponse | null>(null);
// 分页状态
const [pageNum, setPageNum] = useState(DEFAULT_CURRENT - 1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const form = useForm<ApplicationCategoryRequest>({
defaultValues: {
name: '',
code: '',
icon: '',
sort: 0,
description: '',
enabled: true,
}
});
// 加载分类列表
const loadCategories = async () => {
setLoading(true);
try {
const query: ApplicationCategoryQuery = {
name: searchText || undefined,
pageNum,
pageSize,
};
const result = await getCategories(query);
setData(result || null);
} catch (error) {
console.error('加载分类失败:', error);
toast({
variant: "destructive",
title: "加载失败",
description: error instanceof Error ? error.message : '未知错误',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
loadCategories();
}
}, [open, searchText, pageNum, pageSize]);
// 搜索
const handleSearch = () => {
setPageNum(0); // 搜索时重置到第一页
};
// 新建
const handleCreate = () => {
form.reset({
name: '',
code: '',
icon: '',
sort: 0,
description: '',
enabled: true,
});
setEditRecord(null);
setEditMode(true);
};
// 编辑
const handleEdit = (record: ApplicationCategoryResponse) => {
setEditRecord(record);
form.reset({
name: record.name,
code: record.code,
icon: record.icon || '',
sort: record.sort,
description: record.description || '',
enabled: record.enabled,
});
setEditMode(true);
};
// 打开删除确认对话框
const handleDeleteClick = (record: ApplicationCategoryResponse) => {
setDeleteRecord(record);
setDeleteDialogOpen(true);
};
// 确认删除
const confirmDelete = async () => {
if (!deleteRecord) return;
try {
await deleteCategory(deleteRecord.id);
toast({
title: "删除成功",
description: `分类 "${deleteRecord.name}" 已删除`,
});
loadCategories();
onSuccess?.();
setDeleteDialogOpen(false);
setDeleteRecord(null);
} catch (error) {
console.error('删除失败:', error);
toast({
variant: "destructive",
title: "删除失败",
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 保存
const handleSave = async (values: ApplicationCategoryRequest) => {
try {
if (editRecord) {
await updateCategory(editRecord.id, values);
toast({
title: "更新成功",
description: `分类 "${values.name}" 已更新`,
});
} else {
await createCategory(values);
toast({
title: "创建成功",
description: `分类 "${values.name}" 已创建`,
});
}
setEditMode(false);
loadCategories();
onSuccess?.();
} catch (error) {
console.error('保存失败:', error);
toast({
variant: "destructive",
title: "保存失败",
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 取消编辑
const handleCancel = () => {
setEditMode(false);
setEditRecord(null);
form.reset();
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderKanban className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
{!editMode ? (
<div className="space-y-4">
{/* 搜索栏 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索分类名称..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-10"
/>
</div>
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 表格 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[160px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px] sticky right-0 bg-background shadow-[-2px_0_4px_rgba(0,0,0,0.05)]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
<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>
) : data?.content && data.content.length > 0 ? (
data.content.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-medium">
{record.name}
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-0.5 rounded whitespace-nowrap">
{record.code}
</code>
</TableCell>
<TableCell>
{record.icon ? (
<div className="flex items-center justify-center">
<DynamicIcon name={record.icon} className="h-5 w-5" />
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="text-center">{record.sort}</TableCell>
<TableCell>
{record.enabled ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-gray-400" />
)}
</TableCell>
<TableCell className="max-w-[200px] truncate" title={record.description}>
{record.description || '-'}
</TableCell>
<TableCell className="sticky right-0 bg-background shadow-[-2px_0_4px_rgba(0,0,0,0.05)]">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleEdit(record)}
>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleDeleteClick(record)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<FolderKanban className="h-12 w-12 opacity-20" />
<p className="text-sm"></p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{data && data.totalElements > 0 && (
<div className="mt-4">
<DataTablePagination
pageIndex={pageNum + 1}
pageSize={pageSize}
pageCount={Math.ceil((data.totalElements || 0) / pageSize)}
onPageChange={(page) => setPageNum(page - 1)}
/>
</div>
)}
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSave)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
rules={{ required: '请输入分类名称' }}
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入分类名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
rules={{ required: '请输入分类代码' }}
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入分类代码" {...field} disabled={!!editRecord} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
placeholder="点击选择图标"
value={field.value}
readOnly
onClick={() => setIconSelectOpen(true)}
className="cursor-pointer"
/>
{field.value && (
<div className="flex items-center justify-center w-10 h-10 border rounded-md">
<DynamicIcon name={field.value} className="h-5 w-5" />
</div>
)}
</div>
</FormControl>
<FormMessage />
<LucideIconSelect
open={iconSelectOpen}
onOpenChange={setIconSelectOpen}
value={field.value}
onChange={field.onChange}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="number"
placeholder="输入排序值"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="输入分类描述"
className="resize-none"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base"></FormLabel>
<div className="text-sm text-muted-foreground">
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button type="button" variant="outline" onClick={handleCancel}>
</Button>
<Button type="submit">
</Button>
</div>
</form>
</Form>
)}
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p></p>
{deleteRecord && (
<div className="rounded-md border p-3 space-y-2 bg-muted/50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">:</span>
<span className="font-medium">{deleteRecord.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">:</span>
<code className="text-xs bg-background px-2 py-1 rounded">
{deleteRecord.code}
</code>
</div>
{deleteRecord.icon && (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">:</span>
<DynamicIcon name={deleteRecord.icon} className="h-4 w-4" />
</div>
)}
{deleteRecord.description && (
<div className="flex items-start gap-2">
<span className="text-sm font-medium text-muted-foreground">:</span>
<span className="text-sm">{deleteRecord.description}</span>
</div>
)}
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default CategoryManageDialog;

View File

@ -81,14 +81,22 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<span className="font-medium">{record.appName}</span> <span className="font-medium">{record.appName}</span>
</div> </div>
{record.category && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{record.category.name}</span>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<Badge variant="outline">{getLanguageLabel(record.language)}</Badge> <Badge variant="outline">{getLanguageLabel(record.language)}</Badge>
</div> </div>
{record.appDesc && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<span className="font-medium">{record.projectGroup?.projectGroupName || '-'}</span> <span className="font-medium">{record.appDesc}</span>
</div> </div>
)}
</div> </div>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -1,22 +1,20 @@
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 {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CodeOutlined, CodeOutlined,
GithubOutlined, GithubOutlined,
JavaOutlined, JavaOutlined,
NodeIndexOutlined, NodeIndexOutlined,
PythonOutlined, PythonOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getApplicationPage, deleteApplication } from './service'; import { getApplicationPage } from './service';
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 { getEnabledCategories } from '../Category/service';
import type { ApplicationCategoryResponse } from '../Category/types';
import ApplicationModal from './components/ApplicationModal'; import ApplicationModal from './components/ApplicationModal';
import DeleteDialog from './components/DeleteDialog'; import DeleteDialog from './components/DeleteDialog';
import CategoryManageDialog from './components/CategoryManageDialog';
import { import {
Table, Table,
TableHeader, TableHeader,
@ -30,7 +28,9 @@ import {
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
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 {
@ -46,7 +46,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { searchFormSchema, type SearchFormValues } from './schema'; import { searchFormSchema, type SearchFormValues } from './schema';
import { DataTablePagination } from '@/components/ui/pagination'; import { DataTablePagination } from '@/components/ui/pagination';
import { Plus, Edit, Trash2, Server, Activity, Database, ExternalLink } from 'lucide-react'; import { Plus, Edit, Trash2, Server, Activity, Database, ExternalLink, Settings } from 'lucide-react';
interface Column { interface Column {
accessorKey?: keyof Application; accessorKey?: keyof Application;
@ -59,13 +59,14 @@ interface Column {
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 10;
const ApplicationList: React.FC = () => { const ApplicationList: React.FC = () => {
const [projectGroups, setProjects] = useState<ProjectGroup[]>([]);
const [selectedProjectGroupId, setSelectedProjectGroupId] = useState<number>();
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [currentApplication, setCurrentApplication] = useState<Application>(); const [currentApplication, setCurrentApplication] = useState<Application>();
const [list, setList] = useState<Application[]>([]); const [list, setList] = useState<Application[]>([]);
const [categories, setCategories] = useState<ApplicationCategoryResponse[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedCategoryId, setSelectedCategoryId] = useState<number>();
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageNum: 1, pageNum: 1,
pageSize: DEFAULT_PAGE_SIZE, pageSize: DEFAULT_PAGE_SIZE,
@ -84,37 +85,26 @@ const ApplicationList: React.FC = () => {
}, },
}); });
// 获取项目列表 // 加载分类列表
const fetchProjects = async () => { const loadCategories = async () => {
try { try {
const data = await getProjectGroupList(); const data = await getEnabledCategories();
setProjects(data); setCategories(data || []);
if (data.length > 0 && !selectedProjectGroupId) {
setSelectedProjectGroupId(data[0].id);
}
} catch (error) { } catch (error) {
toast({ console.error('加载分类失败:', error);
variant: 'destructive',
title: '获取项目组列表失败',
duration: 3000,
});
} }
}; };
useEffect(() => { useEffect(() => {
fetchProjects(); loadCategories();
}, []); }, []);
const loadData = async (params?: ApplicationQuery) => { const loadData = async (params?: ApplicationQuery) => {
if (!selectedProjectGroupId) {
setList([]);
return;
}
setLoading(true); setLoading(true);
try { try {
const queryParams: ApplicationQuery = { const queryParams: ApplicationQuery = {
...params, ...params,
projectGroupId: selectedProjectGroupId, categoryId: selectedCategoryId,
pageNum: pagination.pageNum, pageNum: pagination.pageNum,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
}; };
@ -151,17 +141,9 @@ const ApplicationList: React.FC = () => {
useEffect(() => { useEffect(() => {
loadData(form.getValues()); loadData(form.getValues());
}, [selectedProjectGroupId, pagination.pageNum, pagination.pageSize]); }, [selectedCategoryId, pagination.pageNum, pagination.pageSize]);
const handleAdd = () => { const handleAdd = () => {
if (!selectedProjectGroupId) {
toast({
variant: 'destructive',
title: '请先选择项目组',
duration: 3000,
});
return;
}
setCurrentApplication(undefined); setCurrentApplication(undefined);
setModalVisible(true); setModalVisible(true);
}; };
@ -171,10 +153,6 @@ const ApplicationList: React.FC = () => {
setModalVisible(true); setModalVisible(true);
}; };
const handleProjectChange = (value: string) => {
setSelectedProjectGroupId(Number(value));
};
const handleModalClose = () => { const handleModalClose = () => {
setModalVisible(false); setModalVisible(false);
setCurrentApplication(undefined); setCurrentApplication(undefined);
@ -226,27 +204,27 @@ const ApplicationList: React.FC = () => {
{ {
accessorKey: 'appCode', accessorKey: 'appCode',
header: '应用编码', header: '应用编码',
size: 180, size: 150,
}, },
{ {
accessorKey: 'appName', accessorKey: 'appName',
header: '应用名称', header: '应用名称',
size: 150, size: 130,
}, },
{ {
id: 'projectGroup', id: 'category',
header: '项目组', header: '应用分类',
size: 150, size: 120,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{row.original.projectGroup?.projectGroupName}</span> <span>{row.original.category?.name || '-'}</span>
</div> </div>
), ),
}, },
{ {
accessorKey: 'appDesc', accessorKey: 'appDesc',
header: '应用描述', header: '应用描述',
size: 200, size: 180,
}, },
{ {
id: 'externalSystem', id: 'externalSystem',
@ -340,32 +318,6 @@ const ApplicationList: React.FC = () => {
return ( return (
<PageContainer> <PageContainer>
{/* 页面标题 */}
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight"></h2>
<div className="flex items-center gap-4">
<Select
value={selectedProjectGroupId?.toString()}
onValueChange={handleProjectChange}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="请选择项目组" />
</SelectTrigger>
<SelectContent>
{projectGroups.map((project) => (
<SelectItem key={project.id} value={project.id.toString()}>
{project.projectGroupName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleAdd} disabled={!selectedProjectGroupId}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{/* 统计卡片 */} {/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<Card> <Card>
@ -400,10 +352,32 @@ const ApplicationList: React.FC = () => {
</Card> </Card>
</div> </div>
{/* 搜索过滤 */} {/* 应用管理 */}
<Card> <Card>
<div className="p-6"> <CardHeader>
<div className="flex items-center gap-4"> <div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-2">
Git集成和分类管理
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setCategoryDialogOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleAdd}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6">
{/* 搜索过滤 */}
<div className="flex items-center gap-4 mb-6">
<Input <Input
placeholder="应用编码" placeholder="应用编码"
value={form.watch('appCode')} value={form.watch('appCode')}
@ -416,6 +390,29 @@ const ApplicationList: React.FC = () => {
onChange={(e) => form.setValue('appName', e.target.value)} onChange={(e) => form.setValue('appName', e.target.value)}
className="max-w-[200px]" className="max-w-[200px]"
/> />
<Select
value={selectedCategoryId?.toString()}
onValueChange={(value) => {
if (value === "all") {
setSelectedCategoryId(undefined);
} else {
setSelectedCategoryId(Number(value));
}
setPagination({ ...pagination, pageNum: 1 });
}}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select <Select
value={form.watch('language')} value={form.watch('language')}
onValueChange={(value) => onValueChange={(value) =>
@ -449,15 +446,8 @@ const ApplicationList: React.FC = () => {
</Button> </Button>
<Button onClick={() => loadData(form.getValues())}></Button> <Button onClick={() => loadData(form.getValues())}></Button>
</div> </div>
</div>
</Card>
{/* 数据表格 */} {/* 数据表格 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -482,13 +472,22 @@ const ApplicationList: React.FC = () => {
) : ( ) : (
list.map((item) => ( list.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
{columns.map((column) => ( {columns.map((column) => {
let cellContent;
if (column.cell) {
cellContent = column.cell({ row: { original: item } });
} else if (column.accessorKey) {
const value = item[column.accessorKey];
cellContent = value != null ? String(value) : '';
} else {
cellContent = '';
}
return (
<TableCell key={column.accessorKey || column.id}> <TableCell key={column.accessorKey || column.id}>
{column.cell {cellContent}
? column.cell({ row: { original: item } })
: item[column.accessorKey!]}
</TableCell> </TableCell>
))} );
})}
</TableRow> </TableRow>
)) ))
)} )}
@ -507,13 +506,12 @@ const ApplicationList: React.FC = () => {
</Card> </Card>
{/* 对话框 */} {/* 对话框 */}
{modalVisible && selectedProjectGroupId && ( {modalVisible && (
<ApplicationModal <ApplicationModal
open={modalVisible} open={modalVisible}
onCancel={handleModalClose} onCancel={handleModalClose}
onSuccess={handleSuccess} onSuccess={handleSuccess}
initialValues={currentApplication} initialValues={currentApplication}
projectGroupId={selectedProjectGroupId}
/> />
)} )}
<DeleteDialog <DeleteDialog
@ -522,6 +520,11 @@ const ApplicationList: React.FC = () => {
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
<CategoryManageDialog
open={categoryDialogOpen}
onOpenChange={setCategoryDialogOpen}
onSuccess={loadCategories}
/>
</PageContainer> </PageContainer>
); );
}; };

View File

@ -12,19 +12,13 @@ export const applicationFormSchema = z.object({
appCode: z.string().min(1, '应用编码不能为空'), appCode: z.string().min(1, '应用编码不能为空'),
appName: z.string().min(1, '应用名称不能为空'), appName: z.string().min(1, '应用名称不能为空'),
appDesc: z.string().max(200, "应用描述不能超过200个字符").optional(), appDesc: z.string().max(200, "应用描述不能超过200个字符").optional(),
externalSystemId: z.number({ categoryId: z.number({
required_error: '请选择Git实例', required_error: '请选择应用分类',
invalid_type_error: 'Git实例必须是数字', invalid_type_error: '应用分类必须是数字',
}),
projectGroupId: z.number().min(1, "请选择项目组"),
repoGroupId: z.number({
required_error: '请选择代码仓库组',
invalid_type_error: '代码仓库组必须是数字',
}),
repoProjectId: z.number({
required_error: '请选择项目',
invalid_type_error: '项目必须是数字',
}), }),
externalSystemId: z.number().optional(),
repoGroupId: z.number().optional(),
repoProjectId: z.number().optional(),
language: z.nativeEnum(DevelopmentLanguageTypeEnum, { language: z.nativeEnum(DevelopmentLanguageTypeEnum, {
required_error: "请选择开发语言", required_error: "请选择开发语言",
}), }),

View File

@ -1,7 +1,7 @@
import type {BaseQuery} from '@/types/base'; import type {BaseQuery} from '@/types/base';
import {ProjectGroup} from "@/pages/Deploy/ProjectGroup/List/types";
import {ExternalSystemResponse} from "@/pages/Deploy/External/types"; import {ExternalSystemResponse} from "@/pages/Deploy/External/types";
import {BaseResponse} from "@/types/base"; import {BaseResponse} from "@/types/base";
import type {ApplicationCategoryResponse} from "../Category/types";
export enum DevelopmentLanguageTypeEnum { export enum DevelopmentLanguageTypeEnum {
JAVA = 'JAVA', JAVA = 'JAVA',
@ -13,17 +13,17 @@ export enum DevelopmentLanguageTypeEnum {
export interface Application extends BaseResponse { export interface Application extends BaseResponse {
appCode: string; appCode: string;
appName: string; appName: string;
appDesc: string; appDesc?: string;
language: DevelopmentLanguageTypeEnum; language: DevelopmentLanguageTypeEnum;
enabled: boolean; enabled: boolean;
sort: number; sort: number;
projectGroupId: number; categoryId: number;
projectGroup?: ProjectGroup; category?: ApplicationCategoryResponse;
externalSystemId: number; externalSystemId?: number;
externalSystem?: ExternalSystemResponse; externalSystem?: ExternalSystemResponse;
repoGroupId: number; repoGroupId?: number;
repositoryGroup?: RepositoryGroup; repositoryGroup?: RepositoryGroup;
repoProjectId: number; repoProjectId?: number;
repositoryProject?: RepositoryProject; repositoryProject?: RepositoryProject;
} }
@ -40,7 +40,6 @@ export interface RepositoryGroup extends BaseResponse {
webUrl?: string; webUrl?: string;
visibility?: string; visibility?: string;
sort?: number; sort?: number;
enabled?: boolean;
} }
export interface RepositoryProject extends BaseResponse { export interface RepositoryProject extends BaseResponse {
@ -64,9 +63,9 @@ export type UpdateApplicationRequest = Application;
export type ApplicationFormValues = CreateApplicationRequest; export type ApplicationFormValues = CreateApplicationRequest;
export interface ApplicationQuery extends BaseQuery { export interface ApplicationQuery extends BaseQuery {
projectGroupId?: number;
appCode?: string; appCode?: string;
appName?: string; appName?: string;
categoryId?: number;
enabled?: boolean; enabled?: boolean;
language?: string; language?: string;
} }

View File

@ -1,109 +0,0 @@
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,293 +0,0 @@
import React, {useState, useEffect} from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {Switch} from "@/components/ui/switch";
import {useToast} from "@/components/ui/use-toast";
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import type {ProjectGroup} from '../types';
import {ProjectGroupTypeEnum} from '../types';
import {createProjectGroup, updateProjectGroup} from '../service';
import {formSchema, type FormValues} from '../schema';
interface ProjectGroupModalProps {
open: boolean;
onCancel: () => void;
onSuccess: () => void;
initialValues?: ProjectGroup;
}
const ProjectGroupModal: React.FC<ProjectGroupModalProps> = ({
open,
onCancel,
onSuccess,
initialValues,
}) => {
const [loading, setLoading] = useState(false);
const {toast} = useToast();
const isEdit = !!initialValues?.id;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
projectGroupCode: '',
projectGroupName: '',
projectGroupDesc: '',
type: undefined,
enabled: true,
sort: 0,
tenantCode: 'default',
},
});
useEffect(() => {
if (open) {
if (initialValues) {
form.reset({
projectGroupCode: initialValues.projectGroupCode,
projectGroupName: initialValues.projectGroupName,
projectGroupDesc: initialValues.projectGroupDesc || '',
type: initialValues.type,
enabled: initialValues.enabled,
sort: initialValues.sort || 0,
tenantCode: initialValues.tenantCode || 'default',
});
} else {
form.reset({
projectGroupCode: '',
projectGroupName: '',
projectGroupDesc: '',
type: undefined,
enabled: true,
sort: 0,
tenantCode: 'default',
});
}
}
}, [form, initialValues, open]);
const handleSubmit = async (values: FormValues) => {
try {
setLoading(true);
if (isEdit) {
await updateProjectGroup({
...values,
id: initialValues!.id,
});
toast({
title: "操作成功",
description: "项目组更新成功",
variant: "default",
});
onSuccess?.();
} else {
await createProjectGroup(values);
toast({
title: "操作成功",
description: "项目组创建成功",
variant: "default",
});
onSuccess?.();
}
} catch (error) {
console.error('操作失败:', error);
toast({
title: "操作失败",
description: error instanceof Error ? error.message : `项目组${isEdit ? '更新' : '创建'}失败`,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑' : '新建'}</DialogTitle>
<DialogDescription>
{isEdit ? '编辑现有项目组信息' : '创建一个新的项目组'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="projectGroupCode"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="请输入项目组编码"
disabled={isEdit}
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectGroupName"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="请输入项目组名称"
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isEdit}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择项目组类型" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={ProjectGroupTypeEnum.PRODUCT}></SelectItem>
<SelectItem value={ProjectGroupTypeEnum.PROJECT}></SelectItem>
</SelectContent>
</Select>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectGroupDesc"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="请输入项目组描述"
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onCancel}
>
</Button>
<Button type="submit" disabled={loading}>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default ProjectGroupModal;

View File

@ -1,346 +0,0 @@
import React, { useState, useEffect } from 'react';
import { PageContainer } from '@/components/ui/page-container';
import { getProjectGroupPage } from './service';
import type { ProjectGroup, ProjectGroupQueryParams } from './types';
import { ProjectGroupTypeEnum } from './types';
import { getProjectTypeInfo } from './utils';
import ProjectGroupModal from './components/ProjectGroupModal';
import DeleteDialog from './components/DeleteDialog';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { searchFormSchema, type SearchFormValues } from './schema';
import { DataTablePagination } from '@/components/ui/pagination';
import { Plus, Edit, Trash2, FolderKanban, Activity, Database, Layers, Boxes } from 'lucide-react';
const DEFAULT_PAGE_SIZE = 10;
const ProjectGroupList: React.FC = () => {
const [modalVisible, setModalVisible] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [currentProject, setCurrentProject] = useState<ProjectGroup>();
const [list, setList] = useState<ProjectGroup[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
pageNum: 1,
pageSize: DEFAULT_PAGE_SIZE,
totalElements: 0,
});
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
const { toast } = useToast();
const form = useForm<SearchFormValues>({
resolver: zodResolver(searchFormSchema),
defaultValues: {
projectGroupCode: '',
projectGroupName: '',
type: undefined,
enabled: undefined,
},
});
const loadData = async (params?: SearchFormValues) => {
setLoading(true);
try {
const queryParams: ProjectGroupQueryParams = {
...params,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
};
const data = await getProjectGroupPage(queryParams);
setList(data.content || []);
setPagination((prev) => ({
...prev,
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) {
toast({
variant: 'destructive',
title: '加载失败',
description: '加载数据失败,请稍后重试',
duration: 3000,
});
} finally {
setLoading(false);
}
};
const handlePageChange = (page: number) => {
setPagination((prev) => ({
...prev,
pageNum: page,
}));
};
useEffect(() => {
loadData(form.getValues());
}, [pagination.pageNum, pagination.pageSize]);
const handleEdit = (project: ProjectGroup) => {
setCurrentProject(project);
setModalVisible(true);
};
const handleModalClose = () => {
setModalVisible(false);
setCurrentProject(undefined);
};
const handleSuccess = () => {
setModalVisible(false);
setCurrentProject(undefined);
loadData(form.getValues());
};
return (
<PageContainer>
{/* 页面标题 */}
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight"></h2>
<Button onClick={() => {
setCurrentProject(undefined);
setModalVisible(true);
}}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</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>
<div className="p-6">
<div className="flex items-center gap-4">
<Input
placeholder="项目组编码"
value={form.watch('projectGroupCode')}
onChange={(e) => form.setValue('projectGroupCode', e.target.value)}
className="max-w-[200px]"
/>
<Input
placeholder="项目组名称"
value={form.watch('projectGroupName')}
onChange={(e) => form.setValue('projectGroupName', e.target.value)}
className="max-w-[200px]"
/>
<Select
value={form.watch('type')}
onValueChange={(value) => form.setValue('type', value as ProjectGroupTypeEnum)}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="项目组类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ProjectGroupTypeEnum.PRODUCT}></SelectItem>
<SelectItem value={ProjectGroupTypeEnum.PROJECT}></SelectItem>
</SelectContent>
</Select>
<Select
value={form.watch('enabled')?.toString()}
onValueChange={(value) => form.setValue('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={() => form.reset()}>
</Button>
<Button onClick={() => loadData(form.getValues())}></Button>
</div>
</div>
</Card>
{/* 数据表格 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
list.map((item) => {
const typeInfo = getProjectTypeInfo(item.type);
return (
<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.label}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Layers className="h-3 w-3 text-muted-foreground" />
<span>{item.totalEnvironments}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Boxes className="h-3 w-3 text-muted-foreground" />
<span>{item.totalApplications}</span>
</div>
</TableCell>
<TableCell>
<Badge
variant={item.enabled ? 'default' : 'secondary'}
className="inline-flex"
>
{item.enabled ? '启用' : '禁用'}
</Badge>
</TableCell>
<TableCell>{item.sort}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item)}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentProject(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={pagination.pageNum}
pageSize={pagination.pageSize}
pageCount={Math.ceil(pagination.totalElements / pagination.pageSize)}
onPageChange={handlePageChange}
/>
</div>
</div>
</CardContent>
</Card>
{/* 对话框 */}
<ProjectGroupModal
open={modalVisible}
onCancel={handleModalClose}
onSuccess={handleSuccess}
initialValues={currentProject}
/>
<DeleteDialog
open={deleteDialogOpen}
record={currentProject}
onOpenChange={setDeleteDialogOpen}
onSuccess={handleSuccess}
/>
</PageContainer>
);
};
export default ProjectGroupList;

View File

@ -1,46 +0,0 @@
import * as z from "zod";
import {ProjectGroupTypeEnum} from "./types";
export const searchFormSchema = z.object({
projectGroupCode: z.string().optional(),
projectGroupName: z.string().optional(),
type: z.nativeEnum(ProjectGroupTypeEnum).optional(),
enabled: z.boolean().optional(),
});
export const formSchema = z.object({
projectGroupCode: z.string({
required_error: "项目组编码不能为空",
invalid_type_error: "项目组编码格式不正确",
})
.min(1, "项目组编码不能为空")
.max(50, "项目组编码不能超过50个字符")
.regex(/^[a-z0-9-]+$/, "项目组编码只能包含小写字母、数字和连字符"),
projectGroupName: z.string({
required_error: "项目组名称不能为空",
invalid_type_error: "项目组名称格式不正确",
})
.min(1, "项目组名称不能为空")
.max(50, "项目组名称不能超过50个字符"),
type: z.nativeEnum(ProjectGroupTypeEnum, {
required_error: "请选择项目组类型",
invalid_type_error: "项目组类型无效",
}),
projectGroupDesc: z.string()
.max(200, "项目组描述不能超过200个字符")
.nullable()
.optional()
.transform(val => val || ''),
enabled: z.boolean().default(true),
sort: z.number({
required_error: "排序不能为空",
invalid_type_error: "排序必须是数字",
})
.int("排序必须是整数")
.min(0, "排序不能小于0")
.default(0),
tenantCode: z.string().default('default'),
});
export type SearchFormValues = z.infer<typeof searchFormSchema>;
export type FormValues = z.infer<typeof formSchema>;

View File

@ -1,41 +0,0 @@
import request from '@/utils/request';
import type { CreateProjectGroupRequest, UpdateProjectGroupRequest, ProjectGroup, ProjectGroupQueryParams } from './types';
import type { Page } from '@/types/base';
const BASE_URL = '/api/v1/project-group';
// 创建项目
export const createProjectGroup = (data: CreateProjectGroupRequest) =>
request.post<void>(BASE_URL, data);
// 更新项目
export const updateProjectGroup = (data: UpdateProjectGroupRequest) =>
request.put<void>(`${BASE_URL}/${data.id}`, data);
// 删除项目
export const deleteProjectGroup = (id: number) =>
request.delete<void>(`${BASE_URL}/${id}`);
// 获取项目详情
export const getProjectGroup = (id: number) =>
request.get<ProjectGroup>(`${BASE_URL}/${id}`);
// 分页查询项目列表
export const getProjectGroupPage = (params?: ProjectGroupQueryParams) =>
request.get<Page<ProjectGroup>>(`${BASE_URL}/page`, { params });
// 获取所有项目列表
export const getProjectGroupList = () =>
request.get<ProjectGroup[]>(BASE_URL);
// 条件查询项目列表
export const getProjectGroupListByCondition = (params?: ProjectGroupQueryParams) =>
request.get<ProjectGroup[]>(`${BASE_URL}/list`, { params });
// 批量处理项目
export const batchProcessProjectGroup = (data: any) =>
request.post<void>(`${BASE_URL}/batch`, data);
// 导出项目数据
export const exportProjectGroup = () =>
request.get(`${BASE_URL}/export`, { responseType: 'blob' });

View File

@ -1,42 +0,0 @@
import {BaseResponse, BaseRequest, BaseQuery} from '@/types/base';
export enum ProjectGroupTypeEnum {
PRODUCT = 'PRODUCT',
PROJECT = 'PROJECT'
}
// 项目基础信息
export interface ProjectGroup extends BaseResponse {
tenantCode: string;
type: ProjectGroupTypeEnum;
projectGroupCode: string;
projectGroupName: string;
projectGroupDesc?: string;
enabled: boolean;
totalEnvironments: number;
totalApplications: number;
sort: number;
}
// 创建项目请求参数
export interface CreateProjectGroupRequest extends BaseRequest {
tenantCode: string;
type: ProjectGroupTypeEnum;
projectGroupCode: string;
projectGroupName: string;
projectGroupDesc?: string;
enabled: boolean;
sort: number;
}
// 更新项目请求参数
export interface UpdateProjectGroupRequest extends CreateProjectGroupRequest {
id: number;
}
// 分页查询参数
export interface ProjectGroupQueryParams extends BaseQuery {
projectGroupName?: string;
projectGroupCode?: string;
enabled?: boolean;
}

View File

@ -1,37 +0,0 @@
import React from 'react';
import {ProjectOutlined, RocketOutlined} from '@ant-design/icons';
import {ProjectGroupTypeEnum} from './types';
interface ProjectTypeInfo {
type: ProjectGroupTypeEnum;
label: string;
color: string;
icon: React.ReactNode;
}
// 获取项目组类型信息
export const getProjectTypeInfo = (type: ProjectGroupTypeEnum): ProjectTypeInfo => {
switch (type) {
case ProjectGroupTypeEnum.PRODUCT:
return {
type: ProjectGroupTypeEnum.PRODUCT,
label: '产品型',
color: '#1890ff',
icon: <RocketOutlined/>,
};
case ProjectGroupTypeEnum.PROJECT:
return {
type: ProjectGroupTypeEnum.PROJECT,
label: '项目型',
color: '#52c41a',
icon: <ProjectOutlined/>,
};
default:
return {
type: type,
label: type || '未知',
color: '#666666',
icon: <ProjectOutlined/>,
};
}
};

View File

@ -37,7 +37,6 @@ const WorkflowMonitor = lazy(() => import('../pages/Workflow/Monitor'));
const LogStreamPage = lazy(() => import('../pages/LogStream')); const LogStreamPage = lazy(() => import('../pages/LogStream'));
const NodeDesign = lazy(() => import('../pages/Workflow/NodeDesign')); const NodeDesign = lazy(() => import('../pages/Workflow/NodeDesign'));
const NodeDesignForm = lazy(() => import('../pages/Workflow/NodeDesign/Design')); const NodeDesignForm = lazy(() => import('../pages/Workflow/NodeDesign/Design'));
const ProjectGroupList = lazy(() => import('../pages/Deploy/ProjectGroup/List'));
const ApplicationList = lazy(() => import('../pages/Deploy/Application/List')); const ApplicationList = lazy(() => import('../pages/Deploy/Application/List'));
const EnvironmentList = lazy(() => import('../pages/Deploy/Environment/List')); const EnvironmentList = lazy(() => import('../pages/Deploy/Environment/List'));
const DeploymentConfigList = lazy(() => import('../pages/Deploy/Deployment/List')); const DeploymentConfigList = lazy(() => import('../pages/Deploy/Deployment/List'));
@ -81,10 +80,6 @@ const router = createBrowserRouter([
{ {
path: 'deploy', path: 'deploy',
children: [ children: [
{
path: 'project-group',
element: <Suspense fallback={<LoadingComponent/>}><ProjectGroupList/></Suspense>
},
{ {
path: 'applications', path: 'applications',
element: <Suspense fallback={<LoadingComponent/>}><ApplicationList/></Suspense> element: <Suspense fallback={<LoadingComponent/>}><ApplicationList/></Suspense>