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 { Spinner } from "@heroui/spinner"; import { Alert } from "@heroui/alert"; import { Progress } from "@heroui/progress"; import toast from 'react-hot-toast'; import axios from 'axios'; import { createNode, getNodeList, updateNode, deleteNode, getNodeInstallCommand } from "@/api"; interface Node { id: number; name: string; ip: string; serverIp: string; portSta: number; portEnd: number; version?: string; 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; ipString: string; serverIp: string; portSta: number; portEnd: number; } export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [loading, setLoading] = 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 [form, setForm] = useState({ id: null, name: '', ipString: '', serverIp: '', portSta: 1000, portEnd: 65535 }); 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; useEffect(() => { loadNodes(); initWebSocket(); return () => { closeWebSocket(); }; }, []); // 加载节点列表 const loadNodes = async () => { setLoading(true); try { const res = await getNodeList(); if (res.code === 0) { setNodeList(res.data.map((node: any) => ({ ...node, connectionStatus: node.status === 1 ? 'online' : 'offline', systemInfo: null, copyLoading: false }))); } 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 { websocketRef.current = new WebSocket(wsUrl); websocketRef.current.onopen = () => { reconnectAttemptsRef.current = 0; }; 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; attemptReconnect(); }; } catch (error) { attemptReconnect(); } }; // 处理WebSocket消息 const handleWebSocketMessage = (data: any) => { const { id, type, data: messageData } = data; if (type === 'status') { setNodeList(prev => prev.map(node => { if (node.id == id) { return { ...node, connectionStatus: messageData === 1 ? 'online' : 'offline', systemInfo: messageData === 0 ? null : node.systemInfo }; } return node; })); } else if (type === 'info') { setNodeList(prev => prev.map(node => { if (node.id == id) { 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 (reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current++; reconnectTimerRef.current = setTimeout(() => { initWebSocket(); }, 3000 * reconnectAttemptsRef.current); } }; // 关闭WebSocket连接 const closeWebSocket = () => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } reconnectAttemptsRef.current = 0; 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; } setNodeList(prev => prev.map(node => ({ ...node, connectionStatus: 'offline', systemInfo: 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"; }; // 验证IP地址格式 const validateIp = (ip: string): boolean => { if (!ip || !ip.trim()) return false; const trimmedIp = ip.trim(); // IPv4格式验证 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]?)$/; // IPv6格式验证 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]))$/; if (ipv4Regex.test(trimmedIp) || ipv6Regex.test(trimmedIp) || trimmedIp === 'localhost') { return true; } // 验证域名格式 if (/^\d+$/.test(trimmedIp)) return false; const domainRegex = /^[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 singleLabelDomain = /^[a-zA-Z][a-zA-Z0-9\-]{0,62}$/; return domainRegex.test(trimmedIp) || singleLabelDomain.test(trimmedIp); }; // 表单验证 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位'; } if (!form.ipString.trim()) { newErrors.ipString = '请输入入口IP地址'; } else { const ips = form.ipString.split('\n').map(ip => ip.trim()).filter(ip => ip); if (ips.length === 0) { newErrors.ipString = '请输入至少一个有效IP地址'; } else { for (let i = 0; i < ips.length; i++) { if (!validateIp(ips[i])) { newErrors.ipString = `第${i + 1}行IP地址格式错误: ${ips[i]}`; break; } } } } if (!form.serverIp.trim()) { newErrors.serverIp = '请输入服务器IP地址'; } else if (!validateIp(form.serverIp.trim())) { newErrors.serverIp = '请输入有效的IPv4、IPv6地址或域名'; } if (!form.portSta || form.portSta < 1 || form.portSta > 65535) { newErrors.portSta = '端口范围必须在1-65535之间'; } if (!form.portEnd || form.portEnd < 1 || form.portEnd > 65535) { newErrors.portEnd = '端口范围必须在1-65535之间'; } else if (form.portEnd < form.portSta) { newErrors.portEnd = '结束端口不能小于起始端口'; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // 新增节点 const handleAdd = () => { setDialogTitle('新增节点'); setIsEdit(false); setDialogVisible(true); resetForm(); }; // 编辑节点 const handleEdit = (node: Node) => { setDialogTitle('编辑节点'); setIsEdit(true); setForm({ id: node.id, name: node.name, ipString: node.ip ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '', serverIp: node.serverIp || '', portSta: node.portSta, portEnd: node.portEnd }); 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 ipString = form.ipString .split('\n') .map(ip => ip.trim()) .filter(ip => ip) .join(','); const submitData = { ...form, ip: ipString }; delete (submitData as any).ipString; const apiCall = isEdit ? updateNode : createNode; const data = isEdit ? submitData : { name: form.name, ip: ipString, serverIp: form.serverIp, portSta: form.portSta, portEnd: form.portEnd }; 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, ip: ipString, serverIp: form.serverIp, portSta: form.portSta, portEnd: form.portEnd } : n )); } else { loadNodes(); } } else { toast.error(res.msg || (isEdit ? '更新失败' : '创建失败')); } } catch (error) { toast.error('网络错误,请重试'); } finally { setSubmitLoading(false); } }; // 重置表单 const resetForm = () => { setForm({ id: null, name: '', ipString: '', serverIp: '', portSta: 1000, portEnd: 65535 }); setErrors({}); }; return (
{/* 页面头部 */}
{/* 节点列表 */} {loading ? (
正在加载...
) : nodeList.length === 0 ? (

暂无节点配置

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

) : (
{nodeList.map((node) => (

{node.name}

{node.serverIp}

{node.connectionStatus === 'online' ? '在线' : '离线'}
{/* 基础信息 */}
入口IP
{node.ip ? ( node.ip.split(',').length > 1 ? ( {node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个 ) : ( {node.ip.trim()} ) ) : '-'}
端口 {node.portSta}-{node.portEnd}
版本 {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" > {dialogTitle}
setForm(prev => ({ ...prev, name: e.target.value }))} isInvalid={!!errors.name} errorMessage={errors.name} variant="bordered" /> setForm(prev => ({ ...prev, serverIp: e.target.value }))} isInvalid={!!errors.serverIp} errorMessage={errors.serverIp} variant="bordered" />