import { useState, useEffect, useMemo, useRef } from "react"; import { Card, CardBody, CardHeader } from "@heroui/card"; import { Button } from "@heroui/button"; import { Input } from "@heroui/input"; import { Textarea } from "@heroui/input"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, } from "@heroui/modal"; import { Chip } from "@heroui/chip"; import { Switch } from "@heroui/switch"; import { Spinner } from "@heroui/spinner"; import { Alert } from "@heroui/alert"; import { Progress } from "@heroui/progress"; import { Accordion, AccordionItem } from "@heroui/accordion"; import { Select, SelectItem } from "@heroui/select"; import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import axios from "axios"; import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, type DragEndEvent, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { createNode, getNodeList, updateNode, deleteNode, getNodeInstallCommand, updateNodeOrder, batchDeleteNodes, upgradeNode, batchUpgradeNodes, getNodeReleases, rollbackNode, } from "@/api"; interface Node { id: number; inx?: number; name: string; ip: string; serverIp: string; serverIpV4?: string; serverIpV6?: string; port: string; tcpListenAddr?: string; udpListenAddr?: string; version?: string; http?: number; // 0 关 1 开 tls?: number; // 0 关 1 开 socks?: number; // 0 关 1 开 status: number; isRemote?: number; remoteUrl?: string; syncError?: string; connectionStatus: "online" | "offline"; systemInfo?: { cpuUsage: number; memoryUsage: number; uploadTraffic: number; downloadTraffic: number; uploadSpeed: number; downloadSpeed: number; uptime: number; } | null; copyLoading?: boolean; upgradeLoading?: boolean; rollbackLoading?: boolean; } interface NodeForm { id: number | null; name: string; serverHost: string; serverIpV4: string; serverIpV6: string; port: string; tcpListenAddr: string; udpListenAddr: string; interfaceName: string; http: number; // 0 关 1 开 tls: number; // 0 关 1 开 socks: number; // 0 关 1 开 } const SortableItem = ({ id, children, }: { id: number; children: (listeners: any) => any; }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const style = { transform: transform ? CSS.Transform.toString(transform) : undefined, transition: isDragging ? undefined : transition || undefined, opacity: isDragging ? 0.5 : 1, willChange: "transform", }; return (
{children(listeners)}
); }; export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); const [wsConnected, setWsConnected] = useState(false); const [wsConnecting, setWsConnecting] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); const [isEdit, setIsEdit] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [nodeToDelete, setNodeToDelete] = useState(null); const [protocolDisabled, setProtocolDisabled] = useState(false); const [protocolDisabledReason, setProtocolDisabledReason] = useState(""); const [form, setForm] = useState({ id: null, name: "", serverHost: "", serverIpV4: "", serverIpV6: "", port: "1000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", http: 0, tls: 0, socks: 0, }); const [errors, setErrors] = useState>({}); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); const [batchLoading, setBatchLoading] = useState(false); // 安装命令相关状态 const [installCommandModal, setInstallCommandModal] = useState(false); const [installCommand, setInstallCommand] = useState(""); const [currentNodeName, setCurrentNodeName] = useState(""); // 升级相关状态 const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">( "single", ); const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState( null, ); const [releases, setReleases] = useState< Array<{ version: string; name: string; publishedAt: string; prerelease: boolean; }> >([]); const [releasesLoading, setReleasesLoading] = useState(false); const [selectedVersion, setSelectedVersion] = useState(""); const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); const [upgradeProgress, setUpgradeProgress] = useState< Record >({}); const websocketRef = useRef(null); const reconnectTimerRef = useRef(null); const reconnectAttemptsRef = useRef(0); const maxReconnectAttempts = 5; const offlineTimersRef = useRef>>( new Map(), ); const offlineDelayMs = 3000; const clearOfflineTimer = (nodeId: number) => { const timer = offlineTimersRef.current.get(nodeId); if (timer) { clearTimeout(timer); offlineTimersRef.current.delete(nodeId); } }; const scheduleNodeOffline = (nodeId: number) => { if (offlineTimersRef.current.has(nodeId)) return; const timer = setTimeout(() => { offlineTimersRef.current.delete(nodeId); setNodeList((prev) => prev.map((node) => { if (node.id !== nodeId) return node; if (node.connectionStatus === "offline" && node.systemInfo === null) return node; return { ...node, connectionStatus: "offline", systemInfo: null }; }), ); }, offlineDelayMs); offlineTimersRef.current.set(nodeId, timer); }; useEffect(() => { loadNodes(); initWebSocket(); return () => { closeWebSocket(); }; }, []); // 加载节点列表 const loadNodes = async () => { setLoading(true); try { const res = await getNodeList(); if (res.code === 0) { const nodesData: Node[] = (res.data || []).map((node: any) => ({ ...node, inx: node.inx ?? 0, connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline", syncError: node.syncError || undefined, systemInfo: null, copyLoading: false, })); setNodeList(nodesData); // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序 const hasDbOrdering = nodesData.some( (n) => n.inx !== undefined && n.inx !== 0, ); if (hasDbOrdering) { const dbOrder = [...nodesData] .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) .map((n) => n.id); setNodeOrder(dbOrder); } else { try { const stored = localStorage.getItem("node-order"); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { const existingIds = new Set(nodesData.map((n) => n.id)); const validOrder = parsed .map((id: any) => Number(id)) .filter((id: number) => existingIds.has(id)); if (validOrder.length > 0) { setNodeOrder(validOrder); } else { setNodeOrder(nodesData.map((n) => n.id)); } } else { setNodeOrder(nodesData.map((n) => n.id)); } } else { setNodeOrder(nodesData.map((n) => n.id)); } } catch { setNodeOrder(nodesData.map((n) => n.id)); } } } else { toast.error(res.msg || "加载节点列表失败"); } } catch { toast.error("网络错误,请重试"); } finally { setLoading(false); } }; // 初始化WebSocket连接 const initWebSocket = () => { if ( websocketRef.current && (websocketRef.current.readyState === WebSocket.OPEN || websocketRef.current.readyState === WebSocket.CONNECTING) ) { return; } if (websocketRef.current) { closeWebSocket(); } // 构建WebSocket URL,使用axios的baseURL const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : "/api/v1/"); const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") + `/system-info?type=0&secret=${localStorage.getItem("token")}`; try { setWsConnecting(true); websocketRef.current = new WebSocket(wsUrl); websocketRef.current.onopen = () => { reconnectAttemptsRef.current = 0; setWsConnected(true); setWsConnecting(false); }; websocketRef.current.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch { // 解析失败时不输出错误信息 } }; websocketRef.current.onerror = () => { // WebSocket错误时不输出错误信息 }; websocketRef.current.onclose = () => { websocketRef.current = null; setWsConnected(false); setWsConnecting(false); attemptReconnect(); }; } catch { setWsConnected(false); setWsConnecting(false); attemptReconnect(); } }; // 处理WebSocket消息 const handleWebSocketMessage = (data: any) => { const { id, type, data: messageData } = data; const nodeId = Number(id); if (Number.isNaN(nodeId)) return; if (type === "status") { if (messageData === 1) { clearOfflineTimer(nodeId); setNodeList((prev) => prev.map((node) => { if (node.id !== nodeId) return node; if (node.connectionStatus === "online") return node; return { ...node, connectionStatus: "online" }; }), ); } else { // 离线事件做延迟处理,避免短抖动导致频繁闪烁 scheduleNodeOffline(nodeId); } } else if (type === "info") { clearOfflineTimer(nodeId); setNodeList((prev) => prev.map((node) => { if (node.id === nodeId) { try { let systemInfo; if (typeof messageData === "string") { systemInfo = JSON.parse(messageData); } else { systemInfo = messageData; } const currentUpload = parseInt(systemInfo.bytes_transmitted) || 0; const currentDownload = parseInt(systemInfo.bytes_received) || 0; const currentUptime = parseInt(systemInfo.uptime) || 0; let uploadSpeed = 0; let downloadSpeed = 0; if (node.systemInfo && node.systemInfo.uptime) { const timeDiff = currentUptime - node.systemInfo.uptime; if (timeDiff > 0 && timeDiff <= 10) { const lastUpload = node.systemInfo.uploadTraffic || 0; const lastDownload = node.systemInfo.downloadTraffic || 0; const uploadDiff = currentUpload - lastUpload; const downloadDiff = currentDownload - lastDownload; const uploadReset = currentUpload < lastUpload; const downloadReset = currentDownload < lastDownload; if (!uploadReset && uploadDiff >= 0) { uploadSpeed = uploadDiff / timeDiff; } if (!downloadReset && downloadDiff >= 0) { downloadSpeed = downloadDiff / timeDiff; } } } return { ...node, connectionStatus: "online", systemInfo: { cpuUsage: parseFloat(systemInfo.cpu_usage) || 0, memoryUsage: parseFloat(systemInfo.memory_usage) || 0, uploadTraffic: currentUpload, downloadTraffic: currentDownload, uploadSpeed: uploadSpeed, downloadSpeed: downloadSpeed, uptime: currentUptime, }, }; } catch { return node; } } return node; }), ); } else if (type === "upgrade_progress") { try { const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData; if (progressData?.data) { setUpgradeProgress((prev) => ({ ...prev, [nodeId]: { stage: progressData.data.stage || "", percent: progressData.data.percent || 0, message: progressData.message || "", }, })); } } catch { // ignore parse errors } } }; // 尝试重新连接 const attemptReconnect = () => { if (reconnectTimerRef.current) return; if (reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current++; reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = null; initWebSocket(); }, 3000 * reconnectAttemptsRef.current); } }; // 关闭WebSocket连接 const closeWebSocket = () => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } offlineTimersRef.current.forEach((timer) => clearTimeout(timer)); offlineTimersRef.current.clear(); reconnectAttemptsRef.current = 0; setWsConnected(false); setWsConnecting(false); if (websocketRef.current) { websocketRef.current.onopen = null; websocketRef.current.onmessage = null; websocketRef.current.onerror = null; websocketRef.current.onclose = null; if ( websocketRef.current.readyState === WebSocket.OPEN || websocketRef.current.readyState === WebSocket.CONNECTING ) { websocketRef.current.close(); } websocketRef.current = null; } }; // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { if (bytesPerSecond === 0) return "0 B/s"; const k = 1024; const sizes = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s"]; const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k)); return ( parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] ); }; // 格式化开机时间 const formatUptime = (seconds: number): string => { if (seconds === 0) return "-"; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return `${days}天${hours}小时`; } else if (hours > 0) { return `${hours}小时${minutes}分钟`; } else { return `${minutes}分钟`; } }; // 格式化流量 const formatTraffic = (bytes: number): string => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; // 获取进度条颜色 const getProgressColor = ( value: number, offline = false, ): "default" | "primary" | "secondary" | "success" | "warning" | "danger" => { if (offline) return "default"; if (value <= 50) return "success"; if (value <= 80) return "warning"; return "danger"; }; // IPv4/IPv6 格式验证(仅用于判定地址族) const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; const validateIpv4Literal = (ip: string): boolean => ipv4Regex.test(ip.trim()); const validateIpv6Literal = (ip: string): boolean => ipv6Regex.test(ip.trim()); // Hostname/domain validation (no scheme/port) const hostnameRegex = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/; const validateHostname = (host: string): boolean => { const v = host.trim(); if (!v) return false; if (v === "localhost") return true; return hostnameRegex.test(v); }; // 验证端口格式:支持 80,443,100-600 const validatePort = ( portStr: string, ): { valid: boolean; error?: string } => { if (!portStr || !portStr.trim()) { return { valid: false, error: "请输入端口" }; } const trimmed = portStr.trim(); const parts = trimmed .split(",") .map((p) => p.trim()) .filter((p) => p); if (parts.length === 0) { return { valid: false, error: "请输入有效的端口" }; } for (const part of parts) { // 检查是否是端口范围 (如 100-600) if (part.includes("-")) { const range = part.split("-").map((p) => p.trim()); if (range.length !== 2) { return { valid: false, error: `端口范围格式错误: ${part}` }; } const start = parseInt(range[0]); const end = parseInt(range[1]); if (isNaN(start) || isNaN(end)) { return { valid: false, error: `端口必须是数字: ${part}` }; } if (start < 1 || start > 65535 || end < 1 || end > 65535) { return { valid: false, error: `端口范围必须在 1-65535 之间: ${part}`, }; } if (start >= end) { return { valid: false, error: `起始端口必须小于结束端口: ${part}` }; } } else { // 单个端口 const port = parseInt(part); if (isNaN(port)) { return { valid: false, error: `端口必须是数字: ${part}` }; } if (port < 1 || port > 65535) { return { valid: false, error: `端口必须在 1-65535 之间: ${part}` }; } } } return { valid: true }; }; // 表单验证 const validateForm = (): boolean => { const newErrors: Record = {}; if (!form.name.trim()) { newErrors.name = "请输入节点名称"; } else if (form.name.trim().length < 2) { newErrors.name = "节点名称长度至少2位"; } else if (form.name.trim().length > 50) { newErrors.name = "节点名称长度不能超过50位"; } const v4 = form.serverIpV4.trim(); const v6 = form.serverIpV6.trim(); const host = form.serverHost.trim(); if (!v4 && !v6 && !host) { const msg = "请至少填写一个 IPv4/IPv6 地址或域名"; newErrors.serverIpV4 = msg; newErrors.serverIpV6 = msg; newErrors.serverHost = msg; } else { if (v4 && !validateIpv4Literal(v4)) { newErrors.serverIpV4 = "请输入有效的IPv4地址"; } if (v6 && !validateIpv6Literal(v6)) { newErrors.serverIpV6 = "请输入有效的IPv6地址"; } if (host && !validateHostname(host)) { newErrors.serverHost = "请输入有效的域名/主机名"; } } const portValidation = validatePort(form.port); if (!portValidation.valid) { newErrors.port = portValidation.error || "端口格式错误"; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // 新增节点 const handleAdd = () => { setDialogTitle("新增节点"); setIsEdit(false); setDialogVisible(true); resetForm(); setProtocolDisabled(true); setProtocolDisabledReason("节点未在线,等待节点上线后再设置"); }; // 编辑节点 const handleEdit = (node: Node) => { setDialogTitle("编辑节点"); setIsEdit(true); const legacy = (node.serverIp || "").trim(); const normalizedV4 = node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : ""); const normalizedV6 = node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : ""); const normalizedHost = !normalizedV4 && !normalizedV6 && legacy ? legacy : ""; setForm({ id: node.id, name: node.name, serverHost: normalizedHost, serverIpV4: normalizedV4, serverIpV6: normalizedV6, port: node.port || "1000-65535", tcpListenAddr: node.tcpListenAddr || "[::]", udpListenAddr: node.udpListenAddr || "[::]", interfaceName: (node as any).interfaceName || "", http: typeof node.http === "number" ? node.http : 1, tls: typeof node.tls === "number" ? node.tls : 1, socks: typeof node.socks === "number" ? node.socks : 1, }); const offline = node.connectionStatus !== "online"; setProtocolDisabled(offline); setProtocolDisabledReason( offline ? "节点未在线,等待节点上线后再设置" : "", ); setDialogVisible(true); }; // 删除节点 const handleDelete = (node: Node) => { setNodeToDelete(node); setDeleteModalOpen(true); }; const confirmDelete = async () => { if (!nodeToDelete) return; setDeleteLoading(true); try { const res = await deleteNode(nodeToDelete.id); if (res.code === 0) { toast.success("删除成功"); setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id)); setDeleteModalOpen(false); setNodeToDelete(null); } else { toast.error(res.msg || "删除失败"); } } catch { toast.error("网络错误,请重试"); } finally { setDeleteLoading(false); } }; // 复制安装命令 const handleCopyInstallCommand = async (node: Node) => { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)), ); try { const res = await getNodeInstallCommand(node.id); if (res.code === 0 && res.data) { try { await navigator.clipboard.writeText(res.data); toast.success("安装命令已复制到剪贴板"); } catch { // 复制失败,显示安装命令模态框 setInstallCommand(res.data); setCurrentNodeName(node.name); setInstallCommandModal(true); } } else { toast.error(res.msg || "获取安装命令失败"); } } catch { toast.error("获取安装命令失败"); } finally { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)), ); } }; // 手动复制安装命令 const handleManualCopy = async () => { try { await navigator.clipboard.writeText(installCommand); toast.success("安装命令已复制到剪贴板"); setInstallCommandModal(false); } catch { toast.error("复制失败,请手动选择文本复制"); } }; // 打开版本选择弹窗 const openUpgradeModal = async ( target: "single" | "batch", nodeId?: number, ) => { setUpgradeTarget(target); setUpgradeTargetNodeId(nodeId || null); setSelectedVersion(""); setUpgradeModalOpen(true); setReleasesLoading(true); try { const res = await getNodeReleases(); if (res.code === 0 && Array.isArray(res.data)) { setReleases(res.data); } else { toast.error(res.msg || "获取版本列表失败"); } } catch { toast.error("获取版本列表失败"); } finally { setReleasesLoading(false); } }; // 确认升级(从版本弹窗) const handleConfirmUpgrade = async () => { const version = selectedVersion || undefined; if (upgradeTarget === "single" && upgradeTargetNodeId) { setUpgradeModalOpen(false); // Find the node const node = nodeList.find((n) => n.id === upgradeTargetNodeId); if (!node) return; setNodeList((prev) => prev.map((n) => n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n, ), ); try { const res = await upgradeNode(upgradeTargetNodeId, version); if (res.code === 0) { toast.success(`节点升级命令已发送,节点将自动重启`); } else { toast.error(res.msg || "升级失败"); } } catch { toast.error("网络错误,请重试"); } finally { setNodeList((prev) => prev.map((n) => n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n, ), ); } } else if (upgradeTarget === "batch") { setBatchUpgradeLoading(true); setUpgradeModalOpen(false); try { const res = await batchUpgradeNodes(Array.from(selectedIds), version); if (res.code === 0) { toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); } else { toast.error(res.msg || "批量升级失败"); } } catch { toast.error("网络错误,请重试"); } finally { setBatchUpgradeLoading(false); } } }; // 回退节点 const handleRollbackNode = async (node: Node) => { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), ); try { const res = await rollbackNode(node.id); if (res.code === 0) { toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); } else { toast.error(res.msg || "回退失败"); } } catch { toast.error("网络错误,请重试"); } finally { setNodeList((prev) => prev.map((n) => n.id === node.id ? { ...n, rollbackLoading: false } : n, ), ); } }; // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; setSubmitLoading(true); try { const apiCall = isEdit ? updateNode : createNode; const { serverHost, ...rest } = form; const data = { ...rest, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || serverHost?.trim() || "", }; const res = await apiCall(data); if (res.code === 0) { toast.success(isEdit ? "更新成功" : "创建成功"); setDialogVisible(false); if (isEdit) { setNodeList((prev) => prev.map((n) => n.id === form.id ? { ...n, name: form.name, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || "", serverIpV4: form.serverIpV4, serverIpV6: form.serverIpV6, port: form.port, tcpListenAddr: form.tcpListenAddr, udpListenAddr: form.udpListenAddr, interfaceName: form.interfaceName, http: form.http, tls: form.tls, socks: form.socks, } : n, ), ); } else { loadNodes(); } } else { toast.error(res.msg || (isEdit ? "更新失败" : "创建失败")); } } catch { toast.error("网络错误,请重试"); } finally { setSubmitLoading(false); } }; // 重置表单 const resetForm = () => { setForm({ id: null, name: "", serverHost: "", serverIpV4: "", serverIpV6: "", port: "1000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", http: 0, tls: 0, socks: 0, }); setErrors({}); }; // 处理拖拽结束 const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!active || !over || active.id === over.id) return; if (!nodeOrder || nodeOrder.length === 0) return; const activeId = Number(active.id); const overId = Number(over.id); if (isNaN(activeId) || isNaN(overId)) return; const oldIndex = nodeOrder.indexOf(activeId); const newIndex = nodeOrder.indexOf(overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; const newOrder = arrayMove(nodeOrder, oldIndex, newIndex); setNodeOrder(newOrder); // 保存到 localStorage try { localStorage.setItem("node-order", JSON.stringify(newOrder)); } catch {} // 持久化到数据库 try { const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index })); const response = await updateNodeOrder({ nodes: nodesToUpdate }); if (response.code === 0) { setNodeList((prev) => prev.map((node) => { const updated = nodesToUpdate.find((n) => n.id === node.id); return updated ? { ...node, inx: updated.inx } : node; }), ); } else { toast.error("保存排序失败:" + (response.msg || "未知错误")); } } catch { toast.error("保存排序失败,请重试"); } }; // 批量操作处理函数 const toggleSelectMode = () => { setSelectMode((prev) => { if (prev) { setSelectedIds(new Set()); } return !prev; }); }; const toggleSelect = (id: number) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; const selectAll = () => { setSelectedIds(new Set(sortedNodes.map((n) => n.id))); }; const deselectAll = () => { setSelectedIds(new Set()); }; const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const res = await batchDeleteNodes(Array.from(selectedIds)); if (res.code === 0) { toast.success(`成功删除 ${selectedIds.size} 个节点`); setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id))); setSelectedIds(new Set()); setBatchDeleteModalOpen(false); setSelectMode(false); } else { toast.error(res.msg || "删除失败"); } } catch { toast.error("网络错误,请重试"); } finally { setBatchLoading(false); } }; // 传感器配置 const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); // 根据排序顺序获取节点列表 const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; const sortedByDb = [...nodeList].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); // 如果数据库中没有排序信息,则使用本地存储的顺序 if ( nodeOrder && nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0) ) { const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const)); const localSorted: Node[] = []; nodeOrder.forEach((id) => { const node = nodeMap.get(id); if (node) localSorted.push(node); }); nodeList.forEach((node) => { if (!nodeOrder.includes(node.id)) { localSorted.push(node); } }); return localSorted; } return sortedByDb; }, [nodeList, nodeOrder]); const sortableNodeIds = useMemo( () => sortedNodes.map((n) => n.id), [sortedNodes], ); return (
{/* 页面头部 */}
{/* 批量操作浮动工具栏 */} {selectMode && selectedIds.size > 0 && (
已选 {selectedIds.size} 项
)} {!wsConnected && ( )} {/* 节点列表 */} {loading ? (
正在加载...
) : nodeList.length === 0 ? (

暂无节点配置

还没有创建任何节点配置,点击上方按钮开始创建

) : (
{sortedNodes.map((node) => { const isRemoteNode = node.isRemote === 1; return ( {(listeners) => (
{selectMode && ( toggleSelect(node.id)} /> )}

{node.name}

{isRemoteNode && ( 远程 )} {node.connectionStatus === "online" ? "在线" : "离线"}
{isRemoteNode && node.syncError && (
{node.syncError === "provider_share_deleted" ? "提供方已删除该分享" : node.syncError === "provider_share_disabled" ? "提供方已禁用该分享" : node.syncError === "provider_share_expired" ? "提供方分享已过期" : `远程同步失败: ${node.syncError}`}
)} {/* 基础信息 */}
IP
{node.serverIpV4?.trim() || node.serverIpV6?.trim() ? (
{node.serverIpV4?.trim() && ( {node.serverIpV4.trim()} )} {node.serverIpV6?.trim() && ( {node.serverIpV6.trim()} )}
) : ( {node.serverIp.trim()} )}
{!isRemoteNode && ( <>
版本 {node.version || "未知"}
{upgradeProgress[node.id] && upgradeProgress[node.id].percent < 100 && (
)}
开机时间 {node.connectionStatus === "online" && node.systemInfo ? formatUptime(node.systemInfo.uptime) : "-"}
)}
{!isRemoteNode && ( <> {/* 系统监控 */}
CPU {node.connectionStatus === "online" && node.systemInfo ? `${node.systemInfo.cpuUsage.toFixed(1)}%` : "-"}
内存 {node.connectionStatus === "online" && node.systemInfo ? `${node.systemInfo.memoryUsage.toFixed(1)}%` : "-"}
上传
{node.connectionStatus === "online" && node.systemInfo ? formatSpeed( node.systemInfo.uploadSpeed, ) : "-"}
下载
{node.connectionStatus === "online" && node.systemInfo ? formatSpeed( node.systemInfo.downloadSpeed, ) : "-"}
{/* 流量统计 */}
↑ 上行流量
{node.connectionStatus === "online" && node.systemInfo ? formatTraffic( node.systemInfo.uploadTraffic, ) : "-"}
↓ 下行流量
{node.connectionStatus === "online" && node.systemInfo ? formatTraffic( node.systemInfo.downloadTraffic, ) : "-"}
)} {/* 操作按钮 */}
{!isRemoteNode && (
)}
{!isRemoteNode && ( )}
)}
); })}
)} {/* 新增/编辑节点对话框 */} setDialogVisible(false)} > {dialogTitle}
setForm((prev) => ({ ...prev, name: e.target.value })) } /> setForm((prev) => ({ ...prev, serverHost: e.target.value })) } />
setForm((prev) => ({ ...prev, serverIpV4: e.target.value })) } /> setForm((prev) => ({ ...prev, serverIpV6: e.target.value })) } />
setForm((prev) => ({ ...prev, port: e.target.value })) } /> {/* 高级配置 */}
setForm((prev) => ({ ...prev, interfaceName: e.target.value, })) } />
TCP
} value={form.tcpListenAddr} variant="bordered" onChange={(e) => setForm((prev) => ({ ...prev, tcpListenAddr: e.target.value, })) } /> UDP
} value={form.udpListenAddr} variant="bordered" onChange={(e) => setForm((prev) => ({ ...prev, udpListenAddr: e.target.value, })) } />
{/* 屏蔽协议 */}
屏蔽协议
开启开关以屏蔽对应协议
{protocolDisabled && ( )}
{/* HTTP tile */}
HTTP
禁用/启用
setForm((prev) => ({ ...prev, http: v ? 1 : 0, })) } />
{form.http === 1 ? "已开启" : "已关闭"}
{/* TLS tile */}
TLS
禁用/启用
setForm((prev) => ({ ...prev, tls: v ? 1 : 0 })) } />
{form.tls === 1 ? "已开启" : "已关闭"}
{/* SOCKS tile */}
SOCKS
禁用/启用
setForm((prev) => ({ ...prev, socks: v ? 1 : 0, })) } />
{form.socks === 1 ? "已开启" : "已关闭"}
{/* 删除确认模态框 */} {(onClose) => ( <>

确认删除

确定要删除节点{" "} "{nodeToDelete?.name}" 吗?

此操作不可恢复,请谨慎操作。

)}
{/* 安装命令模态框 */} setInstallCommandModal(false)} > 安装命令 - {currentNodeName}

请复制以下安装命令到服务器上执行: