import { useState, useEffect, useMemo, useRef } from "react"; 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 { SearchBar } from "@/components/search-bar"; import { AnimatedPage } from "@/components/animated-page"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Input, Textarea } from "@/shadcn-bridge/heroui/input"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, } from "@/shadcn-bridge/heroui/modal"; import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Divider } from "@/shadcn-bridge/heroui/divider"; import { Alert } from "@/shadcn-bridge/heroui/alert"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { createTunnel, getTunnelList, updateTunnel, deleteTunnel, getNodeList, diagnoseTunnel, updateTunnelOrder, batchDeleteTunnels, batchRedeployTunnels, } from "@/api"; import { PageLoadingState } from "@/components/page-state"; import { buildDiagnosisFallbackResult, getDiagnosisQualityDisplay, type DiagnosisResult, } from "@/pages/tunnel/diagnosis"; import { diagnoseTunnelStream } from "@/api/diagnosis-stream"; import { createTunnelFormDefaults, getTunnelFlowDisplay, getTunnelTypeDisplay, validateTunnelForm, } from "@/pages/tunnel/form"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; import { extractApiErrorMessage } from "@/api/error-message"; interface ChainTunnel { nodeId: number; protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' - 转发链协议 strategy?: string; // 'fifo' | 'round' | 'rand' - 仅转发链需要 chainType?: number; // 1: 入口, 2: 转发链, 3: 出口 inx?: number; // 转发链序号 connectIp?: string; // 连接IP(多IP节点指定连接地址) } 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; ipPreference?: string; status: number; createdTime: string; } interface Node { id: number; name: string; status: number; // 1: 在线, 0: 离线 serverIp?: string; serverIpV4?: string; serverIpV6?: string; extraIPs?: string; } interface TunnelForm { id?: number; name: string; type: number; inNodeId: ChainTunnel[]; outNodeId?: ChainTunnel[]; chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点 flow: number; trafficRatio: number; inIp: string; // 入口IP ipPreference: string; status: number; } export default function TunnelPage() { const [loading, setLoading] = useState(true); const [tunnels, setTunnels] = useState([]); const [tunnelOrder, setTunnelOrder] = useState([]); const [nodes, setNodes] = useState([]); const [searchKeyword, setSearchKeyword] = useLocalStorageState( "tunnel-search-keyword", "", ); const [isSearchVisible, setIsSearchVisible] = useState(false); // 模态框状态 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 [diagnosisProgress, setDiagnosisProgress] = useState({ total: 0, completed: 0, success: 0, failed: 0, timedOut: false, }); const diagnosisAbortRef = useRef(null); const getNodeIpOptions = (nodeId: number): string[] => { const node = nodes.find((item) => item.id === nodeId); if (!node) { return []; } const values: string[] = []; const push = (value?: string) => { const trimmed = (value || "").trim(); if (trimmed) { values.push(trimmed); } }; push(node.serverIpV4); push(node.serverIpV6); push(node.serverIp); (node.extraIPs || "") .split(",") .map((v) => v.trim()) .filter((v) => v) .forEach((v) => values.push(v)); return Array.from(new Set(values)); }; const getCommonIpOptions = (nodeIds: number[]): string[] => { if (nodeIds.length === 0) { return []; } const optionSets = nodeIds.map( (nodeId) => new Set(getNodeIpOptions(nodeId)), ); const base = optionSets[0]; return Array.from(base).filter((ip) => optionSets.every((set) => set.has(ip)), ); }; // 表单状态 const [form, setForm] = useState(createTunnelFormDefaults()); // 表单验证错误 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(() => { return () => { diagnosisAbortRef.current?.abort(); diagnosisAbortRef.current = null; }; }, []); 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 { setTunnelOrder( loadStoredOrder( "tunnel-order", 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 = validateTunnelForm(form, nodes); setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // 新增隧道 const handleAdd = () => { setIsEdit(false); setForm(createTunnelFormDefaults()); 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: string) => ip.trim()) .join("\n") : "", ipPreference: tunnel.ipPreference || "", 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 toSelectedNodeIds = (keys: Iterable): number[] => { return Array.from(keys) .map((key) => Number.parseInt(String(key), 10)) .filter((nodeId) => Number.isFinite(nodeId)); }; // 更新某一跳的所有节点的协议 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 }; }); }; // 更新某一跳的所有节点的连接IP const updateChainConnectIp = (groupIndex: number, connectIp: string) => { setForm((prev) => { const chainNodes = [...(prev.chainNodes || [])]; chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map((node) => ({ ...node, connectIp, })); return { ...prev, chainNodes }; }); }; // 获取所有转发链中已选择的节点ID列表 const getSelectedChainNodeIds = (): number[] => { return (form.chainNodes || []).flatMap((group) => group.map((node) => node.nodeId), ); }; // 获取转发链分组(已经是二维数组) const getChainGroups = (): ChainTunnel[][] => { return form.chainNodes || []; }; const mergeOrderedNodes = ( currentNodes: ChainTunnel[], selectedNodeIds: number[], buildDefault: (nodeId: number) => ChainTunnel, ): ChainTunnel[] => { const selectedSet = new Set(selectedNodeIds); const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId)); const keptIds = new Set(kept.map((node) => node.nodeId)); const added = selectedNodeIds .filter((nodeId) => !keptIds.has(nodeId)) .map((nodeId) => buildDefault(nodeId)); return [...kept, ...added]; }; const syncChainGroupNodes = ( groupIndex: number, selectedNodeIds: number[], ) => { setForm((prev) => { const chainNodes = [...(prev.chainNodes || [])]; const currentGroup = chainNodes[groupIndex] || []; const protocol = currentGroup[0]?.protocol || "tls"; const strategy = currentGroup[0]?.strategy || "round"; const realNodes = currentGroup.filter((node) => node.nodeId !== -1); const mergedNodes = mergeOrderedNodes( realNodes, selectedNodeIds, (nodeId) => ({ nodeId, chainType: 2, protocol, strategy, }), ); chainNodes[groupIndex] = mergedNodes.length > 0 ? mergedNodes : [{ nodeId: -1, chainType: 2, protocol, strategy }]; return { ...prev, 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) => { diagnosisAbortRef.current?.abort(); const abortController = new AbortController(); diagnosisAbortRef.current = abortController; setCurrentDiagnosisTunnel(tunnel); setDiagnosisModalOpen(true); setDiagnosisLoading(true); setDiagnosisProgress({ total: 0, completed: 0, success: 0, failed: 0, timedOut: false, }); setDiagnosisResult({ tunnelName: tunnel.name, tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发", timestamp: Date.now(), results: [], }); try { let streamErrorMessage = ""; const streamResult = await diagnoseTunnelStream( tunnel.id, { onStart: (payload) => { const startTunnelName = typeof payload.tunnelName === "string" && payload.tunnelName.trim() !== "" ? payload.tunnelName : tunnel.name; const startTunnelType = typeof payload.tunnelType === "string" && payload.tunnelType.trim() !== "" ? payload.tunnelType : tunnel.type === 1 ? "端口转发" : "隧道转发"; const startTotal = Number(payload.total); const startItems = Array.isArray(payload.items) ? (payload.items as DiagnosisResult["results"]) : []; setDiagnosisResult((prev) => ({ tunnelName: startTunnelName, tunnelType: startTunnelType, timestamp: Date.now(), results: startItems.length > 0 ? startItems : prev?.results || [], })); if (Number.isFinite(startTotal) && startTotal >= 0) { setDiagnosisProgress((prev) => ({ ...prev, total: startTotal, })); } }, onItem: ({ result, progress }) => { setDiagnosisResult((prev) => { const base: DiagnosisResult = prev || { tunnelName: tunnel.name, tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发", timestamp: Date.now(), results: [], }; const nextResults = [...base.results]; const existingIndex = nextResults.findIndex( (item) => item.description === result.description && item.nodeId === result.nodeId && item.targetIp === result.targetIp && item.targetPort === result.targetPort, ); if (existingIndex >= 0) { nextResults[existingIndex] = { ...result, diagnosing: false, }; } else { nextResults.push({ ...result, diagnosing: false, }); } return { ...base, timestamp: Date.now(), results: nextResults, }; }); setDiagnosisProgress({ total: progress.total, completed: progress.completed, success: progress.success, failed: progress.failed, timedOut: Boolean(progress.timedOut), }); }, onDone: (progress) => { setDiagnosisProgress({ total: progress.total, completed: progress.completed, success: progress.success, failed: progress.failed, timedOut: Boolean(progress.timedOut), }); }, onError: (message) => { streamErrorMessage = message; }, }, abortController.signal, ); if (streamResult.fallback) { const response = await diagnoseTunnel(tunnel.id); if (response.code === 0) { const resultData = response.data as DiagnosisResult; const successCount = resultData.results.filter( (r) => r.success, ).length; const failedCount = resultData.results.length - successCount; setDiagnosisResult(resultData); setDiagnosisProgress({ total: resultData.results.length, completed: resultData.results.length, success: successCount, failed: failedCount, timedOut: false, }); } else { toast.error(response.msg || "诊断失败"); setDiagnosisResult( buildDiagnosisFallbackResult({ tunnelName: tunnel.name, tunnelType: tunnel.type, description: "诊断失败", message: response.msg || "诊断过程中发生错误", }), ); setDiagnosisProgress({ total: 1, completed: 1, success: 0, failed: 1, timedOut: false, }); } return; } if (streamErrorMessage) { toast.error(streamErrorMessage); } if (streamResult.timedOut) { toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果"); } } catch { if (abortController.signal.aborted) { return; } toast.error("网络错误,请重试"); setDiagnosisResult( buildDiagnosisFallbackResult({ tunnelName: tunnel.name, tunnelType: tunnel.type, description: "网络错误", message: "无法连接到服务器", }), ); setDiagnosisProgress({ total: 1, completed: 1, success: 0, failed: 1, timedOut: false, }); } finally { if (diagnosisAbortRef.current === abortController) { diagnosisAbortRef.current = null; } setDiagnosisLoading(false); } }; // 处理拖拽结束 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); saveOrder("tunnel-order", newOrder); // 持久化到数据库 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 (error) { toast.error(extractApiErrorMessage(error, "删除失败")); } 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 (error) { toast.error(extractApiErrorMessage(error, "下发失败")); } 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 []; let filteredTunnels = tunnels; if (searchKeyword.trim()) { const lowerKeyword = searchKeyword.toLowerCase(); filteredTunnels = filteredTunnels.filter( (t) => (t.name && t.name.toLowerCase().includes(lowerKeyword)) || (t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)), ); } const sortedByDb = [...filteredTunnels].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(filteredTunnels.map((t) => [t.id, t] as const)); const localSorted: Tunnel[] = []; tunnelOrder.forEach((id) => { const tunnel = tunnelMap.get(id); if (tunnel) localSorted.push(tunnel); }); filteredTunnels.forEach((tunnel) => { if (!tunnelOrder.includes(tunnel.id)) { localSorted.push(tunnel); } }); return localSorted; } return sortedByDb; }, [tunnels, tunnelOrder, searchKeyword]); 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: React.CSSProperties = { transform: transform ? CSS.Transform.toString({ ...transform, x: Math.round(transform.x), y: Math.round(transform.y), }) : undefined, transition: isDragging ? undefined : transition || undefined, opacity: isDragging ? 0.5 : 1, willChange: isDragging ? "transform" : undefined, }; return (
{children(listeners)}
); }; if (loading) { return ; } return (
setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} />
{selectMode ? ( <> 已选择 {selectedIds.size} 项 ) : ( <> )}
{/* 隧道卡片网格 */} {tunnels.length > 0 ? (
{sortedTunnels.map((tunnel) => { const typeDisplay = getTunnelTypeDisplay(tunnel.type); const tunnelTypeChipClassName = tunnel.type === 1 ? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700" : "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700"; 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} 出口
{/* 流量配置 */}
流量计算
{getTunnelFlowDisplay(tunnel.flow)}
流量倍率
{tunnel.trafficRatio}x
{tunnel.type === 2 && tunnel.ipPreference && (
连接偏好
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}
)}
)}
); })}
) : ( /* 空状态 */

暂无隧道配置

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

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

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

{isEdit ? "修改节点配置会中断现有连接,隧道类型不可修改" : "创建新的隧道配置"}

setForm((prev) => ({ ...prev, name: e.target.value })) } />
setForm((prev) => ({ ...prev, trafficRatio: parseFloat(e.target.value) || 0, })) } />