641 lines
27 KiB
TypeScript
641 lines
27 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Search,
|
|
Plus,
|
|
MoreHorizontal,
|
|
Terminal,
|
|
Trash2,
|
|
Edit,
|
|
Download,
|
|
Upload,
|
|
Server as ServerIcon,
|
|
Cloud,
|
|
FileSpreadsheet,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { serverApi } from '@/services/api';
|
|
import { ServerStatsCards } from './ServerStatsCards';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { toast } from 'sonner';
|
|
import type { Server as ServerType } from '@/types';
|
|
import { CloudServerImport } from './CloudServerImport';
|
|
import { TerminalDialog } from './TerminalDialog';
|
|
import { ExcelServerImport } from './ExcelServerImport';
|
|
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
|
|
|
export function ServerManager() {
|
|
const confirm = useConfirm();
|
|
const [servers, setServers] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
|
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
|
|
|
// 创建/编辑表单状态
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [formData, setFormData] = useState<ServerFormData>(EMPTY_FORM);
|
|
const [formSaving, setFormSaving] = useState(false);
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
|
|
// 终端 Dialog 状态
|
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
|
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null);
|
|
|
|
const openTerminal = (server: any) => {
|
|
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
|
|
setTerminalOpen(true);
|
|
};
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res: any = await serverApi.getServers();
|
|
setServers(res?.list || []);
|
|
} catch (e) {
|
|
console.error('加载服务器列表失败:', e);
|
|
setServers([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
// 解析标签:后端 tags 可能是 JSON 字符串、逗号分隔字符串或数组
|
|
const getTags = (server: any): string[] => {
|
|
const tags = server.tags;
|
|
if (!tags) return [];
|
|
if (Array.isArray(tags)) return tags;
|
|
try {
|
|
const parsed = JSON.parse(tags);
|
|
return Array.isArray(parsed) ? parsed : [String(tags)];
|
|
} catch {
|
|
return String(tags)
|
|
.split(',')
|
|
.map((t: string) => t.trim())
|
|
.filter(Boolean);
|
|
}
|
|
};
|
|
|
|
const filteredServers = servers.filter((server) => {
|
|
const matchesSearch =
|
|
(server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(server.ip || '').includes(searchQuery) ||
|
|
(server.public_ip || '').includes(searchQuery);
|
|
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedServers.length === filteredServers.length) {
|
|
setSelectedServers([]);
|
|
} else {
|
|
setSelectedServers(filteredServers.map((s) => s.id));
|
|
}
|
|
};
|
|
|
|
const toggleSelect = (id: string) => {
|
|
if (selectedServers.includes(id)) {
|
|
setSelectedServers(selectedServers.filter((s) => s !== id));
|
|
} else {
|
|
setSelectedServers([...selectedServers, id]);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
const server = servers.find((s) => String(s.id) === String(id));
|
|
const ok = await confirm({
|
|
title: '删除服务器',
|
|
description: `确定删除「${server?.hostname || id}」吗?此操作不可撤销。`,
|
|
confirmText: '删除',
|
|
variant: 'destructive',
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await serverApi.deleteServer(id);
|
|
toast.success('服务器已删除');
|
|
loadData();
|
|
} catch (e) {
|
|
console.error('删除服务器失败:', e);
|
|
toast.error('删除失败');
|
|
}
|
|
};
|
|
|
|
// 批量删除
|
|
const handleBatchDelete = async () => {
|
|
if (selectedServers.length === 0) return;
|
|
const ok = await confirm({
|
|
title: '批量删除服务器',
|
|
description: `确定删除选中的 ${selectedServers.length} 台服务器吗?此操作不可撤销。`,
|
|
confirmText: '批量删除',
|
|
variant: 'destructive',
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await serverApi.batchDelete(selectedServers.map((id) => Number(id)));
|
|
toast.success(`已删除 ${selectedServers.length} 台服务器`);
|
|
setSelectedServers([]);
|
|
loadData();
|
|
} catch (e) {
|
|
console.error('批量删除失败:', e);
|
|
toast.error('批量删除失败');
|
|
}
|
|
};
|
|
|
|
// 打开创建表单
|
|
const openCreateForm = () => {
|
|
setEditingId(null);
|
|
setFormData(EMPTY_FORM);
|
|
setDetailsOpen(false);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
// 打开编辑表单
|
|
const openEditForm = (server: any) => {
|
|
setEditingId(server.id);
|
|
setFormData({
|
|
hostname: server.hostname || '',
|
|
ip: server.ip || '',
|
|
public_ip: server.public_ip || '',
|
|
port: String(server.port || 22),
|
|
username: server.username || 'root',
|
|
auth_type: server.auth_type || 'password',
|
|
password: '',
|
|
os: server.os || '',
|
|
cpu: String(server.cpu || ''),
|
|
memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB
|
|
disk: String(server.disk || ''),
|
|
status: server.status || 'running',
|
|
tags: server.tags || '',
|
|
description: server.description || '',
|
|
});
|
|
setDetailsOpen(false);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
// 保存表单
|
|
const handleSave = async () => {
|
|
if (!formData.hostname || !formData.ip) return;
|
|
try {
|
|
setFormSaving(true);
|
|
const payload: Record<string, any> = {
|
|
hostname: formData.hostname,
|
|
ip: formData.ip,
|
|
public_ip: formData.public_ip || undefined,
|
|
port: Number(formData.port) || 22,
|
|
username: formData.username || 'root',
|
|
auth_type: formData.auth_type || 'password',
|
|
os: formData.os,
|
|
cpu: Number(formData.cpu) || 0,
|
|
memory: (Number(formData.memory) || 0) * 1024, // GB → MB
|
|
disk: Number(formData.disk) || 0,
|
|
status: formData.status,
|
|
tags: formData.tags || undefined,
|
|
description: formData.description || undefined,
|
|
};
|
|
// 密码仅在填写时提交
|
|
if (formData.password) {
|
|
payload.password = formData.password;
|
|
}
|
|
if (editingId) {
|
|
await serverApi.updateServer(editingId, payload);
|
|
} else {
|
|
await serverApi.createServer(payload);
|
|
}
|
|
setFormOpen(false);
|
|
loadData();
|
|
} catch (e) {
|
|
console.error('保存服务器失败:', e);
|
|
} finally {
|
|
setFormSaving(false);
|
|
}
|
|
};
|
|
|
|
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
|
|
const handleExport = () => {
|
|
if (filteredServers.length === 0) {
|
|
toast.error('没有可导出的数据');
|
|
return;
|
|
}
|
|
const headers = ['主机名', 'IP地址', '公网IP', '端口', '操作系统', 'CPU(核)', '内存(GB)', '磁盘(GB)', '状态', '标签'];
|
|
const rows = filteredServers.map((s) => {
|
|
const memGB = s.memory ? Math.round(s.memory / 1024) : 0;
|
|
const tags = getTags(s).join(';');
|
|
return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '',
|
|
s.cpu || 0, memGB, s.disk || 0, s.status || '', tags];
|
|
});
|
|
const csv = [headers, ...rows]
|
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
.join('\n');
|
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
toast.success(`已导出 ${filteredServers.length} 台服务器`);
|
|
};
|
|
|
|
const getStatusBadge = (status: ServerType['status']) => {
|
|
switch (status) {
|
|
case 'running':
|
|
return (
|
|
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
|
|
运行中
|
|
</Badge>
|
|
);
|
|
case 'stopped':
|
|
return (
|
|
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)]">
|
|
已停止
|
|
</Badge>
|
|
);
|
|
case 'maintenance':
|
|
return (
|
|
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mr-1.5" />
|
|
维护中
|
|
</Badge>
|
|
);
|
|
case 'offline':
|
|
return (
|
|
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
|
离线
|
|
</Badge>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const colSpanCount = 8;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Page Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">服务器管理</h1>
|
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理云平台服务器资产</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
|
onClick={loadData}
|
|
>
|
|
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
|
刷新
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
|
onClick={handleExport}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
导出
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
批量导入
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
|
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
|
|
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
|
<DropdownMenuItem
|
|
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
|
onClick={() => setCloudImportOpen(true)}
|
|
>
|
|
<Cloud className="h-4 w-4 mr-2 text-[var(--dg-blue)]" />
|
|
云主机导入
|
|
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">从云平台同步</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
|
onClick={() => setExcelImportOpen(true)}
|
|
>
|
|
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
|
|
Excel导入
|
|
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">批量导入服务器</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
|
onClick={openCreateForm}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
添加服务器
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<ServerStatsCards servers={servers} />
|
|
|
|
{/* Filters & Table */}
|
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
|
<CardContent className="p-0">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between flex-wrap gap-4 p-4">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<div className="relative w-64">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
|
<Input
|
|
placeholder="搜索主机名或IP..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
|
/>
|
|
</div>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
|
<SelectValue placeholder="状态" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
|
<SelectItem value="all">全部状态</SelectItem>
|
|
<SelectItem value="running">运行中</SelectItem>
|
|
<SelectItem value="stopped">已停止</SelectItem>
|
|
<SelectItem value="maintenance">维护中</SelectItem>
|
|
<SelectItem value="offline">离线</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{selectedServers.length > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
|
onClick={handleBatchDelete}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
批量删除
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-[var(--dg-border)]">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="w-12">
|
|
<Checkbox
|
|
checked={
|
|
selectedServers.length === filteredServers.length && filteredServers.length > 0
|
|
}
|
|
onCheckedChange={toggleSelectAll}
|
|
className="border-[var(--dg-border)]"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">公网IP</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow className="border-[var(--dg-border)]">
|
|
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
|
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
|
加载中...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : filteredServers.length === 0 ? (
|
|
<TableRow className="border-[var(--dg-border)]">
|
|
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
|
暂无服务器数据
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredServers.map((server) => {
|
|
const cpuUsage = Number(server.cpu_usage || 0);
|
|
const memoryUsage = Number(server.memory_usage || 0);
|
|
const tags = getTags(server);
|
|
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
|
|
return (
|
|
<TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selectedServers.includes(server.id)}
|
|
onCheckedChange={() => toggleSelect(server.id)}
|
|
className="border-[var(--dg-border)]"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
|
<div>
|
|
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
|
|
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
|
|
{server.ip}
|
|
</code>
|
|
</TableCell>
|
|
<TableCell>
|
|
{server.public_ip ? (
|
|
<code className="text-sm text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10 px-2 py-0.5 rounded">
|
|
{server.public_ip}
|
|
</code>
|
|
) : (
|
|
<span className="text-sm text-[var(--dg-text-muted)]">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(server.status)}</TableCell>
|
|
<TableCell>
|
|
<div className="text-sm text-[var(--dg-text-secondary)]">
|
|
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
|
|
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
|
|
<span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
|
|
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
|
|
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
|
|
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full',
|
|
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
|
)}
|
|
style={{ width: `${cpuUsage}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
|
|
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full',
|
|
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
|
)}
|
|
style={{ width: `${memoryUsage}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{tags.slice(0, 3).map((tag) => (
|
|
<Badge
|
|
key={tag}
|
|
variant="outline"
|
|
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]/50"
|
|
>
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
{tags.length > 3 && (
|
|
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
|
+{tags.length - 3}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => openTerminal(server)}>
|
|
<Terminal className="h-4 w-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
|
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
|
|
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
|
<DropdownMenuItem
|
|
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
|
onClick={() => openEditForm(server)}
|
|
>
|
|
<Edit className="h-4 w-4 mr-2" />
|
|
编辑
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
|
onClick={() => openTerminal(server)}
|
|
>
|
|
<Terminal className="h-4 w-4 mr-2" />
|
|
终端
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
|
<DropdownMenuItem
|
|
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
|
|
onClick={() => handleDelete(server.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
删除
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 创建/编辑服务器 Dialog */}
|
|
<ServerForm
|
|
open={formOpen}
|
|
onOpenChange={setFormOpen}
|
|
editingId={editingId}
|
|
formData={formData}
|
|
setFormData={setFormData}
|
|
detailsOpen={detailsOpen}
|
|
setDetailsOpen={setDetailsOpen}
|
|
onSave={handleSave}
|
|
formSaving={formSaving}
|
|
/>
|
|
|
|
{/* Import Dialogs */}
|
|
<CloudServerImport
|
|
open={cloudImportOpen}
|
|
onClose={() => {
|
|
setCloudImportOpen(false);
|
|
loadData();
|
|
}}
|
|
/>
|
|
<ExcelServerImport
|
|
open={excelImportOpen}
|
|
onClose={() => {
|
|
setExcelImportOpen(false);
|
|
loadData();
|
|
}}
|
|
/>
|
|
|
|
{/* 终端 Dialog */}
|
|
<TerminalDialog
|
|
open={terminalOpen}
|
|
onOpenChange={setTerminalOpen}
|
|
serverId={terminalServer?.id ?? null}
|
|
serverName={terminalServer?.name ?? ''}
|
|
serverIp={terminalServer?.ip ?? ''}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|