import { useState, useEffect, 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 toast from 'react-hot-toast'; import axios from 'axios'; import { DndContext, KeyboardSensor, PointerSensor, 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 } 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; // 1: 在线, 0: 离线 connectionStatus: 'online' | 'offline'; systemInfo?: { cpuUsage: number; memoryUsage: number; uploadTraffic: number; downloadTraffic: number; uploadSpeed: number; downloadSpeed: number; uptime: number; } | null; copyLoading?: 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: transition || undefined, opacity: isDragging ? 0.5 : 1, }; 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 [installCommandModal, setInstallCommandModal] = useState(false); const [installCommand, setInstallCommand] = useState(''); const [currentNodeName, setCurrentNodeName] = useState(''); 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.status === 1 ? 'online' : 'offline', 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 (error) { 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 (error) { // 解析失败时不输出错误信息 } }; websocketRef.current.onerror = () => { // WebSocket错误时不输出错误信息 }; websocketRef.current.onclose = () => { websocketRef.current = null; setWsConnected(false); setWsConnecting(false); attemptReconnect(); }; } catch (error) { 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 (error) { return node; } } return node; })); } }; // 尝试重新连接 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 (error) { 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 (copyError) { // 复制失败,显示安装命令模态框 setInstallCommand(res.data); setCurrentNodeName(node.name); setInstallCommandModal(true); } } else { toast.error(res.msg || '获取安装命令失败'); } } catch (error) { 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 (error) { toast.error('复制失败,请手动选择文本复制'); } }; // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; setSubmitLoading(true); try { const apiCall = isEdit ? updateNode : createNode; const { serverHost: _serverHost, ...rest } = form; const data = { ...rest, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.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 (error) { 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 (error) { console.warn('无法保存节点排序到localStorage:', error); } // 持久化到数据库 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 (error) { console.error('保存节点排序到数据库失败:', error); toast.error('保存排序失败,请重试'); } }; // 传感器配置 const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); // 根据排序顺序获取节点列表 const getSortedNodes = (): Node[] => { if (!nodeList || nodeList.length === 0) return []; const sortedNodes = [...nodeList].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); // 如果数据库中没有排序信息,则使用本地存储的顺序 if (nodeOrder && nodeOrder.length > 0 && sortedNodes.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 sortedNodes; }; return (
{/* 页面头部 */}
{!wsConnected && ( )} {/* 节点列表 */} {loading ? (
正在加载...
) : nodeList.length === 0 ? (

暂无节点配置

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

) : ( n.id)} strategy={rectSortingStrategy} >
{getSortedNodes().map((node) => ( {(listeners) => (

{node.name}

{node.connectionStatus === 'online' ? '在线' : '离线'}
{/* 基础信息 */}
IP
{(node.serverIpV4?.trim() || node.serverIpV6?.trim()) ? (
{node.serverIpV4?.trim() && ( {node.serverIpV4.trim()} )} {node.serverIpV6?.trim() && ( {node.serverIpV6.trim()} )}
) : ( {node.serverIp.trim()} )}
版本 {node.version || '未知'}
开机时间 {node.connectionStatus === 'online' && node.systemInfo ? formatUptime(node.systemInfo.uptime) : '-' }
{/* 系统监控 */}
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) : '-' }
{/* 操作按钮 */}
)}
))}
)} {/* 新增/编辑节点对话框 */} setDialogVisible(false)} size="2xl" scrollBehavior="outside" backdrop="blur" placement="center" > {dialogTitle}
setForm(prev => ({ ...prev, name: e.target.value }))} isInvalid={!!errors.name} errorMessage={errors.name} variant="bordered" /> setForm(prev => ({ ...prev, serverHost: e.target.value }))} isInvalid={!!errors.serverHost} errorMessage={errors.serverHost} variant="bordered" description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名" />
setForm(prev => ({ ...prev, serverIpV4: e.target.value }))} isInvalid={!!errors.serverIpV4} errorMessage={errors.serverIpV4} variant="bordered" description="双栈节点组隧道时优先使用 IPv4" /> setForm(prev => ({ ...prev, serverIpV6: e.target.value }))} isInvalid={!!errors.serverIpV6} errorMessage={errors.serverIpV6} variant="bordered" description="至少填写一个 IPv4/IPv6/域名" />
setForm(prev => ({ ...prev, port: e.target.value }))} isInvalid={!!errors.port} errorMessage={errors.port} variant="bordered" description="支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔" classNames={{ input: "font-mono" }} /> {/* 高级配置 */}
setForm(prev => ({ ...prev, interfaceName: e.target.value }))} isInvalid={!!errors.interfaceName} errorMessage={errors.interfaceName} variant="bordered" description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行" />
setForm(prev => ({ ...prev, tcpListenAddr: e.target.value }))} isInvalid={!!errors.tcpListenAddr} errorMessage={errors.tcpListenAddr} variant="bordered" startContent={
TCP
} /> setForm(prev => ({ ...prev, udpListenAddr: e.target.value }))} isInvalid={!!errors.udpListenAddr} errorMessage={errors.udpListenAddr} variant="bordered" startContent={
UDP
} />
{/* 屏蔽协议 */}
屏蔽协议
开启开关以屏蔽对应协议
{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)} size="2xl" scrollBehavior="outside" backdrop="blur" placement="center" > 安装命令 - {currentNodeName}

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