import { useState, useEffect, useMemo } 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 { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; 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 { createTunnel, getTunnelList, updateTunnel, deleteTunnel, getNodeList, diagnoseTunnel, updateTunnelOrder, batchDeleteTunnels, batchRedeployTunnels, } 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 }>({}); // 批量操作相关状态 const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); const [batchLoading, setBatchLoading] = useState(false); 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 { } } catch { 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 { 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 { 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 { 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 {} // 持久化到数据库 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 { toast.error("保存排序失败,请重试"); } }; const toggleSelectMode = () => { setSelectMode(!selectMode); if (selectMode) { setSelectedIds(new Set()); } }; const toggleSelect = (id: number) => { const newSet = new Set(selectedIds); if (newSet.has(id)) { newSet.delete(id); } else { newSet.add(id); } setSelectedIds(newSet); }; const selectAll = () => { const allIds = sortedTunnels.map((t) => t.id); setSelectedIds(new Set(allIds)); }; const deselectAll = () => { setSelectedIds(new Set()); }; const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const res = await batchDeleteTunnels(Array.from(selectedIds)); if (res.code === 0) { const result = res.data; if (result.failCount === 0) { toast.success(`成功删除 ${result.successCount} 项`); } else { toast.error( `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, ); } setSelectedIds(new Set()); setSelectMode(false); setBatchDeleteModalOpen(false); loadData(); } else { toast.error(res.msg || "删除失败"); } } catch (e: any) { toast.error(e.message || "删除失败"); } finally { setBatchLoading(false); } }; const handleBatchRedeploy = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const res = await batchRedeployTunnels(Array.from(selectedIds)); if (res.code === 0) { const result = res.data; if (result.failCount === 0) { toast.success(`成功重新下发 ${result.successCount} 项`); } else { toast.error( `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, ); } setSelectedIds(new Set()); setSelectMode(false); loadData(); } else { toast.error(res.msg || "下发失败"); } } catch (e: any) { toast.error(e.message || "下发失败"); } finally { setBatchLoading(false); } }; // 传感器配置 const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); // 根据排序顺序获取隧道列表 const sortedTunnels = useMemo((): Tunnel[] => { if (!tunnels || tunnels.length === 0) return []; const sortedByDb = [...tunnels].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); // 如果数据库中没有排序信息,则使用本地存储的顺序 if ( tunnelOrder && tunnelOrder.length > 0 && sortedByDb.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 sortedByDb; }, [tunnels, tunnelOrder]); const sortableTunnelIds = useMemo( () => sortedTunnels.map((t) => t.id), [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: isDragging ? undefined : transition || undefined, opacity: isDragging ? 0.5 : 1, willChange: "transform", }; return (
{children(listeners)}
); }; if (loading) { return (
正在加载...
); } return (
{/* 页面头部 */}
{selectMode && selectedIds.size > 0 && (
已选择 {selectedIds.size} 项
)} {/* 隧道卡片网格 */} {tunnels.length > 0 ? (
{sortedTunnels.map((tunnel) => { const typeDisplay = getTypeDisplay(tunnel.type); return ( {(listeners) => (
{selectMode && ( toggleSelect(tunnel.id)} /> )}

{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 })) } />
x
} errorMessage={errors.trafficRatio} isInvalid={!!errors.trafficRatio} label="流量倍率" placeholder="请输入流量倍率" type="number" value={form.trafficRatio.toString()} variant="bordered" onChange={(e) => setForm((prev) => ({ ...prev, trafficRatio: parseFloat(e.target.value) || 0, })) } />