重写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.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}
/>
)

View File

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

View File

@ -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>
{/* 服务器名图标NASQC-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>

View File

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

View File

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

View File

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