重写ssh前端组件,通用化
This commit is contained in:
parent
bb6985341f
commit
c9b8157754
@ -42,12 +42,12 @@ const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
>(({ className, variant, duration = 3000, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
duration={3000}
|
||||
duration={duration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -13,18 +13,22 @@ export function Toaster() {
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
{toasts.map(({ id, title, description, action, ...props }) => {
|
||||
// 确保duration有默认值,不被undefined覆盖
|
||||
const duration = props.duration ?? 3000
|
||||
return (
|
||||
<Toast key={id} {...props} duration={duration}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
|
||||
@ -11,6 +11,10 @@ import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
MonitorDot,
|
||||
Tags,
|
||||
Tag,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
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">
|
||||
{/* 基础信息 */}
|
||||
<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 ? (
|
||||
<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 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={`h-2 w-2 rounded-full ${
|
||||
className={`h-2 w-2 rounded-full flex-shrink-0 ${
|
||||
server.status === 'ONLINE'
|
||||
? 'bg-emerald-500 animate-pulse'
|
||||
: server.status === 'OFFLINE'
|
||||
@ -108,31 +116,96 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 服务器名图标:NAS(QC-NAS) */}
|
||||
{renderValue(
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{server.serverName}
|
||||
</h3>,
|
||||
<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.hostname && (
|
||||
<span className="text-muted-foreground font-normal">({server.hostname})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>,
|
||||
'w-32',
|
||||
{ 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(
|
||||
<span className="font-mono text-xs text-foreground">{server.hostIp}</span>,
|
||||
<span className="font-mono text-foreground">{server.hostIp}</span>,
|
||||
'w-24',
|
||||
{ skeleton: true }
|
||||
)}
|
||||
</div>
|
||||
{server.hostname && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">{server.hostname}</span>
|
||||
)}
|
||||
{server.osType && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{(server.osType && OsTypeLabels[server.osType]?.label) || server.osType}
|
||||
{server.osVersion && ` ${server.osVersion}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 操作系统版本图标:Linux Debian... */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<MonitorDot className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
{renderValue(
|
||||
server.osType ? (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{OsTypeLabels[server.osType]?.label || server.osType}
|
||||
{server.osVersion && ` ${server.osVersion}`}
|
||||
</span>
|
||||
) : null,
|
||||
'w-40',
|
||||
{ skeleton: true }
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@ -222,9 +295,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{/* 展开内容 - 仅显示SSH信息和大图标配置 */}
|
||||
{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">
|
||||
{/* SSH连接信息 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{renderValue(
|
||||
<span className="text-muted-foreground">
|
||||
@ -241,10 +315,12 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 详细配置信息 - 大图标展示 */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<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">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Cpu className="h-5 w-5" />
|
||||
</div>
|
||||
{renderValue(
|
||||
server.cpuCores ? (
|
||||
@ -255,8 +331,8 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<MemoryStick className="h-5 w-5" />
|
||||
</div>
|
||||
{renderValue(
|
||||
server.memorySize ? (
|
||||
@ -267,8 +343,8 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
</div>
|
||||
{renderValue(
|
||||
server.diskSize ? (
|
||||
@ -279,33 +355,6 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -29,11 +29,17 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 { OsType, OsTypeLabels, AuthType, AuthTypeLabels, ServerStatus } from '../types';
|
||||
import { createServer, updateServer, getServerCategories } from '../service';
|
||||
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 {
|
||||
open: boolean;
|
||||
@ -54,6 +60,7 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [systemInfoOpen, setSystemInfoOpen] = useState(true);
|
||||
const isEdit = !!server?.id;
|
||||
|
||||
const form = useForm<ServerFormValues>({
|
||||
@ -162,11 +169,7 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: isEdit ? '更新失败' : '创建失败',
|
||||
description: error.response?.data?.message || '操作失败',
|
||||
});
|
||||
// 错误已在request拦截器中处理,这里不再重复显示toast
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -181,25 +184,192 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<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">
|
||||
<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="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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="osType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
操作系统类型 <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value as OsType)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择系统类型" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(OsTypeLabels).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
</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"
|
||||
@ -216,56 +386,14 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<FormLabel>
|
||||
SSH端口 <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@ -283,7 +411,9 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="sshUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH用户名</FormLabel>
|
||||
<FormLabel>
|
||||
SSH用户名 <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="例如:root" {...field} />
|
||||
</FormControl>
|
||||
@ -298,11 +428,13 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="authType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>认证方式</FormLabel>
|
||||
<FormLabel>
|
||||
认证方式 <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value || 'none'}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
const authType = value === 'none' ? undefined : (value as AuthType);
|
||||
const authType = value as AuthType;
|
||||
field.onChange(authType);
|
||||
// 切换认证方式时清空相关字段
|
||||
if (authType === AuthType.PASSWORD) {
|
||||
@ -315,11 +447,10 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择认证方式" />
|
||||
<SelectValue placeholder="请选择认证方式" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不选择</SelectItem>
|
||||
{Object.entries(AuthTypeLabels).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
@ -339,7 +470,9 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="sshPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH密码</FormLabel>
|
||||
<FormLabel>
|
||||
SSH密码 <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@ -375,7 +508,9 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="sshPrivateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>SSH私钥</FormLabel>
|
||||
<FormLabel>
|
||||
SSH私钥 <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="请粘贴SSH私钥内容"
|
||||
@ -422,32 +557,37 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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="osType"
|
||||
name="hostname"
|
||||
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>
|
||||
<FormLabel>
|
||||
主机名 <span className="text-xs text-muted-foreground">(自动采集)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="测试连接后自动填充" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@ -458,9 +598,11 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="osVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>操作系统版本</FormLabel>
|
||||
<FormLabel>
|
||||
系统版本 <span className="text-xs text-muted-foreground">(自动采集)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="例如:CentOS 7.9" {...field} />
|
||||
<Input placeholder="测试连接后自动填充" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -473,11 +615,13 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="cpuCores"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CPU核心数</FormLabel>
|
||||
<FormLabel>
|
||||
CPU核心数 <span className="text-xs text-muted-foreground">(自动采集)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="例如:8"
|
||||
placeholder="测试连接后自动填充"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
@ -493,11 +637,13 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="memorySize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>内存大小(GB)</FormLabel>
|
||||
<FormLabel>
|
||||
内存大小(GB) <span className="text-xs text-muted-foreground">(自动采集)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="例如:16"
|
||||
placeholder="测试连接后自动填充"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
@ -513,11 +659,13 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
name="diskSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>磁盘大小(GB)</FormLabel>
|
||||
<FormLabel>
|
||||
磁盘大小(GB) <span className="text-xs text-muted-foreground">(自动采集)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="例如:500"
|
||||
placeholder="测试连接后自动填充"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
@ -527,101 +675,10 @@ export const ServerEditDialog: React.FC<ServerEditDialogProps> = ({
|
||||
</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>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogBody>
|
||||
|
||||
@ -173,6 +173,8 @@ const ServerList: React.FC = () => {
|
||||
title: '连接成功',
|
||||
description: `${result.hostname} - ${result.osVersion} (响应时间: ${result.responseTime}ms)`,
|
||||
});
|
||||
// 测试连接成功后刷新数据
|
||||
await loadServers();
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
|
||||
@ -32,38 +32,43 @@ export const serverFormSchema = z.object({
|
||||
.min(1, 'SSH端口不能小于1')
|
||||
.max(65535, 'SSH端口不能大于65535')
|
||||
.default(22),
|
||||
sshUser: z.string().max(50, 'SSH用户名不能超过50个字符').optional(),
|
||||
authType: z.nativeEnum(AuthType).optional(),
|
||||
sshUser: z.string().min(1, 'SSH用户名不能为空').max(50, 'SSH用户名不能超过50个字符'),
|
||||
authType: z.nativeEnum(AuthType, {
|
||||
errorMap: () => ({ message: '请选择认证方式' })
|
||||
}),
|
||||
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(),
|
||||
osType: z.nativeEnum(OsType, {
|
||||
errorMap: () => ({ message: '请选择操作系统类型' })
|
||||
}),
|
||||
osVersion: z.string().max(100, '操作系统版本不能超过100个字符').optional(),
|
||||
hostname: z.string().max(100, '主机名不能超过100个字符').optional(),
|
||||
status: z.nativeEnum(ServerStatus).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(),
|
||||
cpuCores: z.union([z.number().min(1, 'CPU核心数必须大于0'), z.null(), z.undefined()]).optional(),
|
||||
memorySize: z.union([z.number().min(1, '内存大小必须大于0'), z.null(), z.undefined()]).optional(),
|
||||
diskSize: z.union([z.number().min(1, '磁盘大小必须大于0'), z.null(), z.undefined()]).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'],
|
||||
}).superRefine((data, ctx) => {
|
||||
// 如果选择密码认证,必须填写密码
|
||||
if (data.authType === AuthType.PASSWORD && !data.sshPassword) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'SSH密码不能为空',
|
||||
path: ['sshPassword'],
|
||||
});
|
||||
}
|
||||
);
|
||||
// 如果选择密钥认证,必须填写私钥
|
||||
if (data.authType === AuthType.KEY && !data.sshPrivateKey) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'SSH私钥不能为空',
|
||||
path: ['sshPrivateKey'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ServerCategoryFormValues = z.infer<typeof serverCategoryFormSchema>;
|
||||
export type ServerFormValues = z.infer<typeof serverFormSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user