- 详情抽屉打开终端/RDP/文件浏览前先关闭抽屉,修复同 z-index 遮挡冲突 - 删除语义不通的黄灯按钮,绿灯随全屏状态切换 aria-label - 连接指示点补充 role=status 与 sr-only 文案,不再仅靠颜色区分 - LiveMonitorDialog 接入 i18n;修复 TerminalDialog 乱码注释;全屏文案迁至 common 命名空间
697 lines
23 KiB
TypeScript
697 lines
23 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
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,
|
|
Trash2,
|
|
Download,
|
|
Upload,
|
|
Cloud,
|
|
FileSpreadsheet,
|
|
RefreshCw,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import { useServers, useServerMutations } from "@/hooks/api";
|
|
import { ServerStatsStrip } from "./ServerStatsStrip";
|
|
import { useConfirm } from "@/components/ui/confirm-dialog";
|
|
import { toast } from "sonner";
|
|
import { CloudServerImport } from "./CloudServerImport";
|
|
import { TerminalDialog } from "./TerminalDialog";
|
|
import { RdpDialog } from "./RdpDialog";
|
|
import { ServerFileBrowser } from "./ServerFileBrowser";
|
|
import type { JsonData } from "@/types/common";
|
|
import { ExcelServerImport } from "./ExcelServerImport";
|
|
import { ServerForm, EMPTY_FORM, type ServerFormData } from "./ServerForm";
|
|
import { getTags } from "./serverHelpers";
|
|
import { ServerTableRow } from "./ServerTableRow";
|
|
import { ServerDetailSheet } from "./ServerDetailSheet";
|
|
import { usePermission } from "@/hooks/usePermission";
|
|
import { PageShell } from "@/components/layout/PageShell";
|
|
import { ContentPanel } from "@/components/layout/ContentPanel";
|
|
import { Server } from "lucide-react";
|
|
|
|
export function ServerManager() {
|
|
const { t } = useTranslation();
|
|
const confirm = useConfirm();
|
|
const { hasPermission } = usePermission();
|
|
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;
|
|
username?: string;
|
|
hasAuth?: boolean;
|
|
} | null>(null);
|
|
const [rdpOpen, setRdpOpen] = useState(false);
|
|
const [rdpServer, setRdpServer] = useState<{
|
|
id: number;
|
|
name: string;
|
|
ip: string;
|
|
} | null>(null);
|
|
|
|
// 文件浏览 Dialog 状态
|
|
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
|
const [fileBrowserServer, setFileBrowserServer] = useState<{
|
|
id: number;
|
|
name: string;
|
|
} | null>(null);
|
|
const [detailServer, setDetailServer] = useState<JsonData | null>(null);
|
|
|
|
// 分页状态
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(10);
|
|
|
|
const queryParams = useMemo(() => {
|
|
const params: Record<string, unknown> = { page, page_size: pageSize };
|
|
if (searchQuery) params.keyword = searchQuery;
|
|
if (statusFilter && statusFilter !== "all") params.status = statusFilter;
|
|
return params;
|
|
}, [page, pageSize, searchQuery, statusFilter]);
|
|
|
|
const {
|
|
data,
|
|
isLoading: loading,
|
|
isFetching,
|
|
refetch,
|
|
} = useServers(queryParams);
|
|
const {
|
|
createMutation,
|
|
updateMutation,
|
|
deleteMutation,
|
|
batchDeleteMutation,
|
|
} = useServerMutations();
|
|
const servers = data?.list ?? [];
|
|
const total = data?.total ?? 0;
|
|
|
|
const getServerDisplayName = (server: JsonData) =>
|
|
server.alias || server.cloud_instance_name || server.hostname || server.ip || "";
|
|
|
|
// 详情抽屉内打开终端/RDP/文件时先关闭抽屉,避免同 z-index 的 Sheet 遮挡弹窗 // Logan.Li
|
|
const closeDetailSheet = () => setDetailServer(null);
|
|
|
|
const openTerminal = (server: JsonData) => {
|
|
closeDetailSheet();
|
|
setTerminalServer({
|
|
id: server.id,
|
|
name: getServerDisplayName(server),
|
|
ip: server.ip,
|
|
username: server.username || "root",
|
|
hasAuth: server.has_auth ?? true,
|
|
});
|
|
setTerminalOpen(true);
|
|
};
|
|
|
|
const openRdp = (server: JsonData) => {
|
|
closeDetailSheet();
|
|
setRdpServer({ id: server.id, name: getServerDisplayName(server), ip: server.ip });
|
|
setRdpOpen(true);
|
|
};
|
|
|
|
const openFileBrowser = (server: JsonData) => {
|
|
closeDetailSheet();
|
|
setFileBrowserServer({ id: server.id, name: getServerDisplayName(server) });
|
|
setFileBrowserOpen(true);
|
|
};
|
|
|
|
// 搜索/筛选变化时重置到第1页
|
|
const handleSearch = (val: string) => {
|
|
setSearchQuery(val);
|
|
setPage(1);
|
|
};
|
|
const handleStatusFilter = (val: string) => {
|
|
setStatusFilter(val);
|
|
setPage(1);
|
|
};
|
|
|
|
// 导出 CSV
|
|
const toggleSelectAll = () => {
|
|
if (selectedServers.length === servers.length) {
|
|
setSelectedServers([]);
|
|
} else {
|
|
setSelectedServers(servers.map((s) => String(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 name = server ? getServerDisplayName(server) : id;
|
|
const ok = await confirm({
|
|
title: t("server.deleteTitle"),
|
|
description: t("server.deleteConfirm", {
|
|
name,
|
|
ip: server?.ip || "-",
|
|
}),
|
|
confirmText: t("common.delete"),
|
|
variant: "destructive",
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await deleteMutation.mutateAsync(id);
|
|
toast.success(t("server.deleted"));
|
|
} catch (e) {
|
|
console.error("[ServerManager] delete failed:", e);
|
|
toast.error(t("server.deleteFailed"));
|
|
}
|
|
};
|
|
|
|
// 批量删除
|
|
const handleBatchDelete = async () => {
|
|
if (selectedServers.length === 0) return;
|
|
const ok = await confirm({
|
|
title: t("server.batchDeleteTitle"),
|
|
description: t("server.batchDeleteConfirm", {
|
|
count: selectedServers.length,
|
|
}),
|
|
confirmText: t("server.batchDelete"),
|
|
variant: "destructive",
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await batchDeleteMutation.mutateAsync(
|
|
selectedServers.map((id) => Number(id)),
|
|
);
|
|
toast.success(
|
|
t("server.deletedCount", { count: selectedServers.length }),
|
|
);
|
|
setSelectedServers([]);
|
|
} catch (e) {
|
|
console.error("[ServerManager] batch delete failed:", e);
|
|
toast.error(t("server.batchDeleteFailed"));
|
|
}
|
|
};
|
|
|
|
// 打开创建表单
|
|
const openCreateForm = () => {
|
|
setEditingId(null);
|
|
setFormData(EMPTY_FORM);
|
|
setDetailsOpen(false);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
// 打开编辑表单
|
|
const openEditForm = (server: JsonData) => {
|
|
setEditingId(server.id);
|
|
setFormData({
|
|
hostname: server.hostname || "",
|
|
alias: server.alias || "",
|
|
ip: server.ip || "",
|
|
public_ip: server.public_ip || "",
|
|
port: String(server.port || 22),
|
|
username: server.username || "root",
|
|
auth_type: server.auth_type || "password",
|
|
os_type:
|
|
server.os_type || (server.protocol === "rdp" ? "windows" : "linux"),
|
|
protocol:
|
|
server.protocol || (server.os_type === "windows" ? "rdp" : "ssh"),
|
|
rdp_port: String(server.rdp_port || 3389),
|
|
password: "",
|
|
ssh_key: "",
|
|
credential_id: null as number | null,
|
|
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, unknown> = {
|
|
hostname: formData.hostname,
|
|
alias: formData.alias,
|
|
ip: formData.ip,
|
|
public_ip: formData.public_ip || undefined,
|
|
port: Number(formData.port) || 22,
|
|
os_type: formData.os_type || "linux",
|
|
protocol: formData.protocol || "ssh",
|
|
rdp_port: Number(formData.rdp_port) || 3389,
|
|
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.credential_id) {
|
|
payload.credential_id = formData.credential_id;
|
|
} else {
|
|
// 手动输入模式:仅填了密码才提交
|
|
if (formData.password) {
|
|
payload.password = formData.password;
|
|
}
|
|
if (formData.ssh_key) {
|
|
payload.ssh_key = formData.ssh_key;
|
|
}
|
|
}
|
|
if (editingId) {
|
|
await updateMutation.mutateAsync({ id: editingId, data: payload });
|
|
} else {
|
|
await createMutation.mutateAsync(payload);
|
|
}
|
|
toast.success(editingId ? t("server.updated") : t("server.created"));
|
|
setFormOpen(false);
|
|
} catch (e) {
|
|
console.error("[ServerManager] save failed:", e);
|
|
toast.error(t("server.saveFailed"));
|
|
} finally {
|
|
setFormSaving(false);
|
|
}
|
|
};
|
|
|
|
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
|
|
const handleExport = () => {
|
|
if (servers.length === 0) {
|
|
toast.error(t("server.noExportData"));
|
|
return;
|
|
}
|
|
const headers = [
|
|
t("server.csv.name"),
|
|
t("server.csv.hostname"),
|
|
t("server.csv.ip"),
|
|
t("server.csv.publicIp"),
|
|
t("server.csv.port"),
|
|
t("server.csv.os"),
|
|
t("server.csv.cpu"),
|
|
t("server.csv.memory"),
|
|
t("server.csv.disk"),
|
|
t("server.csv.status"),
|
|
t("server.csv.tags"),
|
|
];
|
|
const rows = servers.map((s) => {
|
|
const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
|
|
const tags = getTags(s).join(";");
|
|
return [
|
|
getServerDisplayName(s),
|
|
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(t("server.exported", { count: servers.length }));
|
|
};
|
|
|
|
const colSpanCount = 8;
|
|
|
|
const tableToolbar = (
|
|
<>
|
|
<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 dg-text-muted" />
|
|
<Input
|
|
placeholder={t("server.searchPlaceholder")}
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="pl-9 h-9 dg-input"
|
|
/>
|
|
</div>
|
|
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
|
<SelectTrigger className="w-32 h-9 dg-input">
|
|
<SelectValue placeholder={t("common.status")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t("server.allStatus")}</SelectItem>
|
|
<SelectItem value="running">{t("common.running")}</SelectItem>
|
|
<SelectItem value="stopped">{t("common.stopped")}</SelectItem>
|
|
<SelectItem value="maintenance">
|
|
{t("common.maintenance")}
|
|
</SelectItem>
|
|
<SelectItem value="offline">{t("common.offline")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{selectedServers.length > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm dg-text-secondary">
|
|
{t("server.selected", { count: selectedServers.length })}
|
|
</span>
|
|
{hasPermission("server:delete") && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="dg-btn-danger dg-btn-sm"
|
|
onClick={handleBatchDelete}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
{t("server.batchDelete")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={t("server.title")}
|
|
subtitle={t("server.subtitle")}
|
|
icon={<Server className="h-5 w-5" />}
|
|
metrics={<ServerStatsStrip servers={servers} />}
|
|
actions={
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
className="dg-btn-secondary"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw
|
|
className={cn("h-4 w-4 mr-2", isFetching && "animate-spin")}
|
|
/>
|
|
{t("common.refresh")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="dg-btn-secondary"
|
|
onClick={handleExport}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
{t("common.export")}
|
|
</Button>
|
|
{hasPermission("server:create") && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="dg-btn-secondary">
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
{t("server.batchImport")}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuLabel>
|
|
{t("server.importMethod")}
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => setCloudImportOpen(true)}>
|
|
<Cloud className="h-4 w-4 mr-2 dg-text-blue" />
|
|
{t("server.cloudImport")}
|
|
<span className="ml-2 text-xs dg-text-muted">
|
|
{t("server.cloudImportHint")}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setExcelImportOpen(true)}>
|
|
<FileSpreadsheet className="h-4 w-4 mr-2 dg-text-green" />
|
|
{t("server.csvImport")}
|
|
<span className="ml-2 text-xs dg-text-muted">
|
|
{t("server.csvImportHint")}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
{hasPermission("server:create") && (
|
|
<Button className="dg-btn-primary" onClick={openCreateForm}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{t("server.addServer")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<ContentPanel toolbar={tableToolbar}>
|
|
<div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="w-12">
|
|
<Checkbox
|
|
checked={
|
|
selectedServers.length === servers.length &&
|
|
servers.length > 0
|
|
}
|
|
onCheckedChange={toggleSelectAll}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">
|
|
{t("server.assetName")}
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">
|
|
{t("server.ipAddress")}
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">
|
|
{t("server.publicIp")}
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">
|
|
{t("common.status")}
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">
|
|
{t("server.config")}
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">
|
|
{t("server.tags")}
|
|
</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)] text-right">
|
|
{t("common.actions")}
|
|
</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" />
|
|
{t("common.loading")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : servers.length === 0 ? (
|
|
<TableRow className="border-[var(--dg-border)]">
|
|
<TableCell
|
|
colSpan={colSpanCount}
|
|
className="text-center text-[var(--dg-text-muted)] py-12"
|
|
>
|
|
{t("server.noData")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
servers.map((server) => (
|
|
<ServerTableRow
|
|
key={server.id}
|
|
server={server}
|
|
selected={selectedServers.includes(String(server.id))}
|
|
onToggleSelect={toggleSelect}
|
|
onOpenTerminal={openTerminal}
|
|
onOpenRdp={openRdp}
|
|
onOpenFiles={openFileBrowser}
|
|
onOpenEdit={openEditForm}
|
|
onOpenDetail={setDetailServer}
|
|
onDelete={handleDelete}
|
|
/>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* 分页 */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
|
<span>{t("server.perPage")}</span>
|
|
<Select
|
|
value={String(pageSize)}
|
|
onValueChange={(v) => {
|
|
setPageSize(Number(v));
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
|
<SelectItem value="10">10</SelectItem>
|
|
<SelectItem value="20">20</SelectItem>
|
|
<SelectItem value="30">30</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<span>{t("server.items")}</span>
|
|
<span className="ml-4">
|
|
{t("server.totalItems", { count: total })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
|
{page} / {Math.max(1, Math.ceil(total / pageSize))}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
|
disabled={page >= Math.ceil(total / pageSize)}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</ContentPanel>
|
|
|
|
{/* 创建/编辑服务器 Dialog */}
|
|
<ServerForm
|
|
open={formOpen}
|
|
onOpenChange={setFormOpen}
|
|
editingId={editingId}
|
|
formData={formData}
|
|
setFormData={setFormData}
|
|
detailsOpen={detailsOpen}
|
|
setDetailsOpen={setDetailsOpen}
|
|
onSave={handleSave}
|
|
formSaving={formSaving}
|
|
/>
|
|
<RdpDialog
|
|
open={rdpOpen}
|
|
onOpenChange={setRdpOpen}
|
|
serverId={rdpServer?.id ?? null}
|
|
serverName={rdpServer?.name || ""}
|
|
serverIp={rdpServer?.ip || ""}
|
|
/>
|
|
|
|
{/* Import Dialogs */}
|
|
<CloudServerImport
|
|
open={cloudImportOpen}
|
|
onClose={() => {
|
|
setCloudImportOpen(false);
|
|
refetch();
|
|
}}
|
|
/>
|
|
<ExcelServerImport
|
|
open={excelImportOpen}
|
|
onClose={() => {
|
|
setExcelImportOpen(false);
|
|
refetch();
|
|
}}
|
|
/>
|
|
|
|
{/* 终端 Dialog */}
|
|
<TerminalDialog
|
|
open={terminalOpen}
|
|
onOpenChange={setTerminalOpen}
|
|
serverId={terminalServer?.id ?? null}
|
|
serverName={terminalServer?.name ?? ""}
|
|
serverIp={terminalServer?.ip ?? ""}
|
|
username={terminalServer?.username || "root"}
|
|
hasAuth={terminalServer?.hasAuth ?? true}
|
|
/>
|
|
|
|
<ServerDetailSheet
|
|
open={!!detailServer}
|
|
onOpenChange={(v) => {
|
|
if (!v) setDetailServer(null);
|
|
}}
|
|
server={
|
|
detailServer
|
|
? servers.find((s) => String(s.id) === String(detailServer.id)) || detailServer
|
|
: null
|
|
}
|
|
onOpenTerminal={openTerminal}
|
|
onOpenRdp={openRdp}
|
|
onOpenFiles={openFileBrowser}
|
|
onOpenEdit={(s) => {
|
|
setDetailServer(null);
|
|
openEditForm(s);
|
|
}}
|
|
/>
|
|
|
|
{/* 文件浏览 Dialog */}
|
|
<ServerFileBrowser
|
|
open={fileBrowserOpen}
|
|
onOpenChange={setFileBrowserOpen}
|
|
serverId={fileBrowserServer?.id ?? null}
|
|
serverName={fileBrowserServer?.name ?? ""}
|
|
/>
|
|
</PageShell>
|
|
);
|
|
}
|