增加团队管理页面

This commit is contained in:
dengqichen 2025-10-30 15:59:09 +08:00
parent b4174c28a5
commit 5186a5c43f
10 changed files with 2373 additions and 7 deletions

View File

@ -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}>

View File

@ -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

View File

@ -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>
</>
);
};

View 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>
);
};

View File

@ -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>
);
};

View 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;

View 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>;

View 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`);

View 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私钥进行认证' },
};

View File

@ -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>
}
]
},