增加团队管理页面
This commit is contained in:
parent
b4174c28a5
commit
5186a5c43f
@ -53,6 +53,16 @@ interface InfiniteScrollListProps<T> {
|
|||||||
*/
|
*/
|
||||||
emptyIcon?: React.ReactNode;
|
emptyIcon?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义空状态组件
|
||||||
|
*/
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义初始加载占位符
|
||||||
|
*/
|
||||||
|
loadingPlaceholder?: React.ReactNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载完成提示文字
|
* 加载完成提示文字
|
||||||
*/
|
*/
|
||||||
@ -196,7 +206,7 @@ export function InfiniteScrollList<T>({
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
scrollableTarget={scrollableTarget}
|
scrollableTarget={scrollableTarget}
|
||||||
className={listClassName}
|
className={className || listClassName}
|
||||||
>
|
>
|
||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
|
|||||||
@ -593,7 +593,7 @@ const GitManager: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
@ -605,7 +605,7 @@ const GitManager: React.FC = () => {
|
|||||||
|
|
||||||
className="pl-8 h-8"
|
className="pl-8 h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
@ -636,8 +636,8 @@ const GitManager: React.FC = () => {
|
|||||||
项目{selectedGroup && ` - ${selectedGroup.name}`}
|
项目{selectedGroup && ` - ${selectedGroup.name}`}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSyncProjects}
|
onClick={handleSyncProjects}
|
||||||
@ -646,8 +646,8 @@ const GitManager: React.FC = () => {
|
|||||||
<Download
|
<Download
|
||||||
className={`h-4 w-4 ${syncing.projects ? 'animate-bounce' : ''}`}
|
className={`h-4 w-4 ${syncing.projects ? 'animate-bounce' : ''}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -0,0 +1,518 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Plus, Edit, Trash2, Loader2, Search, CheckCircle2, XCircle, Server } from 'lucide-react';
|
||||||
|
import DynamicIcon from '@/components/DynamicIcon';
|
||||||
|
import LucideIconSelect from '@/components/LucideIconSelect';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||||
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
|
import type { ServerCategoryResponse } from '../types';
|
||||||
|
import { serverCategoryFormSchema, type ServerCategoryFormValues } from '../schema';
|
||||||
|
import {
|
||||||
|
getServerCategoryPage,
|
||||||
|
createServerCategory,
|
||||||
|
updateServerCategory,
|
||||||
|
deleteServerCategory,
|
||||||
|
} from '../service';
|
||||||
|
import type { Page } from '@/types/base';
|
||||||
|
|
||||||
|
interface CategoryManageDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategoryManageDialog: React.FC<CategoryManageDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Page<ServerCategoryResponse> | null>(null);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [editRecord, setEditRecord] = useState<ServerCategoryResponse | null>(null);
|
||||||
|
const [iconSelectOpen, setIconSelectOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [categoryToDelete, setCategoryToDelete] = useState<ServerCategoryResponse | null>(null);
|
||||||
|
|
||||||
|
// 分页状态
|
||||||
|
const [pageNum, setPageNum] = useState(DEFAULT_CURRENT - 1);
|
||||||
|
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
|
const form = useForm<ServerCategoryFormValues>({
|
||||||
|
resolver: zodResolver(serverCategoryFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
icon: '',
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
const loadCategories = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getServerCategoryPage({
|
||||||
|
name: searchText || undefined,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
setData(result || null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载失败',
|
||||||
|
description: '获取服务器分类列表失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !editMode) {
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, searchText, pageNum, pageSize, editMode]);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPageNum(0); // 搜索时重置到第一页
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新建
|
||||||
|
const handleCreate = () => {
|
||||||
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
icon: '',
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
setEditRecord(null);
|
||||||
|
setEditMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (category: ServerCategoryResponse) => {
|
||||||
|
form.reset({
|
||||||
|
name: category.name,
|
||||||
|
code: category.code,
|
||||||
|
icon: category.icon || '',
|
||||||
|
description: category.description || '',
|
||||||
|
sort: category.sort,
|
||||||
|
enabled: category.enabled,
|
||||||
|
});
|
||||||
|
setEditRecord(category);
|
||||||
|
setEditMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditMode(false);
|
||||||
|
setEditRecord(null);
|
||||||
|
form.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除分类
|
||||||
|
const handleDelete = (category: ServerCategoryResponse) => {
|
||||||
|
setCategoryToDelete(category);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!categoryToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteServerCategory(categoryToDelete.id);
|
||||||
|
toast({
|
||||||
|
title: '删除成功',
|
||||||
|
description: `分类"${categoryToDelete.name}"已删除`,
|
||||||
|
});
|
||||||
|
loadCategories();
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '删除失败',
|
||||||
|
description: '删除服务器分类失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const onSubmit = async (values: ServerCategoryFormValues) => {
|
||||||
|
try {
|
||||||
|
if (editRecord) {
|
||||||
|
await updateServerCategory(editRecord.id, values);
|
||||||
|
toast({
|
||||||
|
title: '更新成功',
|
||||||
|
description: `分类"${values.name}"已更新`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createServerCategory(values);
|
||||||
|
toast({
|
||||||
|
title: '创建成功',
|
||||||
|
description: `分类"${values.name}"已创建`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setEditMode(false);
|
||||||
|
setEditRecord(null);
|
||||||
|
loadCategories();
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: editRecord ? '更新失败' : '创建失败',
|
||||||
|
description: error.response?.data?.message || '操作失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[85vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editMode ? (editRecord ? '编辑分类' : '新增分类') : '服务器分类管理'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
{editMode ? (
|
||||||
|
/* 编辑表单 */
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
分类名称 <span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:生产服务器" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
分类编码 <span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:PRODUCTION"
|
||||||
|
disabled={!!editRecord}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>描述</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="分类描述" rows={3} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">启用</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
/* 分类列表 */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 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-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSearch} variant="secondary">
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table minWidth="890px">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead width="160px">分类名称</TableHead>
|
||||||
|
<TableHead width="140px">分类编码</TableHead>
|
||||||
|
<TableHead width="60px">图标</TableHead>
|
||||||
|
<TableHead width="120px">服务器数量</TableHead>
|
||||||
|
<TableHead width="80px">排序</TableHead>
|
||||||
|
<TableHead width="80px">状态</TableHead>
|
||||||
|
<TableHead width="150px">描述</TableHead>
|
||||||
|
<TableHead width="100px" sticky>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} 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((category) => (
|
||||||
|
<TableRow key={category.id}>
|
||||||
|
<TableCell width="160px" className="font-medium">
|
||||||
|
{category.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell width="140px">
|
||||||
|
<code className="text-xs bg-muted px-2 py-0.5 rounded whitespace-nowrap">
|
||||||
|
{category.code}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell width="60px">
|
||||||
|
{category.icon ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<DynamicIcon name={category.icon} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell width="120px" className="text-center">
|
||||||
|
<span className="text-sm font-medium">{category.serverCount || 0}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell width="80px" className="text-center">{category.sort}</TableCell>
|
||||||
|
<TableCell width="80px">
|
||||||
|
{category.enabled ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell width="150px" className="max-w-[150px] truncate" title={category.description}>
|
||||||
|
{category.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell width="100px" sticky>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handleEdit(category)}
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handleDelete(category)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Server 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>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleCancelEdit}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={form.handleSubmit(onSubmit)}>
|
||||||
|
{editRecord ? '更新' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除分类"{categoryToDelete?.name}"吗?此操作无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete}>确定</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
242
frontend/src/pages/Deploy/Server/List/components/ServerCard.tsx
Normal file
242
frontend/src/pages/Deploy/Server/List/components/ServerCard.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
MemoryStick,
|
||||||
|
Network,
|
||||||
|
Clock,
|
||||||
|
TestTube,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import type { ServerResponse } from '../types';
|
||||||
|
import { ServerStatusLabels, OsTypeLabels } from '../types';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
interface ServerCardProps {
|
||||||
|
server: ServerResponse;
|
||||||
|
onTest: (server: ServerResponse) => void;
|
||||||
|
onEdit: (server: ServerResponse) => void;
|
||||||
|
onDelete: (server: ServerResponse) => void;
|
||||||
|
isTesting?: boolean;
|
||||||
|
getOsIcon: (osType?: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerCard: React.FC<ServerCardProps> = ({
|
||||||
|
server,
|
||||||
|
onTest,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isTesting,
|
||||||
|
getOsIcon,
|
||||||
|
}) => {
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-';
|
||||||
|
return dayjs(time).fromNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group relative overflow-hidden bg-gradient-to-br from-card to-card/50 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 border-2 hover:border-primary/50 hover:scale-[1.02] flex flex-col">
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
|
<CardContent className="p-6 relative flex-1 flex flex-col">
|
||||||
|
{/* 顶部状态栏 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-3 w-3 rounded-full ${
|
||||||
|
server.status === 'ONLINE' ? 'bg-green-500 animate-pulse shadow-lg shadow-green-500/50' :
|
||||||
|
server.status === 'OFFLINE' ? 'bg-red-500 shadow-lg shadow-red-500/50' :
|
||||||
|
'bg-gray-400'
|
||||||
|
}`} />
|
||||||
|
<Badge className={
|
||||||
|
server.status === 'ONLINE' ? 'bg-green-500/10 text-green-700 border-green-500/30 dark:text-green-400' :
|
||||||
|
server.status === 'OFFLINE' ? 'bg-red-500/10 text-red-700 border-red-500/30 dark:text-red-400' :
|
||||||
|
'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400'
|
||||||
|
}>
|
||||||
|
{ServerStatusLabels[server.status]?.label || server.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{server.categoryName && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{server.categoryName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器信息 */}
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="p-3 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 shadow-inner">
|
||||||
|
{getOsIcon(server.osType)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-bold truncate mb-2 group-hover:text-primary transition-colors">
|
||||||
|
{server.serverName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Network className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-mono font-medium">{server.hostIp}</span>
|
||||||
|
</div>
|
||||||
|
{server.hostname && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Server className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{server.hostname}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 系统信息 */}
|
||||||
|
{server.osType && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Badge variant="secondary" className="font-medium">
|
||||||
|
{OsTypeLabels[server.osType]?.label || server.osType}
|
||||||
|
{server.osVersion && ` ${server.osVersion}`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 硬件配置 */}
|
||||||
|
{(server.cpuCores || server.memorySize || server.diskSize) && (
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5 p-4 bg-gradient-to-br from-muted/50 to-muted/30 rounded-lg border border-border/50">
|
||||||
|
{server.cpuCores && (
|
||||||
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
|
<Cpu className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-xs font-medium">{server.cpuCores} 核</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.memorySize && (
|
||||||
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
|
<MemoryStick className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-xs font-medium">{server.memorySize} GB</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.diskSize && (
|
||||||
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
|
<HardDrive className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||||
|
<span className="text-xs font-medium">{server.diskSize} GB</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
{server.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-4 px-1">
|
||||||
|
{server.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{server.tags && (() => {
|
||||||
|
try {
|
||||||
|
const tags = JSON.parse(server.tags);
|
||||||
|
return Array.isArray(tags) && tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* SSH 用户信息 */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3 px-1">
|
||||||
|
<span>SSH用户: <span className="font-medium text-foreground">{server.sshUser || 'root'}</span></span>
|
||||||
|
<span>端口: <span className="font-medium text-foreground">{server.sshPort || 22}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 认证方式 */}
|
||||||
|
{server.authType && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3 px-1">
|
||||||
|
<span>认证: </span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{server.authType === 'PASSWORD' ? '密码认证' : '密钥认证'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 最后连接时间 */}
|
||||||
|
{server.lastConnectTime && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3 px-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
<span>最后连接: {formatTime(server.lastConnectTime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 弹性空间,将按钮推到底部 */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* 操作按钮 - 固定在底部并居中 */}
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-4 mt-4 border-t border-border/50">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onTest(server)}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="group-hover:border-primary/50 transition-all"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="h-4 w-4 mr-1.5" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs">{isTesting ? '测试中' : '测试'}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>测试SSH连接</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(server)}
|
||||||
|
className="group-hover:border-primary/50 transition-all"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-1.5" />
|
||||||
|
<span className="text-xs">编辑</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>编辑服务器信息</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(server)}
|
||||||
|
className="text-destructive hover:text-destructive hover:border-destructive/50 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
|
<span className="text-xs">删除</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>删除服务器</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -0,0 +1,638 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import type { ServerResponse, ServerCategoryResponse } from '../types';
|
||||||
|
import { OsType, OsTypeLabels, AuthType, AuthTypeLabels } from '../types';
|
||||||
|
import { createServer, updateServer, getServerCategories } from '../service';
|
||||||
|
import { serverFormSchema, type ServerFormValues } from '../schema';
|
||||||
|
import { Eye, EyeOff, X, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ServerEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
server?: ServerResponse | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
server,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<ServerCategoryResponse[]>([]);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const isEdit = !!server?.id;
|
||||||
|
|
||||||
|
const form = useForm<ServerFormValues>({
|
||||||
|
resolver: zodResolver(serverFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
serverName: '',
|
||||||
|
hostIp: '',
|
||||||
|
sshPort: 22,
|
||||||
|
sshUser: 'root',
|
||||||
|
authType: AuthType.PASSWORD,
|
||||||
|
sshPassword: '',
|
||||||
|
sshPrivateKey: '',
|
||||||
|
sshPassphrase: '',
|
||||||
|
categoryId: undefined,
|
||||||
|
osType: undefined,
|
||||||
|
osVersion: '',
|
||||||
|
hostname: '',
|
||||||
|
description: '',
|
||||||
|
cpuCores: undefined,
|
||||||
|
memorySize: undefined,
|
||||||
|
diskSize: undefined,
|
||||||
|
tags: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getServerCategories({ enabled: true });
|
||||||
|
setCategories(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (open) {
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 初始化表单数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (server) {
|
||||||
|
form.reset({
|
||||||
|
serverName: server.serverName,
|
||||||
|
hostIp: server.hostIp,
|
||||||
|
sshPort: server.sshPort,
|
||||||
|
sshUser: server.sshUser || 'root',
|
||||||
|
authType: server.authType || AuthType.PASSWORD,
|
||||||
|
sshPassword: server.sshPassword || '',
|
||||||
|
sshPrivateKey: server.sshPrivateKey || '',
|
||||||
|
sshPassphrase: server.sshPassphrase || '',
|
||||||
|
categoryId: server.categoryId,
|
||||||
|
osType: server.osType,
|
||||||
|
osVersion: server.osVersion || '',
|
||||||
|
hostname: server.hostname || '',
|
||||||
|
description: server.description || '',
|
||||||
|
cpuCores: server.cpuCores,
|
||||||
|
memorySize: server.memorySize,
|
||||||
|
diskSize: server.diskSize,
|
||||||
|
tags: server.tags || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
serverName: '',
|
||||||
|
hostIp: '',
|
||||||
|
sshPort: 22,
|
||||||
|
sshUser: 'root',
|
||||||
|
authType: AuthType.PASSWORD,
|
||||||
|
sshPassword: '',
|
||||||
|
sshPrivateKey: '',
|
||||||
|
sshPassphrase: '',
|
||||||
|
categoryId: undefined,
|
||||||
|
osType: undefined,
|
||||||
|
osVersion: '',
|
||||||
|
hostname: '',
|
||||||
|
description: '',
|
||||||
|
cpuCores: undefined,
|
||||||
|
memorySize: undefined,
|
||||||
|
diskSize: undefined,
|
||||||
|
tags: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [server, open, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: ServerFormValues) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
if (isEdit && server) {
|
||||||
|
await updateServer(server.id, values);
|
||||||
|
toast({
|
||||||
|
title: '更新成功',
|
||||||
|
description: `服务器"${values.serverName}"已更新`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createServer(values);
|
||||||
|
toast({
|
||||||
|
title: '创建成功',
|
||||||
|
description: `服务器"${values.serverName}"已创建`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSuccess?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: isEdit ? '更新失败' : '创建失败',
|
||||||
|
description: error.response?.data?.message || '操作失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? '编辑服务器' : '新增服务器'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
服务器名称 <span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:生产服务器-01" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hostIp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
IP地址 <span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hostname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>主机名</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:prod-server-01" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categoryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>服务器分类</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value?.toString() || 'none'}
|
||||||
|
onValueChange={(value) => field.onChange(value === 'none' ? undefined : Number(value))}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">不选择</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id.toString()}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* SSH 配置 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH端口</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH用户名</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:root" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 认证方式 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="authType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>认证方式</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || 'none'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const authType = value === 'none' ? undefined : (value as AuthType);
|
||||||
|
field.onChange(authType);
|
||||||
|
// 切换认证方式时清空相关字段
|
||||||
|
if (authType === AuthType.PASSWORD) {
|
||||||
|
form.setValue('sshPrivateKey', '');
|
||||||
|
form.setValue('sshPassphrase', '');
|
||||||
|
} else if (authType === AuthType.KEY) {
|
||||||
|
form.setValue('sshPassword', '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择认证方式" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">不选择</SelectItem>
|
||||||
|
{Object.entries(AuthTypeLabels).map(([key, { label }]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 根据认证方式显示不同的输入框 */}
|
||||||
|
{form.watch('authType') === AuthType.PASSWORD && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="输入SSH密码"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch('authType') === AuthType.KEY && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshPrivateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>SSH私钥</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请粘贴SSH私钥内容"
|
||||||
|
rows={6}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshPassphrase"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>私钥密码(可选)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassphrase ? 'text' : 'password'}
|
||||||
|
placeholder="如果私钥加密,请输入密码"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||||
|
>
|
||||||
|
{showPassphrase ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作系统信息 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="osType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>操作系统类型</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || 'none'}
|
||||||
|
onValueChange={(value) => field.onChange(value === 'none' ? undefined : (value as OsType))}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择系统类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">不选择</SelectItem>
|
||||||
|
{Object.entries(OsTypeLabels).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="osVersion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>操作系统版本</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:CentOS 7.9" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 硬件配置 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cpuCores"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>CPU核心数</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="例如:8"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="memorySize"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>内存大小(GB)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="例如:16"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="diskSize"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>磁盘大小(GB)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="例如:500"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>标签(可选)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="输入标签后按回车添加"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (tagInput.trim()) {
|
||||||
|
const currentTags = field.value ? JSON.parse(field.value) : [];
|
||||||
|
if (!currentTags.includes(tagInput.trim())) {
|
||||||
|
const newTags = [...currentTags, tagInput.trim()];
|
||||||
|
field.onChange(JSON.stringify(newTags));
|
||||||
|
}
|
||||||
|
setTagInput('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (tagInput.trim()) {
|
||||||
|
const currentTags = field.value ? JSON.parse(field.value) : [];
|
||||||
|
if (!currentTags.includes(tagInput.trim())) {
|
||||||
|
const newTags = [...currentTags, tagInput.trim()];
|
||||||
|
field.onChange(JSON.stringify(newTags));
|
||||||
|
}
|
||||||
|
setTagInput('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{field.value && (() => {
|
||||||
|
try {
|
||||||
|
const tags = JSON.parse(field.value);
|
||||||
|
return Array.isArray(tags) && tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="gap-1">
|
||||||
|
{tag}
|
||||||
|
<X
|
||||||
|
className="h-3 w-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newTags = tags.filter((_, i) => i !== index);
|
||||||
|
field.onChange(newTags.length > 0 ? JSON.stringify(newTags) : undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>描述</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="服务器描述信息"
|
||||||
|
rows={3}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={form.handleSubmit(onSubmit)} disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
543
frontend/src/pages/Deploy/Server/List/index.tsx
Normal file
543
frontend/src/pages/Deploy/Server/List/index.tsx
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
HelpCircle,
|
||||||
|
Monitor,
|
||||||
|
Apple,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
TooltipProvider,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
|
import type { ServerResponse, ServerCategoryResponse, ServerStatus, OsType } from './types';
|
||||||
|
import { ServerStatusLabels, OsTypeLabels } from './types';
|
||||||
|
import { getServers, getServerCategories, deleteServer, testServerConnection } from './service';
|
||||||
|
import { CategoryManageDialog } from './components/CategoryManageDialog';
|
||||||
|
import { ServerEditDialog } from './components/ServerEditDialog';
|
||||||
|
import { ServerCard } from './components/ServerCard';
|
||||||
|
|
||||||
|
const ServerList: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const [categories, setCategories] = useState<ServerCategoryResponse[]>([]);
|
||||||
|
const [servers, setServers] = useState<ServerResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [totalElements, setTotalElements] = useState(0);
|
||||||
|
// 筛选条件 - 实际应用的
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | undefined>();
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<ServerStatus | undefined>();
|
||||||
|
const [selectedOsType, setSelectedOsType] = useState<OsType | undefined>();
|
||||||
|
|
||||||
|
// 筛选条件 - 临时输入的(未提交)
|
||||||
|
const [tempCategoryId, setTempCategoryId] = useState<number | undefined>();
|
||||||
|
const [tempStatus, setTempStatus] = useState<ServerStatus | undefined>();
|
||||||
|
const [tempOsType, setTempOsType] = useState<OsType | undefined>();
|
||||||
|
|
||||||
|
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<ServerResponse | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [serverToDelete, setServerToDelete] = useState<ServerResponse | null>(null);
|
||||||
|
const [testingServerId, setTestingServerId] = useState<number | null>(null);
|
||||||
|
const [statsData, setStatsData] = useState<{
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
offline: number;
|
||||||
|
unknown: number;
|
||||||
|
}>({ total: 0, online: 0, offline: 0, unknown: 0 });
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getServerCategories();
|
||||||
|
setCategories(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载统计数据 - 从后端获取各状态数量(如果后端没有统计接口,暂时显示总数)
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
// 并行请求各个状态的数量
|
||||||
|
const [totalResult, onlineResult, offlineResult, unknownResult] = await Promise.all([
|
||||||
|
getServers({ categoryId: selectedCategoryId, osType: selectedOsType, pageNum: 0, size: 1 }),
|
||||||
|
getServers({ categoryId: selectedCategoryId, status: 'ONLINE', osType: selectedOsType, pageNum: 0, size: 1 }),
|
||||||
|
getServers({ categoryId: selectedCategoryId, status: 'OFFLINE', osType: selectedOsType, pageNum: 0, size: 1 }),
|
||||||
|
getServers({ categoryId: selectedCategoryId, status: 'UNKNOWN', osType: selectedOsType, pageNum: 0, size: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStatsData({
|
||||||
|
total: totalResult?.totalElements || 0,
|
||||||
|
online: onlineResult?.totalElements || 0,
|
||||||
|
offline: offlineResult?.totalElements || 0,
|
||||||
|
unknown: unknownResult?.totalElements || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载统计数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
loadStats();
|
||||||
|
}, [selectedCategoryId, selectedOsType]); // 移除 selectedStatus,因为统计不需要受状态筛选影响
|
||||||
|
|
||||||
|
// 获取操作系统图标
|
||||||
|
const getOsIcon = (osType?: string) => {
|
||||||
|
const iconClass = "h-6 w-6";
|
||||||
|
switch (osType) {
|
||||||
|
case 'LINUX':
|
||||||
|
return <Server className={iconClass} />;
|
||||||
|
case 'WINDOWS':
|
||||||
|
return <Monitor className={iconClass} />;
|
||||||
|
case 'MACOS':
|
||||||
|
return <Apple className={iconClass} />;
|
||||||
|
case 'UNIX':
|
||||||
|
return <Server className={iconClass} />;
|
||||||
|
default:
|
||||||
|
return <Server className={iconClass} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载服务器列表
|
||||||
|
const loadServers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getServers({
|
||||||
|
categoryId: selectedCategoryId,
|
||||||
|
status: selectedStatus,
|
||||||
|
osType: selectedOsType,
|
||||||
|
pageNum: pageIndex,
|
||||||
|
size: pageSize,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
setServers(result.content || []);
|
||||||
|
setTotalElements(result.totalElements || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载服务器列表失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载失败',
|
||||||
|
description: '无法加载服务器列表',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
const handleTestConnection = async (server: ServerResponse) => {
|
||||||
|
setTestingServerId(server.id);
|
||||||
|
try {
|
||||||
|
const result = await testServerConnection(server.id);
|
||||||
|
// 后端返回 { success: true, data: true/false }
|
||||||
|
// data 为 true 表示连接成功, false 表示连接失败
|
||||||
|
if (result === true) {
|
||||||
|
toast({
|
||||||
|
title: '连接成功',
|
||||||
|
description: '服务器连接正常',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '连接失败',
|
||||||
|
description: '无法连接到服务器',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '测试失败',
|
||||||
|
description: error.response?.data?.message || '无法连接到服务器',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingServerId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑服务器
|
||||||
|
const handleEdit = (server: ServerResponse) => {
|
||||||
|
setEditingServer(server);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除服务器
|
||||||
|
const handleDelete = (server: ServerResponse) => {
|
||||||
|
setServerToDelete(server);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!serverToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteServer(serverToDelete.id);
|
||||||
|
toast({
|
||||||
|
title: '删除成功',
|
||||||
|
description: `服务器"${serverToDelete.serverName}"已删除`,
|
||||||
|
});
|
||||||
|
loadServers();
|
||||||
|
loadStats();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '删除失败',
|
||||||
|
description: error.response?.data?.message || '删除失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setServerToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 成功回调
|
||||||
|
const handleSuccess = () => {
|
||||||
|
loadCategories();
|
||||||
|
loadServers();
|
||||||
|
loadStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面变化
|
||||||
|
const handlePageChange = (newPageIndex: number) => {
|
||||||
|
setPageIndex(newPageIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索按钮 - 应用筛选条件
|
||||||
|
const handleSearch = () => {
|
||||||
|
setSelectedCategoryId(tempCategoryId);
|
||||||
|
setSelectedStatus(tempStatus);
|
||||||
|
setSelectedOsType(tempOsType);
|
||||||
|
setPageIndex(0); // 重置到第一页
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置按钮 - 清空所有筛选条件
|
||||||
|
const handleReset = () => {
|
||||||
|
setTempCategoryId(undefined);
|
||||||
|
setTempStatus(undefined);
|
||||||
|
setTempOsType(undefined);
|
||||||
|
setSelectedCategoryId(undefined);
|
||||||
|
setSelectedStatus(undefined);
|
||||||
|
setSelectedOsType(undefined);
|
||||||
|
setPageIndex(0); // 重置到第一页
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听筛选条件和分页变化
|
||||||
|
useEffect(() => {
|
||||||
|
loadServers();
|
||||||
|
}, [pageIndex, pageSize, selectedCategoryId, selectedStatus, selectedOsType]);
|
||||||
|
|
||||||
|
// 使用statsData作为统计数据
|
||||||
|
const stats = statsData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
|
<div className="flex-shrink-0 space-y-6 p-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-900">
|
||||||
|
<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-blue-600 dark:text-blue-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">所有服务器</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200 dark:border-green-900">
|
||||||
|
<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-green-600 dark:text-green-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.online}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">运行中</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-red-50 to-rose-50 dark:from-red-950/20 dark:to-rose-950/20 border-red-200 dark:border-red-900">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">离线</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.offline}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">不可用</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-950/20 dark:to-slate-950/20 border-gray-200 dark:border-gray-800">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">未知</CardTitle>
|
||||||
|
<HelpCircle className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-600 dark:text-gray-400">{stats.unknown}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">状态未知</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器管理卡片 - 可滚动区域 */}
|
||||||
|
<div className="flex-1 overflow-hidden px-6 pb-6">
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader className="flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-2xl">服务器管理</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
管理和监控服务器资源,支持SSH连接和分类管理
|
||||||
|
</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={() => {
|
||||||
|
setEditingServer(null);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新增服务器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<Separator className="flex-shrink-0" />
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div className="flex items-center gap-3 mb-6 p-4 bg-muted/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">筛选:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={tempCategoryId?.toString() || 'all'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setTempCategoryId(value === 'all' ? undefined : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="全部分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部分类</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id.toString()}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={tempStatus || 'all'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setTempStatus(value === 'all' ? undefined : (value as ServerStatus));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
{Object.entries(ServerStatusLabels).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={tempOsType || 'all'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setTempOsType(value === 'all' ? undefined : (value as OsType));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="全部系统" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部系统</SelectItem>
|
||||||
|
{Object.entries(OsTypeLabels).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1.5" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4 mr-1.5" />
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器卡片网格 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 加载状态 - 骨架屏 */}
|
||||||
|
{loading && servers.length === 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<Card key={index} className="overflow-hidden">
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Skeleton className="h-14 w-14 rounded-lg" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && servers.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||||
|
<Server className="h-16 w-16 mb-4 opacity-20" />
|
||||||
|
<p className="text-lg font-medium">暂无服务器</p>
|
||||||
|
<p className="text-sm mt-2">点击上方"新增服务器"按钮添加服务器</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 服务器列表 */}
|
||||||
|
{!loading && servers.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<ServerCard
|
||||||
|
key={server.id}
|
||||||
|
server={server}
|
||||||
|
onTest={handleTestConnection}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isTesting={testingServerId === server.id}
|
||||||
|
getOsIcon={getOsIcon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{!loading && servers.length > 0 && (
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
共 {totalElements} 条记录,第 {pageIndex + 1} / {Math.ceil(totalElements / pageSize)} 页
|
||||||
|
</div>
|
||||||
|
<DataTablePagination
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
pageCount={Math.ceil(totalElements / pageSize)}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类管理对话框 */}
|
||||||
|
<CategoryManageDialog
|
||||||
|
open={categoryDialogOpen}
|
||||||
|
onOpenChange={setCategoryDialogOpen}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 服务器编辑对话框 */}
|
||||||
|
<ServerEditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
server={editingServer}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除服务器"{serverToDelete?.serverName}"吗?此操作无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete}>确定</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerList;
|
||||||
|
|
||||||
69
frontend/src/pages/Deploy/Server/List/schema.ts
Normal file
69
frontend/src/pages/Deploy/Server/List/schema.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { OsType, AuthType } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器分类表单校验
|
||||||
|
*/
|
||||||
|
export const serverCategoryFormSchema = z.object({
|
||||||
|
name: z.string().min(1, '分类名称不能为空').max(50, '分类名称不能超过50个字符'),
|
||||||
|
code: z.string().min(1, '分类编码不能为空').max(50, '分类编码不能超过50个字符'),
|
||||||
|
icon: z.string().max(50, '图标不能超过50个字符').optional(),
|
||||||
|
description: z.string().max(500, '描述不能超过500个字符').optional(),
|
||||||
|
sort: z.number().min(0, '排序不能小于0').default(0),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器表单校验
|
||||||
|
*/
|
||||||
|
export const serverFormSchema = z.object({
|
||||||
|
serverName: z.string().min(1, '服务器名称不能为空').max(100, '服务器名称不能超过100个字符'),
|
||||||
|
hostIp: z.string()
|
||||||
|
.min(1, 'IP地址不能为空')
|
||||||
|
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, 'IP地址格式不正确')
|
||||||
|
.refine((ip) => {
|
||||||
|
const parts = ip.split('.');
|
||||||
|
return parts.every((part) => {
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
return num >= 0 && num <= 255;
|
||||||
|
});
|
||||||
|
}, 'IP地址范围必须在0-255之间'),
|
||||||
|
sshPort: z.number()
|
||||||
|
.min(1, 'SSH端口不能小于1')
|
||||||
|
.max(65535, 'SSH端口不能大于65535')
|
||||||
|
.default(22),
|
||||||
|
sshUser: z.string().max(50, 'SSH用户名不能超过50个字符').optional(),
|
||||||
|
authType: z.nativeEnum(AuthType).optional(),
|
||||||
|
sshPassword: z.string().max(200, 'SSH密码不能超过200个字符').optional(),
|
||||||
|
sshPrivateKey: z.string().optional(),
|
||||||
|
sshPassphrase: z.string().max(200, '私钥密码不能超过200个字符').optional(),
|
||||||
|
categoryId: z.number().optional(),
|
||||||
|
osType: z.nativeEnum(OsType).optional(),
|
||||||
|
osVersion: z.string().max(100, '操作系统版本不能超过100个字符').optional(),
|
||||||
|
hostname: z.string().max(100, '主机名不能超过100个字符').optional(),
|
||||||
|
description: z.string().max(500, '描述不能超过500个字符').optional(),
|
||||||
|
cpuCores: z.number().min(1, 'CPU核心数必须大于0').optional(),
|
||||||
|
memorySize: z.number().min(1, '内存大小必须大于0').optional(),
|
||||||
|
diskSize: z.number().min(1, '磁盘大小必须大于0').optional(),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// 如果选择密码认证,必须填写密码
|
||||||
|
if (data.authType === AuthType.PASSWORD && !data.sshPassword) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 如果选择密钥认证,必须填写私钥
|
||||||
|
if (data.authType === AuthType.KEY && !data.sshPrivateKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: '密码认证必须填写密码,密钥认证必须填写私钥',
|
||||||
|
path: ['authType'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ServerCategoryFormValues = z.infer<typeof serverCategoryFormSchema>;
|
||||||
|
export type ServerFormValues = z.infer<typeof serverFormSchema>;
|
||||||
|
|
||||||
117
frontend/src/pages/Deploy/Server/List/service.ts
Normal file
117
frontend/src/pages/Deploy/Server/List/service.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type {
|
||||||
|
ServerCategoryResponse,
|
||||||
|
ServerCategoryQuery,
|
||||||
|
ServerCategoryRequest,
|
||||||
|
ServerResponse,
|
||||||
|
ServerRequest,
|
||||||
|
} from './types';
|
||||||
|
import type { Page } from '@/types/base';
|
||||||
|
|
||||||
|
// API 基础路径
|
||||||
|
const CATEGORY_URL = '/api/v1/server-category';
|
||||||
|
const SERVER_URL = '/api/v1/server';
|
||||||
|
|
||||||
|
// ==================== 服务器分类 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器分类列表
|
||||||
|
*/
|
||||||
|
export const getServerCategories = (params?: ServerCategoryQuery) =>
|
||||||
|
request.get<ServerCategoryResponse[]>(`${CATEGORY_URL}/list`, { params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器分类分页列表
|
||||||
|
*/
|
||||||
|
export const getServerCategoryPage = (params?: ServerCategoryQuery) =>
|
||||||
|
request.get<Page<ServerCategoryResponse>>(`${CATEGORY_URL}/page`, { params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器分类详情
|
||||||
|
*/
|
||||||
|
export const getServerCategory = (id: number) =>
|
||||||
|
request.get<ServerCategoryResponse>(`${CATEGORY_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建服务器分类
|
||||||
|
*/
|
||||||
|
export const createServerCategory = (data: ServerCategoryRequest) =>
|
||||||
|
request.post<ServerCategoryResponse>(CATEGORY_URL, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新服务器分类
|
||||||
|
*/
|
||||||
|
export const updateServerCategory = (id: number, data: ServerCategoryRequest) =>
|
||||||
|
request.put<ServerCategoryResponse>(`${CATEGORY_URL}/${id}`, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除服务器分类
|
||||||
|
*/
|
||||||
|
export const deleteServerCategory = (id: number) =>
|
||||||
|
request.delete<void>(`${CATEGORY_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除服务器分类
|
||||||
|
*/
|
||||||
|
export const batchDeleteServerCategories = (ids: number[]) =>
|
||||||
|
request.post<void>(`${CATEGORY_URL}/batch-delete`, { ids });
|
||||||
|
|
||||||
|
// ==================== 服务器 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器分页列表
|
||||||
|
*/
|
||||||
|
export const getServers = (params: {
|
||||||
|
categoryId?: number;
|
||||||
|
status?: string;
|
||||||
|
osType?: string;
|
||||||
|
pageNum?: number;
|
||||||
|
size?: number;
|
||||||
|
}) =>
|
||||||
|
request.get<Page<ServerResponse>>(`${SERVER_URL}/page`, {
|
||||||
|
params: {
|
||||||
|
pageNum: params.pageNum ?? 0,
|
||||||
|
size: params.size ?? 20,
|
||||||
|
categoryId: params.categoryId,
|
||||||
|
status: params.status,
|
||||||
|
osType: params.osType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器详情
|
||||||
|
*/
|
||||||
|
export const getServer = (id: number) =>
|
||||||
|
request.get<ServerResponse>(`${SERVER_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建服务器
|
||||||
|
*/
|
||||||
|
export const createServer = (data: ServerRequest) =>
|
||||||
|
request.post<ServerResponse>(SERVER_URL, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新服务器
|
||||||
|
*/
|
||||||
|
export const updateServer = (id: number, data: ServerRequest) =>
|
||||||
|
request.put<ServerResponse>(`${SERVER_URL}/${id}`, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除服务器
|
||||||
|
*/
|
||||||
|
export const deleteServer = (id: number) =>
|
||||||
|
request.delete<void>(`${SERVER_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除服务器
|
||||||
|
*/
|
||||||
|
export const batchDeleteServers = (ids: number[]) =>
|
||||||
|
request.post<void>(`${SERVER_URL}/batch-delete`, { ids });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试服务器连接
|
||||||
|
* 返回 true 表示连接成功, false 表示连接失败
|
||||||
|
*/
|
||||||
|
export const testServerConnection = (id: number) =>
|
||||||
|
request.post<boolean>(`${SERVER_URL}/${id}/test-connection`);
|
||||||
|
|
||||||
224
frontend/src/pages/Deploy/Server/List/types.ts
Normal file
224
frontend/src/pages/Deploy/Server/List/types.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import type { BaseResponse, BaseQuery } from '@/types/base';
|
||||||
|
|
||||||
|
// ==================== 枚举类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器状态枚举
|
||||||
|
*/
|
||||||
|
export enum ServerStatus {
|
||||||
|
/** 在线 */
|
||||||
|
ONLINE = 'ONLINE',
|
||||||
|
/** 离线 */
|
||||||
|
OFFLINE = 'OFFLINE',
|
||||||
|
/** 未知 */
|
||||||
|
UNKNOWN = 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作系统类型枚举
|
||||||
|
*/
|
||||||
|
export enum OsType {
|
||||||
|
/** Linux */
|
||||||
|
LINUX = 'LINUX',
|
||||||
|
/** Windows */
|
||||||
|
WINDOWS = 'WINDOWS',
|
||||||
|
/** MacOS */
|
||||||
|
MACOS = 'MACOS',
|
||||||
|
/** Unix */
|
||||||
|
UNIX = 'UNIX',
|
||||||
|
/** 其他 */
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证方式枚举
|
||||||
|
*/
|
||||||
|
export enum AuthType {
|
||||||
|
/** 密码认证 */
|
||||||
|
PASSWORD = 'PASSWORD',
|
||||||
|
/** 密钥认证 */
|
||||||
|
KEY = 'KEY',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 服务器分类 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器分类响应类型
|
||||||
|
*/
|
||||||
|
export interface ServerCategoryResponse extends BaseResponse {
|
||||||
|
/** 分类名称 */
|
||||||
|
name: string;
|
||||||
|
/** 分类编码 */
|
||||||
|
code: string;
|
||||||
|
/** 图标 */
|
||||||
|
icon?: string;
|
||||||
|
/** 描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 排序 */
|
||||||
|
sort: number;
|
||||||
|
/** 是否启用 */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 服务器数量 */
|
||||||
|
serverCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器分类查询参数
|
||||||
|
*/
|
||||||
|
export interface ServerCategoryQuery extends BaseQuery {
|
||||||
|
/** 分类名称 */
|
||||||
|
name?: string;
|
||||||
|
/** 是否启用 */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器分类请求类型
|
||||||
|
*/
|
||||||
|
export interface ServerCategoryRequest {
|
||||||
|
/** 分类名称 */
|
||||||
|
name: string;
|
||||||
|
/** 分类编码 */
|
||||||
|
code: string;
|
||||||
|
/** 图标 */
|
||||||
|
icon?: string;
|
||||||
|
/** 描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 排序 */
|
||||||
|
sort: number;
|
||||||
|
/** 是否启用 */
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 服务器 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器响应类型
|
||||||
|
*/
|
||||||
|
export interface ServerResponse extends BaseResponse {
|
||||||
|
/** 服务器名称 */
|
||||||
|
serverName: string;
|
||||||
|
/** IP地址 */
|
||||||
|
hostIp: string;
|
||||||
|
/** SSH端口 */
|
||||||
|
sshPort: number;
|
||||||
|
/** SSH用户名 */
|
||||||
|
sshUser?: string;
|
||||||
|
/** 认证方式 */
|
||||||
|
authType?: AuthType;
|
||||||
|
/** SSH密码(加密存储) */
|
||||||
|
sshPassword?: string;
|
||||||
|
/** SSH私钥(加密存储) */
|
||||||
|
sshPrivateKey?: string;
|
||||||
|
/** 私钥密码(可选,用于加密的私钥) */
|
||||||
|
sshPassphrase?: string;
|
||||||
|
/** 服务器分类ID */
|
||||||
|
categoryId?: number;
|
||||||
|
/** 操作系统类型 */
|
||||||
|
osType?: OsType;
|
||||||
|
/** 操作系统版本 */
|
||||||
|
osVersion?: string;
|
||||||
|
/** 主机名 */
|
||||||
|
hostname?: string;
|
||||||
|
/** 连接状态 */
|
||||||
|
status: ServerStatus;
|
||||||
|
/** 描述 */
|
||||||
|
description?: string;
|
||||||
|
/** CPU核心数 */
|
||||||
|
cpuCores?: number;
|
||||||
|
/** 内存大小(GB) */
|
||||||
|
memorySize?: number;
|
||||||
|
/** 磁盘大小(GB) */
|
||||||
|
diskSize?: number;
|
||||||
|
/** 标签(JSON字符串) */
|
||||||
|
tags?: string;
|
||||||
|
/** 最后连接时间 */
|
||||||
|
lastConnectTime?: string;
|
||||||
|
/** 分类名称 */
|
||||||
|
categoryName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器查询参数
|
||||||
|
*/
|
||||||
|
export interface ServerQuery extends BaseQuery {
|
||||||
|
/** 服务器名称 */
|
||||||
|
serverName?: string;
|
||||||
|
/** IP地址 */
|
||||||
|
hostIp?: string;
|
||||||
|
/** 分类ID */
|
||||||
|
categoryId?: number;
|
||||||
|
/** 操作系统类型 */
|
||||||
|
osType?: OsType;
|
||||||
|
/** 连接状态 */
|
||||||
|
status?: ServerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器请求类型
|
||||||
|
*/
|
||||||
|
export interface ServerRequest {
|
||||||
|
/** 服务器名称 */
|
||||||
|
serverName: string;
|
||||||
|
/** IP地址 */
|
||||||
|
hostIp: string;
|
||||||
|
/** SSH端口 */
|
||||||
|
sshPort: number;
|
||||||
|
/** SSH用户名 */
|
||||||
|
sshUser?: string;
|
||||||
|
/** 认证方式 */
|
||||||
|
authType?: AuthType;
|
||||||
|
/** SSH密码(加密存储) */
|
||||||
|
sshPassword?: string;
|
||||||
|
/** SSH私钥(加密存储) */
|
||||||
|
sshPrivateKey?: string;
|
||||||
|
/** 私钥密码(可选,用于加密的私钥) */
|
||||||
|
sshPassphrase?: string;
|
||||||
|
/** 服务器分类ID */
|
||||||
|
categoryId?: number;
|
||||||
|
/** 操作系统类型 */
|
||||||
|
osType?: OsType;
|
||||||
|
/** 操作系统版本 */
|
||||||
|
osVersion?: string;
|
||||||
|
/** 主机名 */
|
||||||
|
hostname?: string;
|
||||||
|
/** 描述 */
|
||||||
|
description?: string;
|
||||||
|
/** CPU核心数 */
|
||||||
|
cpuCores?: number;
|
||||||
|
/** 内存大小(GB) */
|
||||||
|
memorySize?: number;
|
||||||
|
/** 磁盘大小(GB) */
|
||||||
|
diskSize?: number;
|
||||||
|
/** 标签(JSON字符串) */
|
||||||
|
tags?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器状态标签映射
|
||||||
|
*/
|
||||||
|
export const ServerStatusLabels: Record<ServerStatus, { label: string; description: string }> = {
|
||||||
|
[ServerStatus.ONLINE]: { label: '在线', description: '服务器在线可用' },
|
||||||
|
[ServerStatus.OFFLINE]: { label: '离线', description: '服务器离线或不可达' },
|
||||||
|
[ServerStatus.UNKNOWN]: { label: '未知', description: '服务器状态未知' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作系统类型标签映射
|
||||||
|
*/
|
||||||
|
export const OsTypeLabels: Record<OsType, { label: string; description: string }> = {
|
||||||
|
[OsType.LINUX]: { label: 'Linux', description: 'Linux系列操作系统' },
|
||||||
|
[OsType.WINDOWS]: { label: 'Windows', description: 'Windows系列操作系统' },
|
||||||
|
[OsType.MACOS]: { label: 'MacOS', description: 'MacOS系列操作系统' },
|
||||||
|
[OsType.UNIX]: { label: 'Unix', description: 'Unix系列操作系统' },
|
||||||
|
[OsType.OTHER]: { label: 'Other', description: '其他操作系统' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证方式标签映射
|
||||||
|
*/
|
||||||
|
export const AuthTypeLabels: Record<AuthType, { label: string; description: string }> = {
|
||||||
|
[AuthType.PASSWORD]: { label: '密码认证', description: '使用用户名和密码进行SSH认证' },
|
||||||
|
[AuthType.KEY]: { label: '密钥认证', description: '使用SSH私钥进行认证' },
|
||||||
|
};
|
||||||
|
|
||||||
@ -43,6 +43,7 @@ const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
|
|||||||
const External = lazy(() => import('../pages/Deploy/External'));
|
const External = lazy(() => import('../pages/Deploy/External'));
|
||||||
const TeamList = lazy(() => import('../pages/Deploy/Team/List'));
|
const TeamList = lazy(() => import('../pages/Deploy/Team/List'));
|
||||||
const ScheduleJobList = lazy(() => import('../pages/Deploy/ScheduleJob/List'));
|
const ScheduleJobList = lazy(() => import('../pages/Deploy/ScheduleJob/List'));
|
||||||
|
const ServerList = lazy(() => import('../pages/Deploy/Server/List'));
|
||||||
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
|
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
|
||||||
const FormDefinitionList = lazy(() => import('../pages/Form/Definition'));
|
const FormDefinitionList = lazy(() => import('../pages/Form/Definition'));
|
||||||
const FormDefinitionDesigner = lazy(() => import('../pages/Form/Definition/Designer'));
|
const FormDefinitionDesigner = lazy(() => import('../pages/Form/Definition/Designer'));
|
||||||
@ -95,6 +96,10 @@ const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: 'schedule-jobs',
|
path: 'schedule-jobs',
|
||||||
element: <Suspense fallback={<LoadingComponent/>}><ScheduleJobList/></Suspense>
|
element: <Suspense fallback={<LoadingComponent/>}><ScheduleJobList/></Suspense>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'servers',
|
||||||
|
element: <Suspense fallback={<LoadingComponent/>}><ServerList/></Suspense>
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user