import { useState, useEffect } from "react"; import { Card, CardBody, CardHeader } from "@heroui/card"; import { Button } from "@heroui/button"; import { Input, Textarea } from "@heroui/input"; import { Select, SelectItem } from "@heroui/select"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal"; import { Chip } from "@heroui/chip"; import { Spinner } from "@heroui/spinner"; import { Divider } from "@heroui/divider"; import { Alert } from "@heroui/alert"; import toast from 'react-hot-toast'; import { createTunnel, getTunnelList, updateTunnel, deleteTunnel, getNodeList, diagnoseTunnel } from "@/api"; interface ChainTunnel { nodeId: number; protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' - 转发链协议 strategy?: string; // 'fifo' | 'round' | 'rand' - 仅转发链需要 chainType?: number; // 1: 入口, 2: 转发链, 3: 出口 inx?: number; // 转发链序号 } interface Tunnel { id: number; name: string; type: number; // 1: 端口转发, 2: 隧道转发 inNodeId: ChainTunnel[]; // 入口节点列表 outNodeId?: ChainTunnel[]; // 出口节点列表 chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组 inIp: string; outIp?: string; protocol?: string; flow: number; // 1: 单向, 2: 双向 trafficRatio: number; status: number; createdTime: string; } interface Node { id: number; name: string; status: number; // 1: 在线, 0: 离线 } interface TunnelForm { id?: number; name: string; type: number; inNodeId: ChainTunnel[]; outNodeId?: ChainTunnel[]; chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点 flow: number; trafficRatio: number; inIp: string; // 入口IP status: number; } interface DiagnosisResult { tunnelName: string; tunnelType: string; timestamp: number; results: Array<{ success: boolean; description: string; nodeName: string; nodeId: string; targetIp: string; targetPort?: number; message?: string; averageTime?: number; packetLoss?: number; fromChainType?: number; // 1: 入口, 2: 链, 3: 出口 fromInx?: number; toChainType?: number; toInx?: number; }>; } export default function TunnelPage() { const [loading, setLoading] = useState(true); const [tunnels, setTunnels] = useState([]); const [nodes, setNodes] = useState([]); // 模态框状态 const [modalOpen, setModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false); const [isEdit, setIsEdit] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [tunnelToDelete, setTunnelToDelete] = useState(null); const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] = useState(null); const [diagnosisResult, setDiagnosisResult] = useState(null); // 表单状态 const [form, setForm] = useState({ name: '', type: 1, inNodeId: [], outNodeId: [], chainNodes: [], flow: 1, trafficRatio: 1.0, inIp: '', status: 1 }); // 表单验证错误 const [errors, setErrors] = useState<{[key: string]: string}>({}); useEffect(() => { loadData(); }, []); // 加载所有数据 const loadData = async () => { setLoading(true); try { const [tunnelsRes, nodesRes] = await Promise.all([ getTunnelList(), getNodeList() ]); if (tunnelsRes.code === 0) { setTunnels(tunnelsRes.data || []); } else { toast.error(tunnelsRes.msg || '获取隧道列表失败'); } if (nodesRes.code === 0) { setNodes(nodesRes.data || []); } else { console.warn('获取节点列表失败:', nodesRes.msg); } } catch (error) { console.error('加载数据失败:', error); toast.error('加载数据失败'); } finally { setLoading(false); } }; // 表单验证 const validateForm = (): boolean => { const newErrors: {[key: string]: string} = {}; if (!form.name.trim()) { newErrors.name = '请输入隧道名称'; } else if (form.name.length < 2 || form.name.length > 50) { newErrors.name = '隧道名称长度应在2-50个字符之间'; } if (!form.inNodeId || form.inNodeId.length === 0) { newErrors.inNodeId = '请至少选择一个入口节点'; } else { // 验证所有选择的节点都在线 const offlineNodes = form.inNodeId.filter(item => { const node = nodes.find(n => n.id === item.nodeId); return node && node.status !== 1; }); if (offlineNodes.length > 0) { newErrors.inNodeId = '所有入口节点必须在线'; } } if (form.trafficRatio < 0.0 || form.trafficRatio > 100.0) { newErrors.trafficRatio = '流量倍率必须在0.0-100.0之间'; } // 隧道转发时的验证 if (form.type === 2) { if (!form.outNodeId || form.outNodeId.length === 0) { newErrors.outNodeId = '请至少选择一个出口节点'; } else { // 验证所有选择的节点都在线 const offlineNodes = form.outNodeId.filter(item => { const node = nodes.find(n => n.id === item.nodeId); return node && node.status !== 1; }); if (offlineNodes.length > 0) { newErrors.outNodeId = '所有出口节点必须在线'; } // 检查是否有重复节点 const inNodeIds = form.inNodeId.map(item => item.nodeId); const outNodeIds = form.outNodeId.map(item => item.nodeId); const overlap = inNodeIds.filter(id => outNodeIds.includes(id)); if (overlap.length > 0) { newErrors.outNodeId = '隧道转发模式下,入口和出口不能有相同节点'; } } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // 新增隧道 const handleAdd = () => { setIsEdit(false); setForm({ name: '', type: 1, inNodeId: [], outNodeId: [], chainNodes: [], flow: 1, trafficRatio: 1.0, inIp: '', status: 1 }); setErrors({}); setModalOpen(true); }; // 编辑隧道 - 只能修改部分字段 const handleEdit = (tunnel: Tunnel) => { setIsEdit(true); // 直接使用列表数据,getAllTunnels 已经包含完整的节点信息 setForm({ id: tunnel.id, name: tunnel.name, type: tunnel.type, inNodeId: tunnel.inNodeId || [], outNodeId: tunnel.outNodeId || [], chainNodes: tunnel.chainNodes || [], flow: tunnel.flow, trafficRatio: tunnel.trafficRatio, inIp: tunnel.inIp ? tunnel.inIp.split(',').map(ip => ip.trim()).join('\n') : '', status: tunnel.status }); setErrors({}); setModalOpen(true); }; // 删除隧道 const handleDelete = (tunnel: Tunnel) => { setTunnelToDelete(tunnel); setDeleteModalOpen(true); }; const confirmDelete = async () => { if (!tunnelToDelete) return; setDeleteLoading(true); try { const response = await deleteTunnel(tunnelToDelete.id); if (response.code === 0) { toast.success('删除成功'); setDeleteModalOpen(false); setTunnelToDelete(null); loadData(); } else { toast.error(response.msg || '删除失败'); } } catch (error) { console.error('删除失败:', error); toast.error('删除失败'); } finally { setDeleteLoading(false); } }; // 隧道类型改变时的处理 const handleTypeChange = (type: number) => { setForm(prev => ({ ...prev, type, outNodeId: type === 1 ? [] : prev.outNodeId, chainNodes: type === 1 ? [] : prev.chainNodes })); }; // 删除转发链中的某一跳(删除整个分组) const removeChainNode = (groupIndex: number) => { setForm(prev => ({ ...prev, chainNodes: (prev.chainNodes || []).filter((_, index) => index !== groupIndex) })); }; // 添加节点到指定的转发链跳数 const addNodeToChain = (groupIndex: number, nodeId: number) => { setForm(prev => { const chainNodes = [...(prev.chainNodes || [])]; const group = chainNodes[groupIndex] || []; // 获取当前组的策略和协议 const strategy = group.length > 0 ? group[0].strategy : 'round'; const protocol = group.length > 0 ? group[0].protocol : 'tls'; // 添加节点到该组 chainNodes[groupIndex] = [ ...group, { nodeId, chainType: 2, protocol, strategy } ]; return { ...prev, chainNodes }; }); }; // 从某一跳删除指定节点 const removeNodeFromChain = (groupIndex: number, nodeId: number) => { setForm(prev => { const chainNodes = [...(prev.chainNodes || [])]; chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter(node => node.nodeId !== nodeId); return { ...prev, chainNodes }; }); }; // 更新某一跳的所有节点的协议 const updateChainProtocol = (groupIndex: number, protocol: string) => { setForm(prev => { const chainNodes = [...(prev.chainNodes || [])]; chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map(node => ({ ...node, protocol })); return { ...prev, chainNodes }; }); }; // 更新某一跳的所有节点的策略 const updateChainStrategy = (groupIndex: number, strategy: string) => { setForm(prev => { const chainNodes = [...(prev.chainNodes || [])]; chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map(node => ({ ...node, strategy })); return { ...prev, chainNodes }; }); }; // 获取所有转发链中已选择的节点ID列表 const getSelectedChainNodeIds = (): number[] => { return (form.chainNodes || []).flatMap(group => group.map(node => node.nodeId)); }; // 获取转发链分组(已经是二维数组) const getChainGroups = (): ChainTunnel[][] => { return form.chainNodes || []; }; // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; setSubmitLoading(true); try { // 过滤掉占位节点(nodeId === -1 的节点) const cleanedChainNodes = (form.chainNodes || []) .map(group => group.filter(node => node.nodeId !== -1)) .filter(group => group.length > 0); // 移除空组 // 过滤掉出口节点中的占位节点 const cleanedOutNodeId = (form.outNodeId || []).filter(node => node.nodeId !== -1); // 将换行符分隔的IP转换为逗号分隔 const inIpString = form.inIp .split('\n') .map(ip => ip.trim()) .filter(ip => ip) .join(','); const data = { ...form, inIp: inIpString, outNodeId: cleanedOutNodeId, chainNodes: cleanedChainNodes }; const response = isEdit ? await updateTunnel(data) : await createTunnel(data); if (response.code === 0) { toast.success(isEdit ? '更新成功' : '创建成功'); setModalOpen(false); loadData(); } else { toast.error(response.msg || (isEdit ? '更新失败' : '创建失败')); } } catch (error) { console.error('提交失败:', error); toast.error('网络错误,请重试'); } finally { setSubmitLoading(false); } }; // 诊断隧道 const handleDiagnose = async (tunnel: Tunnel) => { setCurrentDiagnosisTunnel(tunnel); setDiagnosisModalOpen(true); setDiagnosisLoading(true); setDiagnosisResult(null); try { const response = await diagnoseTunnel(tunnel.id); if (response.code === 0) { setDiagnosisResult(response.data); } else { toast.error(response.msg || '诊断失败'); setDiagnosisResult({ tunnelName: tunnel.name, tunnelType: tunnel.type === 1 ? '端口转发' : '隧道转发', timestamp: Date.now(), results: [{ success: false, description: '诊断失败', nodeName: '-', nodeId: '-', targetIp: '-', targetPort: 443, message: response.msg || '诊断过程中发生错误' }] }); } } catch (error) { console.error('诊断失败:', error); toast.error('网络错误,请重试'); setDiagnosisResult({ tunnelName: tunnel.name, tunnelType: tunnel.type === 1 ? '端口转发' : '隧道转发', timestamp: Date.now(), results: [{ success: false, description: '网络错误', nodeName: '-', nodeId: '-', targetIp: '-', targetPort: 443, message: '无法连接到服务器' }] }); } finally { setDiagnosisLoading(false); } }; // 获取类型显示 const getTypeDisplay = (type: number) => { switch (type) { case 1: return { text: '端口转发', color: 'primary' }; case 2: return { text: '隧道转发', color: 'secondary' }; default: return { text: '未知', color: 'default' }; } }; // 获取流量计算显示 const getFlowDisplay = (flow: number) => { switch (flow) { case 1: return '单向计算'; case 2: return '双向计算'; default: return '未知'; } }; // 获取连接质量 const getQualityDisplay = (averageTime?: number, packetLoss?: number) => { if (averageTime === undefined || packetLoss === undefined) return null; if (averageTime < 30 && packetLoss === 0) return { text: '🚀 优秀', color: 'success' }; if (averageTime < 50 && packetLoss === 0) return { text: '✨ 很好', color: 'success' }; if (averageTime < 100 && packetLoss < 1) return { text: '👍 良好', color: 'primary' }; if (averageTime < 150 && packetLoss < 2) return { text: '😐 一般', color: 'warning' }; if (averageTime < 200 && packetLoss < 5) return { text: '😟 较差', color: 'warning' }; return { text: '😵 很差', color: 'danger' }; }; if (loading) { return (
正在加载...
); } return (
{/* 页面头部 */}
{/* 隧道卡片网格 */} {tunnels.length > 0 ? (
{tunnels.map((tunnel) => { const typeDisplay = getTypeDisplay(tunnel.type); return (

{tunnel.name}

{typeDisplay.text}
{/* 拓扑结构 */}
{/* 入口节点 */}
{tunnel.inNodeId?.length || 0}入口
{/* 箭头 */} {/* 转发链 */}
{tunnel.type === 2 ? (tunnel.chainNodes?.length || 0) : 0}跳
{/* 箭头 */} {/* 出口节点 */}
{tunnel.type === 2 ? (tunnel.outNodeId?.length || 0) : (tunnel.inNodeId?.length || 0)}出口
{/* 流量配置 */}
流量计算
{getFlowDisplay(tunnel.flow)}
流量倍率
{tunnel.trafficRatio}x
); })}
) : ( /* 空状态 */

暂无隧道配置

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

)} {/* 新增/编辑模态框 */} {(onClose) => ( <>

{isEdit ? '编辑隧道' : '新增隧道'}

{isEdit ? '编辑时只能修改隧道名称、流量计算和流量倍率' : '创建新的隧道配置'}

setForm(prev => ({ ...prev, name: e.target.value }))} isInvalid={!!errors.name} errorMessage={errors.name} variant="bordered" />
setForm(prev => ({ ...prev, trafficRatio: parseFloat(e.target.value) || 0 }))} isInvalid={!!errors.trafficRatio} errorMessage={errors.trafficRatio} variant="bordered" endContent={
x
} />