增加团队管理页面
This commit is contained in:
parent
b4174c28a5
commit
5186a5c43f
@ -53,6 +53,16 @@ interface InfiniteScrollListProps<T> {
|
||||
*/
|
||||
emptyIcon?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* 自定义空状态组件
|
||||
*/
|
||||
emptyState?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* 自定义初始加载占位符
|
||||
*/
|
||||
loadingPlaceholder?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* 加载完成提示文字
|
||||
*/
|
||||
@ -196,7 +206,7 @@ export function InfiniteScrollList<T>({
|
||||
) : null
|
||||
}
|
||||
scrollableTarget={scrollableTarget}
|
||||
className={listClassName}
|
||||
className={className || listClassName}
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
|
||||
@ -593,7 +593,7 @@ const GitManager: React.FC = () => {
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
@ -636,8 +636,8 @@ const GitManager: React.FC = () => {
|
||||
项目{selectedGroup && ` - ${selectedGroup.name}`}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSyncProjects}
|
||||
@ -646,8 +646,8 @@ const GitManager: React.FC = () => {
|
||||
<Download
|
||||
className={`h-4 w-4 ${syncing.projects ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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 TeamList = lazy(() => import('../pages/Deploy/Team/List'));
|
||||
const ScheduleJobList = lazy(() => import('../pages/Deploy/ScheduleJob/List'));
|
||||
const ServerList = lazy(() => import('../pages/Deploy/Server/List'));
|
||||
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
|
||||
const FormDefinitionList = lazy(() => import('../pages/Form/Definition'));
|
||||
const FormDefinitionDesigner = lazy(() => import('../pages/Form/Definition/Designer'));
|
||||
@ -95,6 +96,10 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: 'schedule-jobs',
|
||||
element: <Suspense fallback={<LoadingComponent/>}><ScheduleJobList/></Suspense>
|
||||
},
|
||||
{
|
||||
path: 'servers',
|
||||
element: <Suspense fallback={<LoadingComponent/>}><ServerList/></Suspense>
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user