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 { 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 { createTunnel, getTunnelList, updateTunnel, deleteTunnel, getNodeList, diagnoseTunnel, updateTunnelOrder } 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; inx?: 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 [tunnelOrder, setTunnelOrder] = 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) { const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({ ...t, inx: t.inx ?? 0, })); setTunnels(tunnelsData); // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序 const hasDbOrdering = tunnelsData.some((t) => t.inx !== undefined && t.inx !== 0); if (hasDbOrdering) { const dbOrder = [...tunnelsData] .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) .map((t) => t.id); setTunnelOrder(dbOrder); } else { try { const stored = localStorage.getItem('tunnel-order'); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { const existingIds = new Set(tunnelsData.map((t) => t.id)); const validOrder = parsed .map((id: any) => Number(id)) .filter((id: number) => existingIds.has(id)); if (validOrder.length > 0) { setTunnelOrder(validOrder); } else { setTunnelOrder(tunnelsData.map((t) => t.id)); } } else { setTunnelOrder(tunnelsData.map((t) => t.id)); } } else { setTunnelOrder(tunnelsData.map((t) => t.id)); } } catch { setTunnelOrder(tunnelsData.map((t) => t.id)); } } } 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' }; }; // 处理拖拽结束 const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!active || !over || active.id === over.id) return; if (!tunnelOrder || tunnelOrder.length === 0) return; const activeId = Number(active.id); const overId = Number(over.id); if (isNaN(activeId) || isNaN(overId)) return; const oldIndex = tunnelOrder.indexOf(activeId); const newIndex = tunnelOrder.indexOf(overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; const newOrder = arrayMove(tunnelOrder, oldIndex, newIndex); setTunnelOrder(newOrder); // 保存到 localStorage try { localStorage.setItem('tunnel-order', JSON.stringify(newOrder)); } catch (error) { console.warn('无法保存隧道排序到localStorage:', error); } // 持久化到数据库 try { const tunnelsToUpdate = newOrder.map((id, index) => ({ id, inx: index })); const response = await updateTunnelOrder({ tunnels: tunnelsToUpdate }); if (response.code === 0) { setTunnels((prev) => prev.map((tunnel) => { const updated = tunnelsToUpdate.find((t) => t.id === tunnel.id); return updated ? { ...tunnel, inx: updated.inx } : tunnel; }) ); } else { toast.error('保存排序失败:' + (response.msg || '未知错误')); } } catch (error) { console.error('保存隧道排序到数据库失败:', error); toast.error('保存排序失败,请重试'); } }; // 传感器配置 const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); // 根据排序顺序获取隧道列表 const getSortedTunnels = (): Tunnel[] => { if (!tunnels || tunnels.length === 0) return []; const sortedTunnels = [...tunnels].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); // 如果数据库中没有排序信息,则使用本地存储的顺序 if (tunnelOrder && tunnelOrder.length > 0 && sortedTunnels.every((t) => t.inx === undefined || t.inx === 0)) { const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const)); const localSorted: Tunnel[] = []; tunnelOrder.forEach((id) => { const tunnel = tunnelMap.get(id); if (tunnel) localSorted.push(tunnel); }); tunnels.forEach((tunnel) => { if (!tunnelOrder.includes(tunnel.id)) { localSorted.push(tunnel); } }); return localSorted; } return sortedTunnels; }; 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)}
); }; if (loading) { return (
正在加载...
); } return (
{/* 页面头部 */}
{/* 隧道卡片网格 */} {tunnels.length > 0 ? ( t.id)} strategy={rectSortingStrategy} >
{getSortedTunnels().map((tunnel) => { const typeDisplay = getTypeDisplay(tunnel.type); return ( {(listeners) => (

{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
} />