import type { BatchOperationFailure, TunnelBatchDeletePreviewApiData, TunnelDeletePreviewApiData, } from "@/api/types"; import { useState, useEffect, useMemo, useRef, useCallback } 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 { LayoutGrid, List } from "lucide-react"; import { SearchBar } from "@/components/search-bar"; import { AnimatedPage } from "@/components/animated-page"; import { BatchActionResultModal } from "@/components/batch-action-result-modal"; 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 { Progress } from "@/shadcn-bridge/heroui/progress"; import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio"; import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion"; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, } from "@/shadcn-bridge/heroui/table"; import { createTunnel, batchDeleteTunnelsWithForwards, getTunnelList, updateTunnel, deleteTunnelWithForwards, getNodeList, diagnoseTunnel, updateTunnelOrder, batchRedeployTunnels, previewBatchTunnelDelete, previewTunnelDelete, } 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 { buildBatchFailureMessage, extractBatchFailures, extractApiErrorMessage, } from "@/api/error-message"; interface ChainTunnel { nodeId: number; protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' | 'kcp' - 转发链协议 strategy?: string; // 'fifo' | 'round' | 'rand' | 'best' - 仅转发链/多出口需要 chainType?: number; // 1: 入口, 2: 转发链, 3: 出口 inx?: number; // 转发链序号 connectIp?: string; // 连接IP(多IP节点指定连接地址) } interface BestExitStateItem { ownerNodeId: number; ownerNodeName: string; ownerRole: "entry" | "chain" | string; exitNodeId?: number; exitNodeName: string; updatedAt?: number; reason?: string; } interface BestExitState { enabled: boolean; summary: string; status: "applied" | "waiting" | string; updatedAt?: number; reason?: string; items: BestExitStateItem[]; } 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; probeTargetHost?: string; probeTargetPort?: number; bestExitState?: BestExitState; status: number; createdTime: string; } const DEFAULT_PROBE_TARGET_HOST = "www.bing.com"; const DEFAULT_PROBE_TARGET_PORT = 443; const getTunnelDiagnosisTarget = (tunnel: Tunnel) => ({ targetIp: tunnel.probeTargetHost || DEFAULT_PROBE_TARGET_HOST, targetPort: tunnel.probeTargetPort || DEFAULT_PROBE_TARGET_PORT, }); 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; probeTargetHost?: string; probeTargetPort?: number; status: number; } interface BatchProgressState { active: boolean; label: string; percent: number; } interface BatchResultModalState { failures: BatchOperationFailure[]; open: boolean; summary: string; title: string; } type TunnelDeleteAction = "replace" | "delete_forwards"; const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = { failures: [], open: false, summary: "", title: "", }; const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace"; const TUNNEL_ORDER_KEY = "tunnel-order"; const isObjectRecord = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); const toSafeString = (value: unknown): string => { if (typeof value === "string") return value; if (typeof value === "number" && Number.isFinite(value)) return String(value); return ""; }; const toSafeNumber = (value: unknown): number | undefined => { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value !== "string" || !value.trim()) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; }; const normalizeBestExitStateItem = ( value: unknown, ): BestExitStateItem | undefined => { if (!isObjectRecord(value)) return undefined; const ownerNodeId = toSafeNumber(value.ownerNodeId); if (ownerNodeId === undefined) return undefined; const exitNodeId = toSafeNumber(value.exitNodeId); const updatedAt = toSafeNumber(value.updatedAt); const reason = toSafeString(value.reason); return { ownerNodeId, ownerNodeName: toSafeString(value.ownerNodeName), ownerRole: toSafeString(value.ownerRole), ...(exitNodeId !== undefined ? { exitNodeId } : {}), exitNodeName: toSafeString(value.exitNodeName), ...(updatedAt !== undefined ? { updatedAt } : {}), ...(reason ? { reason } : {}), }; }; const normalizeBestExitState = (value: unknown): BestExitState | undefined => { if (!isObjectRecord(value) || value.enabled !== true) return undefined; const updatedAt = toSafeNumber(value.updatedAt); const reason = toSafeString(value.reason); const items = Array.isArray(value.items) ? value.items.flatMap((item) => { const normalized = normalizeBestExitStateItem(item); return normalized ? [normalized] : []; }) : []; return { enabled: true, summary: toSafeString(value.summary), status: toSafeString(value.status), ...(updatedAt !== undefined ? { updatedAt } : {}), ...(reason ? { reason } : {}), items, }; }; const bestExitOwnerRoleText = (role?: string) => { if (role === "chain") return "中转"; return "入口"; }; const bestExitDetailTitle = (state?: BestExitState) => { if (!state?.items?.length) return undefined; return state.items .map( (item) => `${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeName || item.ownerNodeId} -> ${item.exitNodeName || "等待探测"}`, ) .join("\n"); }; const renderBestExitState = (state?: BestExitState) => { if (!state?.enabled) return null; const isWaiting = state.status === "waiting"; const summaryText = state.summary || "等待探测"; const displaySummary = isWaiting && !summaryText.includes("等待") ? `等待探测 · ${summaryText}` : summaryText; const className = isWaiting ? "border-warning-200/70 bg-warning-50/50 text-warning-700 dark:border-warning-300/20 dark:bg-warning-900/20 dark:text-warning-300" : "border-success-200/60 bg-success-50/40 text-success-700 dark:border-success-300/20 dark:bg-success-900/20 dark:text-success-300"; return ( 最优出口:{displaySummary} ); }; const mapTunnelApiItems = (items: any[]): Tunnel[] => { return (items || []).map((tunnel) => { const { bestExitState: rawBestExitState, ...tunnelFields } = tunnel; const bestExitState = normalizeBestExitState(rawBestExitState); return { ...tunnelFields, ...(bestExitState ? { bestExitState } : {}), inx: tunnel.inx ?? 0, inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [], outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [], chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [], inIp: tunnel.inIp || "", flow: tunnel.flow ?? 1, trafficRatio: tunnel.trafficRatio ?? 1, status: typeof tunnel.status === "number" ? tunnel.status : 0, createdTime: tunnel.createdTime || "", }; }); }; 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 [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">( "tunnel-view-mode", "grid", ); // 模态框状态 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 [deletePreviewLoading, setDeletePreviewLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [tunnelToDelete, setTunnelToDelete] = useState(null); const [tunnelDeletePreview, setTunnelDeletePreview] = useState(null); const [deleteAction, setDeleteAction] = useState( DEFAULT_TUNNEL_DELETE_ACTION, ); const [deleteTargetTunnelId, setDeleteTargetTunnelId] = useState< number | null >(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 [batchDeletePreviewLoading, setBatchDeletePreviewLoading] = useState(false); const [batchDeletePreview, setBatchDeletePreview] = useState(null); const [batchDeleteAction, setBatchDeleteAction] = useState(DEFAULT_TUNNEL_DELETE_ACTION); const [batchDeleteTargetTunnelId, setBatchDeleteTargetTunnelId] = useState< number | null >(null); const [batchLoading, setBatchLoading] = useState(false); const [batchProgress, setBatchProgress] = useState({ active: false, label: "", percent: 0, }); const [batchResultModal, setBatchResultModal] = useState(EMPTY_BATCH_RESULT_MODAL_STATE); useEffect(() => { return () => { diagnosisAbortRef.current?.abort(); diagnosisAbortRef.current = null; }; }, []); const applyTunnelList = useCallback((items: Tunnel[]) => { setTunnels(items); const hasDbOrdering = items.some( (tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0, ); if (hasDbOrdering) { const dbOrder = [...items] .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) .map((tunnel) => tunnel.id); setTunnelOrder(dbOrder); return; } setTunnelOrder( loadStoredOrder( TUNNEL_ORDER_KEY, items.map((tunnel) => tunnel.id), ), ); }, []); const refreshTunnelList = useCallback( async (withLoading = true) => { if (withLoading) { setLoading(true); } try { const tunnelsRes = await getTunnelList(); if (tunnelsRes.code === 0) { applyTunnelList(mapTunnelApiItems(tunnelsRes.data || [])); } else { toast.error(tunnelsRes.msg || "获取隧道列表失败"); } } catch { toast.error("获取隧道列表失败"); } finally { if (withLoading) { setLoading(false); } } }, [applyTunnelList], ); const refreshNodes = useCallback(async () => { try { const nodesRes = await getNodeList(); if (nodesRes.code === 0) { setNodes(nodesRes.data || []); } } catch {} }, []); // 加载所有数据 const loadData = useCallback(async () => { setLoading(true); try { await Promise.all([refreshTunnelList(false), refreshNodes()]); } catch { toast.error("加载数据失败"); } finally { setLoading(false); } }, [refreshNodes, refreshTunnelList]); useEffect(() => { loadData(); }, [loadData]); const resetDeleteState = useCallback(() => { setDeleteLoading(false); setDeletePreviewLoading(false); setTunnelToDelete(null); setTunnelDeletePreview(null); setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); setDeleteTargetTunnelId(null); }, []); const handleDeleteModalOpenChange = useCallback( (open: boolean) => { setDeleteModalOpen(open); if (!open) { resetDeleteState(); } }, [resetDeleteState], ); const resetBatchDeleteState = useCallback(() => { setBatchDeletePreviewLoading(false); setBatchDeletePreview(null); setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); setBatchDeleteTargetTunnelId(null); }, []); const handleBatchDeleteModalOpenChange = useCallback( (open: boolean) => { setBatchDeleteModalOpen(open); if (!open) { resetBatchDeleteState(); } }, [resetBatchDeleteState], ); // 表单验证 const validateForm = (): boolean => { const newErrors = validateTunnelForm(form, nodes, isEdit); 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 || "", probeTargetHost: tunnel.probeTargetHost || "", probeTargetPort: tunnel.probeTargetPort || 0, status: tunnel.status, }); setErrors({}); setModalOpen(true); }; // 删除隧道 const handleDelete = async (tunnel: Tunnel) => { setTunnelToDelete(tunnel); setDeleteModalOpen(true); setDeletePreviewLoading(true); setTunnelDeletePreview(null); setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); setDeleteTargetTunnelId(null); try { const response = await previewTunnelDelete(tunnel.id); if (response.code !== 0 || !response.data) { toast.error(response.msg || "获取删除依赖失败"); setDeleteModalOpen(false); resetDeleteState(); return; } setTunnelDeletePreview(response.data); } catch (error) { toast.error(extractApiErrorMessage(error, "获取删除依赖失败")); setDeleteModalOpen(false); resetDeleteState(); } finally { setDeletePreviewLoading(false); } }; const confirmDelete = async () => { if (!tunnelToDelete) return; const forwardCount = tunnelDeletePreview?.forwardCount ?? 0; const action: TunnelDeleteAction = forwardCount > 0 ? deleteAction : "delete_forwards"; if ( action === "replace" && forwardCount > 0 && (!deleteTargetTunnelId || !deleteReplacementTunnels.some( (tunnel) => tunnel.id === deleteTargetTunnelId, )) ) { toast.error("请选择替换规则的目标隧道"); return; } setDeleteLoading(true); try { const response = await deleteTunnelWithForwards({ id: tunnelToDelete.id, action, targetTunnelId: action === "replace" ? (deleteTargetTunnelId ?? undefined) : undefined, }); if (response.code === 0) { const deleteResult = (response.data || null) as { warnings?: string[]; } | null; if ((deleteResult?.warnings?.length ?? 0) > 0) { toast.success( `删除成功,另有 ${deleteResult?.warnings?.length ?? 0} 条节点清理提示`, ); } else { toast.success("删除成功"); } setDeleteModalOpen(false); setTunnels((prev) => prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id), ); setTunnelOrder((prev) => { const next = prev.filter((id) => id !== tunnelToDelete.id); saveOrder(TUNNEL_ORDER_KEY, next); return next; }); setSelectedIds((prev) => { const next = new Set(prev); next.delete(tunnelToDelete.id); return next; }); resetDeleteState(); } else if ( response.data && typeof response.data === "object" && Number((response.data as { failCount?: number }).failCount ?? 0) > 0 ) { const result = response.data as { failCount?: number; successCount?: number; }; const failures = extractBatchFailures(response.data); if (failures.length > 0) { setBatchResultModal({ failures, open: true, summary: `成功 ${Number(result.successCount ?? 0)} 项,失败 ${Number(result.failCount ?? failures.length)} 项`, title: "规则处理失败", }); } toast.error(response.msg || "删除失败"); } else { toast.error(response.msg || "删除失败"); } } catch (error) { toast.error(extractApiErrorMessage(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 probeTargetHost = (form.probeTargetHost || "").trim(); const probeTargetPort = probeTargetHost ? Number(form.probeTargetPort || 0) : 0; const data = { ...form, inIp: inIpString, outNodeId: cleanedOutNodeId, chainNodes: cleanedChainNodes, probeTargetHost, probeTargetPort, }; const response = isEdit ? await updateTunnel(data) : await createTunnel(data); if (response.code === 0) { toast.success(isEdit ? "更新成功" : "创建成功"); setModalOpen(false); await refreshTunnelList(false); } else { toast.error(response.msg || (isEdit ? "更新失败" : "创建失败")); } } catch { toast.error("网络错误,请重试"); } finally { setSubmitLoading(false); } }; // 诊断隧道 const handleDiagnose = async (tunnel: Tunnel) => { diagnosisAbortRef.current?.abort(); const abortController = new AbortController(); const diagnosisTarget = getTunnelDiagnosisTarget(tunnel); 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 || "诊断过程中发生错误", ...diagnosisTarget, }), ); 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: "无法连接到服务器", ...diagnosisTarget, }), ); 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_KEY, 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 openBatchResultModal = useCallback( (title: string, summary: string, failures: BatchOperationFailure[]) => { setBatchResultModal({ failures, open: true, summary, title, }); }, [], ); const handleOpenBatchDeleteModal = async () => { if (selectedIds.size === 0) return; setBatchDeleteModalOpen(true); setBatchDeletePreviewLoading(true); setBatchDeletePreview(null); setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); setBatchDeleteTargetTunnelId(null); try { const response = await previewBatchTunnelDelete(selectedTunnelIdList); if (response.code !== 0 || !response.data) { toast.error(response.msg || "获取批量删除依赖失败"); setBatchDeleteModalOpen(false); resetBatchDeleteState(); return; } setBatchDeletePreview(response.data); } catch (error) { toast.error(extractApiErrorMessage(error, "获取批量删除依赖失败")); setBatchDeleteModalOpen(false); resetBatchDeleteState(); } finally { setBatchDeletePreviewLoading(false); } }; const handleBatchDelete = async () => { if (selectedIds.size === 0) return; if ( batchDeleteHasForwardDependencies && batchDeleteAction === "replace" && (!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable) ) { toast.error("请选择替换规则的目标隧道"); return; } setBatchLoading(true); setBatchProgress({ active: true, label: `正在删除 ${selectedIds.size} 条隧道...`, percent: 30, }); try { const res = await batchDeleteTunnelsWithForwards({ ids: selectedTunnelIdList, action: batchDeleteHasForwardDependencies ? batchDeleteAction : "delete_forwards", targetTunnelId: batchDeleteHasForwardDependencies && batchDeleteAction === "replace" ? (batchDeleteTargetTunnelId ?? undefined) : undefined, }); if (res.code === 0) { const result = (res.data || { successCount: 0, failCount: 0, warnings: [], }) as { successCount: number; failCount: number; warnings?: string[]; }; const warningCount = result?.warnings?.length ?? 0; if (result.failCount === 0) { toast.success( warningCount > 0 ? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示` : `成功删除 ${result.successCount} 项`, ); setBatchProgress({ active: true, label: `删除完成:成功 ${result.successCount} 项`, percent: 100, }); } else { const failures = extractBatchFailures(result); if (failures.length > 0) { openBatchResultModal( "批量删除结果", `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, failures, ); } else { toast.error( `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, ); } setBatchProgress({ active: true, label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`, percent: 75, }); } await refreshTunnelList(false); setSelectedIds(new Set()); setSelectMode(false); setBatchDeleteModalOpen(false); resetBatchDeleteState(); } else { toast.error(res.msg || "删除失败"); } } catch (error) { toast.error(extractApiErrorMessage(error, "删除失败")); } finally { setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; const handleBatchRedeploy = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); setBatchProgress({ active: true, label: `正在重新下发 ${selectedIds.size} 条隧道...`, percent: 30, }); 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 { const failures = extractBatchFailures(result); if (failures.length > 0) { openBatchResultModal( "批量下发结果", `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, failures, ); } else { toast.error( buildBatchFailureMessage( result, `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, ), ); } } setSelectedIds(new Set()); setSelectMode(false); setBatchProgress({ active: true, label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`, percent: 100, }); await refreshTunnelList(false); } else { toast.error(res.msg || "下发失败"); } } catch (error) { toast.error(extractApiErrorMessage(error, "下发失败")); } finally { setBatchProgress({ active: false, label: "", percent: 0 }); 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 deleteReplacementTunnels = useMemo(() => { if (!tunnelToDelete) { return [] as Tunnel[]; } return tunnels .filter( (tunnel) => tunnel.id !== tunnelToDelete.id && tunnel.status === 1, ) .sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); }, [tunnelToDelete, tunnels]); useEffect(() => { if (!deleteModalOpen) { return; } if ((tunnelDeletePreview?.forwardCount ?? 0) <= 0) { return; } if (deleteReplacementTunnels.length === 0) { setDeleteAction("delete_forwards"); setDeleteTargetTunnelId(null); return; } if (deleteAction !== "replace") { return; } setDeleteTargetTunnelId((prev) => { if ( prev && deleteReplacementTunnels.some((tunnel) => tunnel.id === prev) ) { return prev; } return deleteReplacementTunnels[0]?.id ?? null; }); }, [ deleteAction, deleteModalOpen, deleteReplacementTunnels, tunnelDeletePreview?.forwardCount, ]); const deletePreviewForwardCount = tunnelDeletePreview?.forwardCount ?? 0; const deleteHasForwardDependencies = deletePreviewForwardCount > 0; const deleteReplaceUnavailable = deleteHasForwardDependencies && deleteReplacementTunnels.length === 0; const deleteConfirmLabel = deleteHasForwardDependencies ? deleteAction === "replace" ? "迁移规则后删除该隧道" : "删除规则并删除该隧道" : "删除该隧道"; const selectedTunnelIdList = useMemo( () => Array.from(selectedIds), [selectedIds], ); const batchDeleteReplacementTunnels = useMemo(() => { if (selectedIds.size === 0) { return [] as Tunnel[]; } return tunnels .filter((tunnel) => !selectedIds.has(tunnel.id) && tunnel.status === 1) .sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); }, [selectedIds, tunnels]); useEffect(() => { if (!batchDeleteModalOpen) { return; } if ((batchDeletePreview?.totalForwardCount ?? 0) <= 0) { return; } if (batchDeleteReplacementTunnels.length === 0) { setBatchDeleteAction("delete_forwards"); setBatchDeleteTargetTunnelId(null); return; } if (batchDeleteAction !== "replace") { return; } setBatchDeleteTargetTunnelId((prev) => { if ( prev && batchDeleteReplacementTunnels.some((tunnel) => tunnel.id === prev) ) { return prev; } return batchDeleteReplacementTunnels[0]?.id ?? null; }); }, [ batchDeleteAction, batchDeleteModalOpen, batchDeletePreview?.totalForwardCount, batchDeleteReplacementTunnels, ]); const batchDeleteTotalForwardCount = batchDeletePreview?.totalForwardCount ?? 0; const batchDeleteHasForwardDependencies = batchDeleteTotalForwardCount > 0; const batchDeleteDependentTunnelCount = batchDeletePreview?.items?.filter((item) => item.forwardCount > 0).length ?? 0; const batchDeleteDirectDeleteTunnelCount = Math.max( selectedTunnelIdList.length - batchDeleteDependentTunnelCount, 0, ); const batchDeletePreviewItems = useMemo(() => { return [...(batchDeletePreview?.items ?? [])].sort((a, b) => { if (a.forwardCount > 0 === b.forwardCount > 0) { return a.tunnelName.localeCompare(b.tunnelName, "zh-CN"); } return a.forwardCount > 0 ? -1 : 1; }); }, [batchDeletePreview?.items]); const batchDeleteDependentItems = useMemo( () => batchDeletePreviewItems.filter((item) => item.forwardCount > 0), [batchDeletePreviewItems], ); const batchDeleteReplaceUnavailable = batchDeleteHasForwardDependencies && batchDeleteReplacementTunnels.length === 0; const batchDeleteConfirmLabel = batchDeleteHasForwardDependencies ? batchDeleteAction === "replace" ? `迁移规则后删除这 ${selectedTunnelIdList.length} 条隧道` : `删除规则并删除 ${selectedTunnelIdList.length} 条隧道` : `删除这 ${selectedTunnelIdList.length} 条隧道`; 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} 项 ) : ( <> )}
{batchProgress.active && (
)} {/* 隧道卡片网格 */} {tunnels.length > 0 ? ( viewMode === "list" ? ( {selectMode ? ( 0 } onValueChange={(checked) => checked ? selectAll() : deselectAll() } /> ) : ( )} 隧道名称 类型 拓扑 流量统计 操作 {(tunnel) => { const typeDisplay = getTunnelTypeDisplay(tunnel.type); const tunnelTypeChipClassName = tunnel.type === 1 ? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700" : "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700"; const bestExitStateContent = renderBestExitState( tunnel.bestExitState, ); return ( {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} 出口
{bestExitStateContent}
{getTunnelFlowDisplay(tunnel.flow)} | {tunnel.trafficRatio}x {tunnel.type === 2 && tunnel.ipPreference && ( <> | {tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"} )}
); }}
) : (
{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"; const bestExitStateContent = renderBestExitState( tunnel.bestExitState, ); 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} 出口
{bestExitStateContent && (
{bestExitStateContent}
)}
{/* 流量配置 */}
流量计算
{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 })) } />

入口配置

{/* 隧道转发时显示转发链配置 */} {form.type === 2 && ( <>

转发链配置

{getChainGroups().length > 0 && (
{getChainGroups().map((groupNodes, groupIndex) => { const protocol = groupNodes.length > 0 ? groupNodes[0].protocol || "tls" : "tls"; const strategy = groupNodes.length > 0 ? groupNodes[0].strategy || "round" : "round"; const groupSelectedNodeIds = groupNodes .filter((ct) => ct.nodeId !== -1) .map((ct) => ct.nodeId); const groupIpOptions = getCommonIpOptions(groupSelectedNodeIds); const isMultiNodeGroup = groupSelectedNodeIds.length > 1; const selectedGroupConnectIp = groupNodes.length > 0 ? groupNodes[0].connectIp || "" : ""; return (
第{groupIndex + 1}跳
{/* 节点选择 - 移动端100%,桌面端50% */}
{/* 协议选择 - 25% */} {/* 负载策略 - 25% */}
{/* 连接IP - 转发链节点 */}
); })}
)} {getChainGroups().length === 0 && (

还没有添加转发链,点击上方"添加一跳"按钮开始添加

)} )} {/* 隧道转发时显示出口配置 */} {form.type === 2 && ( <>

出口配置

{(() => { const selectedOutNodeIds = (form.outNodeId || []) .filter((ct) => ct.nodeId !== -1) .map((ct) => ct.nodeId); const isMultiExit = selectedOutNodeIds.length > 1; const commonOutIpOptions = getCommonIpOptions(selectedOutNodeIds); return ( <>
{/* 节点选择 - 移动端100%,桌面端50% */}
{/* 协议选择 - 25% */} {/* 负载策略 - 25% */}
{/* 连接IP - 出口节点 */} ); })()} )} 高级设置 } >
setForm((prev) => ({ ...prev, trafficRatio: parseFloat(e.target.value) || 0, })) } />