重构消息通知弹窗
This commit is contained in:
parent
5b8e12b82f
commit
df369d0d0c
@ -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':
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user