重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-21 12:24:38 +08:00
parent 5b8e12b82f
commit df369d0d0c
2 changed files with 250 additions and 166 deletions

View File

@ -29,7 +29,17 @@ export const convertValidationRules = (validationRules?: ValidationRule[]): Rule
break; break;
case 'pattern': case 'pattern':
if (rule.value) { if (rule.value) {
antdRule.pattern = new RegExp(rule.value); // 只支持标准正则字面量格式:/pattern/flags
const regexMatch = rule.value.match(/^\/(.+)\/([gimsuy]*)$/);
if (regexMatch) {
try {
antdRule.pattern = new RegExp(regexMatch[1], regexMatch[2]);
} catch (error) {
console.error('正则表达式语法错误:', rule.value, error);
}
} else {
console.warn('正则表达式格式错误,请使用标准格式:/pattern/flags例如 /^\\d+$/i');
}
} }
break; break;
case 'min': case 'min':

View File

@ -9,11 +9,13 @@ import {
Pencil, Pencil,
Trash2, Trash2,
Loader2, Loader2,
ChevronDown,
} 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';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
import type { ServerResponse } from '../types'; import type { ServerResponse } from '../types';
import { ServerStatusLabels, OsTypeLabels } from '../types'; import { ServerStatusLabels, OsTypeLabels } from '../types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -32,189 +34,261 @@ interface ServerCardProps {
getOsIcon: (osType?: string) => React.ReactNode; getOsIcon: (osType?: string) => React.ReactNode;
} }
export const ServerCard: React.FC<ServerCardProps> = ({ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit, onDelete, isTesting, getOsIcon }) => {
server, const [expanded, setExpanded] = React.useState(false);
onTest,
onEdit,
onDelete,
isTesting,
getOsIcon,
}) => {
const formatTime = (time?: string) => { const formatTime = (time?: string) => {
if (!time) return '-'; if (!time) return '-';
return dayjs(time).fromNow(); return dayjs(time).fromNow();
}; };
return ( const renderValue = (
<Card className="group relative overflow-hidden hover:shadow-md transition-all duration-300 border hover:border-primary/50 flex flex-col h-full"> content: React.ReactNode,
<CardContent className="p-4 relative flex-1 flex flex-col"> skeletonWidth: string,
{/* 顶部:状态和分类 */} options?: { skeleton?: boolean; skeletonHeight?: string; fallback?: React.ReactNode }
<div className="flex items-center justify-between gap-2 mb-3"> ) => {
<div className="flex items-center gap-1.5"> if (content === undefined || content === null || content === '' || content === false) {
<div className={`h-2.5 w-2.5 rounded-full ${ if (options?.skeleton) {
server.status === 'ONLINE' ? 'bg-green-500 animate-pulse' : return (
server.status === 'OFFLINE' ? 'bg-red-500' : <Skeleton
'bg-gray-400' className={`${options.skeletonHeight ?? 'h-3'} rounded ${skeletonWidth}`}
}`} /> />
<Badge className={`text-xs ${ );
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' : return options?.fallback ?? null;
'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400' }
}`}> return content;
{(server.status && ServerStatusLabels[server.status]?.label) || server.status || '-'} };
</Badge>
</div>
{server.categoryName && (
<Badge variant="outline" className="text-xs ml-auto">
{server.categoryName}
</Badge>
)}
</div>
{/* 中间:服务器名称和基本信息 */} return (
<div className="flex items-start gap-3 mb-2.5 flex-1"> <Card className="group relative flex h-full flex-col justify-between overflow-visible border transition-all duration-200 hover:border-primary/40 hover:shadow-lg">
<div className="p-2 rounded-lg bg-muted/50"> <CardContent className="relative flex flex-1 flex-col gap-3 p-3">
<div className="h-5 w-5"> {/* 基础信息 */}
{getOsIcon(server.osType)} <div className="flex items-start gap-3">
</div> <div className="flex h-9 w-9 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>
) : (
<Skeleton className="h-5 w-5 rounded" />
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-sm font-semibold truncate group-hover:text-primary transition-colors"> <div className="flex flex-wrap items-center gap-2">
{server.serverName} <div
</h3> className={`h-2 w-2 rounded-full ${
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5"> server.status === 'ONLINE'
? 'bg-emerald-500 animate-pulse'
: server.status === 'OFFLINE'
? 'bg-red-500'
: 'bg-slate-400'
}`}
/>
{renderValue(
<Badge
variant="outline"
className={`border-transparent text-[11px] font-medium ${
server.status === 'ONLINE'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300'
: server.status === 'OFFLINE'
? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-700/30 dark:text-slate-200'
}`}
>
{(server.status && ServerStatusLabels[server.status]?.label) || server.status}
</Badge>,
'w-16'
)}
{server.categoryName && (
<Badge variant="outline" className="text-[11px]">
{server.categoryName}
</Badge>
)}
</div>
{renderValue(
<h3 className="truncate text-sm font-semibold text-foreground">
{server.serverName}
</h3>,
'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" /> <Network className="h-3 w-3 flex-shrink-0" />
<span className="font-mono truncate">{server.hostIp}</span> {renderValue(
<span className="font-mono text-xs text-foreground">{server.hostIp}</span>,
'w-24',
{ skeleton: true }
)}
</div> </div>
{server.hostname && ( {server.hostname && (
<div className="text-xs text-muted-foreground truncate"> <span className="truncate text-[11px] text-muted-foreground">{server.hostname}</span>
{server.hostname}
</div>
)} )}
{server.osType && ( {server.osType && (
<div className="text-xs text-muted-foreground mt-0.5"> <span className="text-[11px] text-muted-foreground">
{(server.osType && OsTypeLabels[server.osType]?.label) || server.osType || '-'} {(server.osType && OsTypeLabels[server.osType]?.label) || server.osType}
{server.osVersion && ` ${server.osVersion}`} {server.osVersion && ` ${server.osVersion}`}
</div> </span>
)} )}
</div> </div>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-7 w-7 flex-shrink-0 rounded-full border border-border/50 transition-transform ${
expanded ? 'rotate-180' : ''
}`}
onClick={() => setExpanded((prev) => !prev)}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
{/* 快速信息 */}
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
{server.lastConnectTime ? (
<span> {formatTime(server.lastConnectTime)}</span>
) : (
<span className="text-muted-foreground/50"></span>
)}
</div>
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onTest(server)}
disabled={isTesting}
>
{isTesting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TestTube className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onEdit(server)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => onDelete(server)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</div> </div>
{/* SSH 和认证方式 - 在一行显示 */} {/* 展开内容 */}
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2.5 flex-wrap"> {expanded && (
<span>SSH: <span className="font-medium text-foreground">{server.sshUser || 'root'}:{server.sshPort || 22}</span></span> <div className="absolute left-0 right-0 top-full z-20 -mt-px space-y-3 rounded-b-xl border border-border border-t bg-card p-3 text-xs shadow-lg">
{server.authType && ( <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs"> {renderValue(
{server.authType === 'PASSWORD' ? '密码' : '密钥'} <span className="text-muted-foreground">
</Badge> SSH
)} <span className="ml-1 font-medium text-foreground">
</div> {(server.sshUser || 'root') + ':' + (server.sshPort || 22)}
</span>
{/* 硬件配置 - 紧凑显示 */} </span>,
{(server.cpuCores || server.memorySize || server.diskSize) && ( 'w-32'
<div className="grid grid-cols-3 gap-2 mb-2.5 p-2 bg-muted/20 rounded border border-border/40"> )}
{server.cpuCores && ( {server.authType && (
<div className="flex flex-col items-center gap-0.5"> <Badge variant="secondary" className="text-[11px]">
<Cpu className="h-3 w-3 text-blue-600 dark:text-blue-400" /> {server.authType === 'PASSWORD' ? '密码认证' : '密钥认证'}
<span className="text-xs font-medium">{server.cpuCores}</span> </Badge>
</div> )}
)} </div>
{server.memorySize && ( <div className="grid grid-cols-3 gap-2 text-center">
<div className="flex flex-col items-center gap-0.5"> <div className="flex flex-col items-center gap-1">
<MemoryStick className="h-3 w-3 text-purple-600 dark:text-purple-400" /> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<span className="text-xs font-medium">{server.memorySize}GB</span> <Cpu className="h-4 w-4" />
</div> </div>
)} {renderValue(
{server.diskSize && ( server.cpuCores ? (
<div className="flex flex-col items-center gap-0.5"> <span className="text-xs font-medium text-foreground">{server.cpuCores}</span>
<HardDrive className="h-3 w-3 text-orange-600 dark:text-orange-400" /> ) : null,
<span className="text-xs font-medium">{server.diskSize}GB</span> 'w-10',
</div> { skeleton: true, skeletonHeight: 'h-4' }
)}
</div>
)}
{/* 标签 - 可选显示 */}
{server.tags && (() => {
try {
const tags = JSON.parse(server.tags);
return Array.isArray(tags) && tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2.5">
{tags.slice(0, 2).map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{tags.length > 2 && <Badge variant="outline" className="text-xs">+{tags.length - 2}</Badge>}
</div>
);
} catch {
return null;
}
})()}
{/* 描述 - 始终显示 */}
{server.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2.5 px-1">
{server.description}
</p>
)}
{/* 最后连接时间 */}
{server.lastConnectTime && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2.5">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="truncate"> {formatTime(server.lastConnectTime)}</span>
</div>
)}
{/* 操作按钮 - Hover 时才显示 */}
<div className="flex items-center justify-center gap-2 pt-2 mt-auto border-t border-border/30 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onTest(server)}
disabled={isTesting}
className="h-7 px-2"
>
{isTesting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TestTube className="h-3.5 w-3.5" />
)} )}
</Button> </div>
</TooltipTrigger> <div className="flex flex-col items-center gap-1">
<TooltipContent></TooltipContent> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
</Tooltip> <MemoryStick className="h-4 w-4" />
<Tooltip> </div>
<TooltipTrigger asChild> {renderValue(
<Button server.memorySize ? (
variant="ghost" <span className="text-xs font-medium text-foreground">{server.memorySize}GB</span>
size="sm" ) : null,
onClick={() => onEdit(server)} 'w-10',
className="h-7 px-2" { skeleton: true, skeletonHeight: 'h-4' }
> )}
<Pencil className="h-3.5 w-3.5" /> </div>
</Button> <div className="flex flex-col items-center gap-1">
</TooltipTrigger> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<TooltipContent></TooltipContent> <HardDrive className="h-4 w-4" />
</Tooltip> </div>
<Tooltip> {renderValue(
<TooltipTrigger asChild> server.diskSize ? (
<Button <span className="text-xs font-medium text-foreground">{server.diskSize}GB</span>
variant="ghost" ) : null,
size="sm" 'w-10',
onClick={() => onDelete(server)} { skeleton: true, skeletonHeight: 'h-4' }
className="h-7 px-2 text-destructive hover:text-destructive" )}
> </div>
<Trash2 className="h-3.5 w-3.5" /> </div>
</Button>
</TooltipTrigger> {server.tags && (() => {
<TooltipContent></TooltipContent> try {
</Tooltip> const tags = JSON.parse(server.tags);
</div> 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>
)}
</CardContent> </CardContent>
</Card> </Card>
); );