重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-07 15:29:46 +08:00
parent bb6985341f
commit c9b8157754
6 changed files with 408 additions and 291 deletions

View File

@ -42,12 +42,12 @@ const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, duration = 3000, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
ref={ref} ref={ref}
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
duration={3000} duration={duration}
{...props} {...props}
/> />
) )

View File

@ -13,8 +13,11 @@ export function Toaster() {
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => ( {toasts.map(({ id, title, description, action, ...props }) => {
<Toast key={id} {...props}> // 确保duration有默认值不被undefined覆盖
const duration = props.duration ?? 3000
return (
<Toast key={id} {...props} duration={duration}>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (
@ -24,7 +27,8 @@ export function Toaster() {
{action} {action}
<ToastClose /> <ToastClose />
</Toast> </Toast>
))} )
})}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) )

View File

@ -11,6 +11,10 @@ import {
Loader2, Loader2,
ChevronDown, ChevronDown,
Terminal, Terminal,
MonitorDot,
Tags,
Tag,
FileText,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -69,17 +73,21 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
<CardContent className="flex flex-1 flex-col gap-3 p-3"> <CardContent className="flex flex-1 flex-col gap-3 p-3">
{/* 基础信息 */} {/* 基础信息 */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/50"> {/* 左侧:操作系统大图标 */}
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-lg bg-muted/50">
{server.osType ? ( {server.osType ? (
<div className="h-5 w-5 text-primary">{getOsIcon(server.osType)}</div> <div className="h-8 w-8 text-primary">{getOsIcon(server.osType)}</div>
) : ( ) : (
<Skeleton className="h-5 w-5 rounded" /> <Skeleton className="h-8 w-8 rounded" />
)} )}
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{/* 主要内容区域 */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
{/* 第一行:状态徽章 + 分类 */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div <div
className={`h-2 w-2 rounded-full ${ className={`h-2 w-2 rounded-full flex-shrink-0 ${
server.status === 'ONLINE' server.status === 'ONLINE'
? 'bg-emerald-500 animate-pulse' ? 'bg-emerald-500 animate-pulse'
: server.status === 'OFFLINE' : server.status === 'OFFLINE'
@ -108,31 +116,96 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
</Badge> </Badge>
)} )}
</div> </div>
{/* 服务器名图标NASQC-NAS */}
{renderValue( {renderValue(
<h3 className="truncate text-sm font-semibold text-foreground"> <div className="flex items-center gap-1.5 text-sm">
<Tag className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<span className="font-semibold text-foreground truncate">
{server.serverName} {server.serverName}
</h3>, {server.hostname && (
<span className="text-muted-foreground font-normal">{server.hostname}</span>
)}
</span>
</div>,
'w-32', 'w-32',
{ skeleton: true } { skeleton: true }
)} )}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Network className="h-3 w-3 flex-shrink-0" /> {/* IP图标172.22.222.111 */}
<div className="flex items-center gap-1.5 text-xs">
<Network className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{renderValue( {renderValue(
<span className="font-mono text-xs text-foreground">{server.hostIp}</span>, <span className="font-mono text-foreground">{server.hostIp}</span>,
'w-24', 'w-24',
{ skeleton: true } { skeleton: true }
)} )}
</div> </div>
{server.hostname && (
<span className="truncate text-[11px] text-muted-foreground">{server.hostname}</span> {/* 操作系统版本图标Linux Debian... */}
)} <div className="flex items-center gap-1.5 text-xs">
{server.osType && ( <MonitorDot className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground"> {renderValue(
{(server.osType && OsTypeLabels[server.osType]?.label) || server.osType} server.osType ? (
<span className="text-muted-foreground truncate">
{OsTypeLabels[server.osType]?.label || server.osType}
{server.osVersion && ` ${server.osVersion}`} {server.osVersion && ` ${server.osVersion}`}
</span> </span>
) : null,
'w-40',
{ skeleton: true }
)} )}
</div> </div>
{/* 标签图标:华为云 阿里云 */}
<div className="flex items-center gap-1.5">
<Tags className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{renderValue(
(() => {
try {
const tags = server.tags ? JSON.parse(server.tags) : [];
if (Array.isArray(tags) && tags.length > 0) {
return (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag: string, index: number) => (
<Badge key={index} variant="outline" className="text-[10px] py-0 px-1.5">
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5">
+{tags.length - 3}
</Badge>
)}
</div>
);
}
return null;
} catch {
return null;
}
})(),
'w-32',
{ skeleton: true }
)}
</div>
{/* 描述图标:测试 */}
<div className="flex items-start gap-1.5">
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-0.5" />
{renderValue(
server.description ? (
<p className="text-[11px] text-muted-foreground line-clamp-2 flex-1">
{server.description}
</p>
) : null,
'w-full',
{ skeleton: true }
)}
</div>
</div>
{/* 右侧:展开按钮 */}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -222,9 +295,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
</div> </div>
</CardContent> </CardContent>
{/* 展开内容 */} {/* 展开内容 - 仅显示SSH信息和大图标配置 */}
{expanded && ( {expanded && (
<div className="absolute left-[-1px] right-[-1px] top-full z-20 space-y-3 rounded-b-xl border-x border-b border-border bg-card p-3 text-xs shadow-lg group-hover:border-primary/40"> <div className="absolute left-[-1px] right-[-1px] top-full z-20 space-y-3 rounded-b-xl border-x border-b border-border bg-card p-3 text-xs shadow-lg group-hover:border-primary/40">
{/* SSH连接信息 */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{renderValue( {renderValue(
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@ -241,10 +315,12 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
</Badge> </Badge>
)} )}
</div> </div>
{/* 详细配置信息 - 大图标展示 */}
<div className="grid grid-cols-3 gap-2 text-center"> <div className="grid grid-cols-3 gap-2 text-center">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Cpu className="h-4 w-4" /> <Cpu className="h-5 w-5" />
</div> </div>
{renderValue( {renderValue(
server.cpuCores ? ( server.cpuCores ? (
@ -255,8 +331,8 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
)} )}
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<MemoryStick className="h-4 w-4" /> <MemoryStick className="h-5 w-5" />
</div> </div>
{renderValue( {renderValue(
server.memorySize ? ( server.memorySize ? (
@ -267,8 +343,8 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
)} )}
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<HardDrive className="h-4 w-4" /> <HardDrive className="h-5 w-5" />
</div> </div>
{renderValue( {renderValue(
server.diskSize ? ( server.diskSize ? (
@ -279,33 +355,6 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
)} )}
</div> </div>
</div> </div>
{server.tags && (() => {
try {
const tags = JSON.parse(server.tags);
if (Array.isArray(tags) && tags.length > 0) {
return (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag: string, index: number) => (
<Badge key={index} variant="outline" className="text-[11px]">
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-[11px]">+{tags.length - 3}</Badge>
)}
</div>
);
}
} catch {
return null;
}
return null;
})()}
{server.description ? (
<p className="text-xs text-muted-foreground line-clamp-3">{server.description}</p>
) : null}
</div> </div>
)} )}
</Card> </Card>

View File

@ -29,11 +29,17 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { Separator } from '@/components/ui/separator';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import type { ServerResponse, ServerCategoryResponse } from '../types'; import type { ServerResponse, ServerCategoryResponse } from '../types';
import { OsType, OsTypeLabels, AuthType, AuthTypeLabels, ServerStatus } from '../types'; import { OsType, OsTypeLabels, AuthType, AuthTypeLabels, ServerStatus } from '../types';
import { createServer, updateServer, getServerCategories } from '../service'; import { createServer, updateServer, getServerCategories } from '../service';
import { serverFormSchema, type ServerFormValues } from '../schema'; import { serverFormSchema, type ServerFormValues } from '../schema';
import { Eye, EyeOff, X, Plus } from 'lucide-react'; import { Eye, EyeOff, X, Plus, ChevronDown } from 'lucide-react';
interface ServerEditDialogProps { interface ServerEditDialogProps {
open: boolean; open: boolean;
@ -54,6 +60,7 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPassphrase, setShowPassphrase] = useState(false); const [showPassphrase, setShowPassphrase] = useState(false);
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
const [systemInfoOpen, setSystemInfoOpen] = useState(true);
const isEdit = !!server?.id; const isEdit = !!server?.id;
const form = useForm<ServerFormValues>({ const form = useForm<ServerFormValues>({
@ -162,11 +169,7 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: any) {
toast({ // 错误已在request拦截器中处理这里不再重复显示toast
variant: 'destructive',
title: isEdit ? '更新失败' : '创建失败',
description: error.response?.data?.message || '操作失败',
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -181,9 +184,13 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
<DialogBody> <DialogBody>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* 基础信息区 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* 基本信息 */}
<FormField <FormField
control={form.control} control={form.control}
name="serverName" name="serverName"
@ -200,36 +207,6 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
)} )}
/> />
<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 <FormField
control={form.control} control={form.control}
name="categoryId" name="categoryId"
@ -259,188 +236,24 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
)} )}
/> />
{/* 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 <FormField
control={form.control} control={form.control}
name="osType" name="osType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>
<span className="text-destructive">*</span>
</FormLabel>
<Select <Select
value={field.value || 'none'} value={field.value}
onValueChange={(value) => field.onChange(value === 'none' ? undefined : (value as OsType))} onValueChange={(value) => field.onChange(value as OsType)}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="选择系统类型" /> <SelectValue placeholder="请选择系统类型" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none"></SelectItem>
{Object.entries(OsTypeLabels).map(([key, value]) => ( {Object.entries(OsTypeLabels).map(([key, value]) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
{value.label} {value.label}
@ -453,81 +266,6 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
)} )}
/> />
<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 <FormField
control={form.control} control={form.control}
@ -622,6 +360,325 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
)} )}
/> />
</div> </div>
</div>
<Separator />
{/* SSH连接信息区 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
SSH连接信息
</h3>
<div className="grid grid-cols-2 gap-4">
<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="sshPort"
render={({ field }) => (
<FormItem>
<FormLabel>
SSH端口 <span className="text-destructive">*</span>
</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用户名 <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input placeholder="例如root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 认证方式 */}
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
<span className="text-destructive">*</span>
</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
const authType = 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>
{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密码 <span className="text-destructive">*</span>
</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私钥 <span className="text-destructive">*</span>
</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>
)}
/>
</>
)}
</div>
</div>
<Separator />
{/* 系统信息区(自动采集 - 可折叠) */}
<Collapsible open={systemInfoOpen} onOpenChange={setSystemInfoOpen}>
<div className="space-y-4">
<CollapsibleTrigger className="flex w-full items-center justify-between hover:opacity-70 transition-opacity">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
</h3>
<span className="text-xs text-muted-foreground"></span>
</div>
<ChevronDown className={`h-4 w-4 transition-transform ${systemInfoOpen ? 'rotate-180' : ''}`} />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-4 pt-2">
<FormField
control={form.control}
name="hostname"
render={({ field }) => (
<FormItem>
<FormLabel>
<span className="text-xs text-muted-foreground">()</span>
</FormLabel>
<FormControl>
<Input placeholder="测试连接后自动填充" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="osVersion"
render={({ field }) => (
<FormItem>
<FormLabel>
<span className="text-xs text-muted-foreground">()</span>
</FormLabel>
<FormControl>
<Input placeholder="测试连接后自动填充" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 硬件配置 */}
<FormField
control={form.control}
name="cpuCores"
render={({ field }) => (
<FormItem>
<FormLabel>
CPU核心数 <span className="text-xs text-muted-foreground">()</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="测试连接后自动填充"
{...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) <span className="text-xs text-muted-foreground">()</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="测试连接后自动填充"
{...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) <span className="text-xs text-muted-foreground">()</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="测试连接后自动填充"
{...field}
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</form> </form>
</Form> </Form>
</DialogBody> </DialogBody>

View File

@ -173,6 +173,8 @@ const ServerList: React.FC = () => {
title: '连接成功', title: '连接成功',
description: `${result.hostname} - ${result.osVersion} (响应时间: ${result.responseTime}ms)`, description: `${result.hostname} - ${result.osVersion} (响应时间: ${result.responseTime}ms)`,
}); });
// 测试连接成功后刷新数据
await loadServers();
} else { } else {
toast({ toast({
variant: 'destructive', variant: 'destructive',

View File

@ -32,38 +32,43 @@ export const serverFormSchema = z.object({
.min(1, 'SSH端口不能小于1') .min(1, 'SSH端口不能小于1')
.max(65535, 'SSH端口不能大于65535') .max(65535, 'SSH端口不能大于65535')
.default(22), .default(22),
sshUser: z.string().max(50, 'SSH用户名不能超过50个字符').optional(), sshUser: z.string().min(1, 'SSH用户名不能为空').max(50, 'SSH用户名不能超过50个字符'),
authType: z.nativeEnum(AuthType).optional(), authType: z.nativeEnum(AuthType, {
errorMap: () => ({ message: '请选择认证方式' })
}),
sshPassword: z.string().max(200, 'SSH密码不能超过200个字符').optional(), sshPassword: z.string().max(200, 'SSH密码不能超过200个字符').optional(),
sshPrivateKey: z.string().optional(), sshPrivateKey: z.string().optional(),
sshPassphrase: z.string().max(200, '私钥密码不能超过200个字符').optional(), sshPassphrase: z.string().max(200, '私钥密码不能超过200个字符').optional(),
categoryId: z.number().optional(), categoryId: z.number().optional(),
osType: z.nativeEnum(OsType).optional(), osType: z.nativeEnum(OsType, {
errorMap: () => ({ message: '请选择操作系统类型' })
}),
osVersion: z.string().max(100, '操作系统版本不能超过100个字符').optional(), osVersion: z.string().max(100, '操作系统版本不能超过100个字符').optional(),
hostname: z.string().max(100, '主机名不能超过100个字符').optional(), hostname: z.string().max(100, '主机名不能超过100个字符').optional(),
status: z.nativeEnum(ServerStatus).optional(), status: z.nativeEnum(ServerStatus).optional(),
description: z.string().max(500, '描述不能超过500个字符').optional(), description: z.string().max(500, '描述不能超过500个字符').optional(),
cpuCores: z.number().min(1, 'CPU核心数必须大于0').optional(), cpuCores: z.union([z.number().min(1, 'CPU核心数必须大于0'), z.null(), z.undefined()]).optional(),
memorySize: z.number().min(1, '内存大小必须大于0').optional(), memorySize: z.union([z.number().min(1, '内存大小必须大于0'), z.null(), z.undefined()]).optional(),
diskSize: z.number().min(1, '磁盘大小必须大于0').optional(), diskSize: z.union([z.number().min(1, '磁盘大小必须大于0'), z.null(), z.undefined()]).optional(),
tags: z.string().optional(), tags: z.string().optional(),
}).refine( }).superRefine((data, ctx) => {
(data) => {
// 如果选择密码认证,必须填写密码 // 如果选择密码认证,必须填写密码
if (data.authType === AuthType.PASSWORD && !data.sshPassword) { if (data.authType === AuthType.PASSWORD && !data.sshPassword) {
return false; ctx.addIssue({
code: 'custom',
message: 'SSH密码不能为空',
path: ['sshPassword'],
});
} }
// 如果选择密钥认证,必须填写私钥 // 如果选择密钥认证,必须填写私钥
if (data.authType === AuthType.KEY && !data.sshPrivateKey) { if (data.authType === AuthType.KEY && !data.sshPrivateKey) {
return false; ctx.addIssue({
code: 'custom',
message: 'SSH私钥不能为空',
path: ['sshPrivateKey'],
});
} }
return true; });
},
{
message: '密码认证必须填写密码,密钥认证必须填写私钥',
path: ['authType'],
}
);
export type ServerCategoryFormValues = z.infer<typeof serverCategoryFormSchema>; export type ServerCategoryFormValues = z.infer<typeof serverCategoryFormSchema>;
export type ServerFormValues = z.infer<typeof serverFormSchema>; export type ServerFormValues = z.infer<typeof serverFormSchema>;