From ef613c1518104bf4553d0dea4a5beceb8968bdc0 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 15:38:03 +0800 Subject: [PATCH 1/3] feat: add ny format support for forward import with node-based tunnel matching --- vite-frontend/src/api/diagnosis-stream.ts | 89 ++- vite-frontend/src/pages/forward.tsx | 682 +++++++++++++----- .../src/pages/forward/import-format.test.ts | 62 ++ .../src/pages/forward/import-format.ts | 169 +++++ vite-frontend/src/pages/limit.tsx | 1 + vite-frontend/src/pages/node.tsx | 7 +- vite-frontend/src/pages/tunnel.tsx | 49 +- 7 files changed, 854 insertions(+), 205 deletions(-) create mode 100644 vite-frontend/src/pages/forward/import-format.test.ts create mode 100644 vite-frontend/src/pages/forward/import-format.ts diff --git a/vite-frontend/src/api/diagnosis-stream.ts b/vite-frontend/src/api/diagnosis-stream.ts index 8b8dec7..d261e15 100644 --- a/vite-frontend/src/api/diagnosis-stream.ts +++ b/vite-frontend/src/api/diagnosis-stream.ts @@ -1,6 +1,7 @@ +import type { TunnelDiagnosisApiItem } from "@/api/types"; + import axios from "axios"; -import type { TunnelDiagnosisApiItem } from "@/api/types"; import { clearSession, getToken } from "@/utils/session"; const DIAGNOSIS_STREAM_TIMEOUT_MS = 2 * 60 * 1000; @@ -106,13 +107,16 @@ const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => { controller.abort(); } }; + signals.forEach((signal) => { if (signal.aborted) { onAbort(); + return; } signal.addEventListener("abort", onAbort, { once: true }); }); + return controller.signal; }; @@ -120,6 +124,7 @@ const parseMessage = (err: unknown, fallback: string): string => { if (err instanceof Error && err.message) { return err.message; } + return fallback; }; @@ -133,7 +138,12 @@ const runDiagnosisStream = async ({ onError, }: RunDiagnosisStreamOptions): Promise => { if (!isStreamSupported()) { - return { fallback: true, completed: false, timedOut: false, receivedItems: 0 }; + return { + fallback: true, + completed: false, + timedOut: false, + receivedItems: 0, + }; } let receivedItems = 0; @@ -170,27 +180,51 @@ const runDiagnosisStream = async ({ if (response.status === 401) { handleTokenExpired(); - return { fallback: false, completed: false, timedOut: false, receivedItems }; + + return { + fallback: false, + completed: false, + timedOut: false, + receivedItems, + }; } if (response.status === 404) { - return { fallback: true, completed: false, timedOut: false, receivedItems }; + return { + fallback: true, + completed: false, + timedOut: false, + receivedItems, + }; } if (!response.ok || !response.body) { const fallbackMessage = `请求失败(${response.status})`; let message = fallbackMessage; + try { const data = (await response.json()) as RawObject; + if (typeof data.msg === "string" && data.msg.trim()) { message = data.msg; } } catch {} if (receivedItems === 0) { - return { fallback: true, completed: false, timedOut: false, receivedItems }; + return { + fallback: true, + completed: false, + timedOut: false, + receivedItems, + }; } onError?.(message); - return { fallback: false, completed: false, timedOut: false, receivedItems }; + + return { + fallback: false, + completed: false, + timedOut: false, + receivedItems, + }; } const reader = response.body.getReader(); @@ -202,6 +236,7 @@ const runDiagnosisStream = async ({ return; } let parsed: DiagnosisStreamRawEvent; + try { parsed = JSON.parse(line) as DiagnosisStreamRawEvent; } catch { @@ -209,15 +244,18 @@ const runDiagnosisStream = async ({ } const eventType = (parsed.type || "").toLowerCase(); + if (eventType === "start") { if (parsed.data && typeof parsed.data === "object") { const startData = parsed.data as RawObject; const startTotal = Number(startData.total); + if (Number.isFinite(startTotal) && startTotal >= 0) { currentProgress = { ...currentProgress, total: startTotal }; } onStart?.(startData); } + return; } @@ -228,10 +266,12 @@ const runDiagnosisStream = async ({ const itemData = parsed.data as RawObject; const index = Number(itemData.index); const result = itemData.result as TunnelDiagnosisApiItem | undefined; + if (!Number.isFinite(index) || !result || typeof result !== "object") { return; } const progress = normalizeProgress(itemData.progress, currentProgress); + currentProgress = progress; receivedItems += 1; onItem({ @@ -239,6 +279,7 @@ const runDiagnosisStream = async ({ result, progress, }); + return; } @@ -252,6 +293,7 @@ const runDiagnosisStream = async ({ donePayload.progress ?? donePayload, currentProgress, ); + if (typeof donePayload.timedOut === "boolean") { doneProgress.timedOut = donePayload.timedOut; timedOut = donePayload.timedOut; @@ -263,16 +305,19 @@ const runDiagnosisStream = async ({ while (true) { const { value, done } = await reader.read(); + if (done) { break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; lines.forEach((line) => processLine(line.trim())); } const tail = buffer.trim(); + if (tail) { processLine(tail); } @@ -282,6 +327,7 @@ const runDiagnosisStream = async ({ ...currentProgress, timedOut: true, }; + onDone?.(timeoutProgress); } @@ -297,20 +343,43 @@ const runDiagnosisStream = async ({ ...currentProgress, timedOut: true, }; + onDone?.(timeoutProgress); - return { fallback: false, completed: false, timedOut: true, receivedItems }; + + return { + fallback: false, + completed: false, + timedOut: true, + receivedItems, + }; } if (signal?.aborted) { - return { fallback: false, completed: false, timedOut: false, receivedItems }; + return { + fallback: false, + completed: false, + timedOut: false, + receivedItems, + }; } if (receivedItems === 0) { - return { fallback: true, completed: false, timedOut: false, receivedItems }; + return { + fallback: true, + completed: false, + timedOut: false, + receivedItems, + }; } onError?.(parseMessage(error, "流式诊断中断")); - return { fallback: false, completed: false, timedOut: false, receivedItems }; + + return { + fallback: false, + completed: false, + timedOut: false, + receivedItems, + }; } finally { clearTimeout(timeoutId); } diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 66e80a5..e7d73ae 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -87,6 +87,10 @@ import { executeForwardBatchRedeploy, executeForwardBatchToggleService, } from "@/pages/forward/batch-actions"; +import { + convertNyItemToForwardInput, + parseNyFormatData, +} from "@/pages/forward/import-format"; import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order"; import { PageLoadingState } from "@/components/page-state"; import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; @@ -119,6 +123,7 @@ interface Forward { interface Tunnel { id: number; name: string; + type?: number; inIp?: string; inNodeId?: Array<{ nodeId: number }>; inNodePortSta?: number; @@ -127,6 +132,7 @@ interface Tunnel { interface Node { id: number; + name?: string; serverIp?: string; serverIpV4?: string; serverIpV6?: string; @@ -168,8 +174,7 @@ const FORWARD_COMPACT_MODE_EVENT = "forwardCompactModeChanged"; const FORWARD_GROUP_ORDER_CONFIG_KEY = "forward_group_order_map"; const FORWARD_GROUP_COLLAPSED_CONFIG_KEY = "forward_group_collapsed_map"; const FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = "forward-group-order"; -const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = - "forward-group-collapsed"; +const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = "forward-group-collapsed"; const FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = "forward-tunnel-group"; const FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = "min-w-[1320px]"; const FORWARD_GROUPED_TABLE_COLUMN_CLASS = { @@ -239,7 +244,9 @@ const buildForwardGroupCollapsedLocalKey = (tokenUserId: number): string => { return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; }; -const parsePreferenceMap = (raw: string | null): Record | null => { +const parsePreferenceMap = ( + raw: string | null, +): Record | null => { if (!raw) { return null; } @@ -283,7 +290,9 @@ const parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => { return result; }; -const parseGroupCollapsedMap = (raw: string | null): ForwardGroupCollapsedMap => { +const parseGroupCollapsedMap = ( + raw: string | null, +): ForwardGroupCollapsedMap => { const parsed = parsePreferenceMap(raw); if (!parsed) { @@ -357,11 +366,17 @@ const sanitizeGroupCollapsedMap = ( return sanitized; }; -const buildTunnelGroupCollapseKey = (userId: number, tunnelKey: string): string => { +const buildTunnelGroupCollapseKey = ( + userId: number, + tunnelKey: string, +): string => { return `${userId}:${tunnelKey}`; }; -const buildTunnelGroupSortableId = (userId: number, tunnelKey: string): string => { +const buildTunnelGroupSortableId = ( + userId: number, + tunnelKey: string, +): string => { return `${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:${userId}:${tunnelKey}`; }; @@ -392,7 +407,9 @@ const parseTunnelGroupSortableId = ( return { userId, tunnelKey }; }; -const buildAvailableGroupData = (forwards: Forward[]): { +const buildAvailableGroupData = ( + forwards: Forward[], +): { availableTunnelKeysByUser: Map>; availableCollapseKeys: Set; } => { @@ -541,9 +558,16 @@ export default function ForwardPage() { >(null); // 导入相关状态 + type ImportFormat = "flvx" | "ny"; const [importModalOpen, setImportModalOpen] = useState(false); const [importData, setImportData] = useState(""); const [importLoading, setImportLoading] = useState(false); + const [importFormat, setImportFormat] = useState("flvx"); + const [selectedEntryNode, setSelectedEntryNode] = useState( + null, + ); + const [matchedTunnels, setMatchedTunnels] = useState([]); + const [tunnelSelectModalOpen, setTunnelSelectModalOpen] = useState(false); const [selectedTunnelForImport, setSelectedTunnelForImport] = useState< number | null >(null); @@ -724,7 +748,9 @@ export default function ForwardPage() { } try { - const currentRes = await getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY); + const currentRes = await getConfigByName( + FORWARD_GROUP_COLLAPSED_CONFIG_KEY, + ); const globalMap = parsePreferenceMap( currentRes.code === 0 && typeof currentRes.data?.value === "string" @@ -793,12 +819,13 @@ export default function ForwardPage() { ? globalOrderRes.data.value : null, ); - const globalCollapsedMap = parsePreferenceMap( - globalCollapsedRes.code === 0 && - typeof globalCollapsedRes.data?.value === "string" - ? globalCollapsedRes.data.value - : null, - ); + const globalCollapsedMap = + parsePreferenceMap( + globalCollapsedRes.code === 0 && + typeof globalCollapsedRes.data?.value === "string" + ? globalCollapsedRes.data.value + : null, + ); const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()]; const globalCollapsedBucket = @@ -809,7 +836,9 @@ export default function ForwardPage() { typeof globalOrderBucket === "object" && !Array.isArray(globalOrderBucket) ) { - localOrderMap = parseGroupOrderMap(JSON.stringify(globalOrderBucket)); + localOrderMap = parseGroupOrderMap( + JSON.stringify(globalOrderBucket), + ); } if ( @@ -1147,7 +1176,10 @@ export default function ForwardPage() { } else { } - if (allTunnelsRes.status === "fulfilled" && allTunnelsRes.value.code === 0) { + if ( + allTunnelsRes.status === "fulfilled" && + allTunnelsRes.value.code === 0 + ) { setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]); } @@ -1454,6 +1486,7 @@ export default function ForwardPage() { const handleDiagnose = async (forward: Forward) => { diagnosisAbortRef.current?.abort(); const abortController = new AbortController(); + diagnosisAbortRef.current = abortController; setCurrentDiagnosisForward(forward); @@ -1487,6 +1520,7 @@ export default function ForwardPage() { const startItems = Array.isArray(payload.items) ? (payload.items as ForwardDiagnosisResult["results"]) : []; + setDiagnosisResult((prev) => ({ forwardName: startForwardName, timestamp: Date.now(), @@ -1526,6 +1560,7 @@ export default function ForwardPage() { diagnosing: false, }); } + return { ...base, timestamp: Date.now(), @@ -1561,8 +1596,11 @@ export default function ForwardPage() { if (response.code === 0) { const resultData = response.data as ForwardDiagnosisResult; - const successCount = resultData.results.filter((r) => r.success).length; + const successCount = resultData.results.filter( + (r) => r.success, + ).length; const failedCount = resultData.results.length - successCount; + setDiagnosisResult(resultData); setDiagnosisProgress({ total: resultData.results.length, @@ -1772,129 +1810,208 @@ export default function ForwardPage() { } setImportLoading(true); - setImportResults([]); // 清空之前的结果 + setImportResults([]); try { - const lines = importData - .trim() - .split("\n") - .filter((line) => line.trim()); + if (importFormat === "ny") { + const parsedItems = parseNyFormatData(importData); - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - const parts = line.split("|"); + if (parsedItems.length === 0) { + toast.error("未解析到有效的ny格式数据"); - if (parts.length < 2) { - setImportResults((prev) => [ - { - line, - success: false, - message: "格式错误:需要至少包含目标地址和转发名称", - }, - ...prev, - ]); - continue; + setImportLoading(false); + + return; } - const [remoteAddr, name, inPort] = parts; - - if (!remoteAddr.trim() || !name.trim()) { - setImportResults((prev) => [ - { - line, - success: false, - message: "目标地址和转发名称不能为空", - }, - ...prev, - ]); - continue; - } - - // 验证远程地址格式 - 支持单个地址或多个地址用逗号分隔 - const addresses = remoteAddr.trim().split(","); - const addressPattern = /^[^:]+:\d+$/; - const isValidFormat = addresses.every((addr) => - addressPattern.test(addr.trim()), - ); - - if (!isValidFormat) { - setImportResults((prev) => [ - { - line, - success: false, - message: - "目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔", - }, - ...prev, - ]); - continue; - } - - try { - // 处理入口端口 - let portNumber: number | null = null; - - if (inPort && inPort.trim()) { - const port = parseInt(inPort.trim()); - - if (isNaN(port) || port < 1 || port > 65535) { - setImportResults((prev) => [ - { - line, - success: false, - message: "入口端口格式错误,应为1-65535之间的数字", - }, - ...prev, - ]); - continue; - } - portNumber = port; - } - - // 调用创建转发接口 - const response = await createForward({ - name: name.trim(), - tunnelId: selectedTunnelForImport, // 使用用户选择的隧道 - inPort: portNumber, // 使用指定端口或自动分配 - remoteAddr: remoteAddr.trim(), - strategy: "fifo", - }); - - if (response.code === 0) { + for (const item of parsedItems) { + if (item.error) { setImportResults((prev) => [ { - line, - success: true, - message: "创建成功", - forwardName: name.trim(), + line: item.line, + success: false, + message: item.error || "解析失败", }, ...prev, ]); - } else { + + continue; + } + + if (!item.parsed) { + setImportResults((prev) => [ + { + line: item.line, + success: false, + message: "解析失败", + }, + ...prev, + ]); + + continue; + } + + const parsedNyItem = item.parsed; + const nyForwardInput = convertNyItemToForwardInput(parsedNyItem); + + try { + const response = await createForward({ + name: nyForwardInput.name, + tunnelId: selectedTunnelForImport, + inPort: nyForwardInput.inPort, + remoteAddr: nyForwardInput.remoteAddr, + strategy: nyForwardInput.strategy, + }); + + if (response.code === 0) { + setImportResults((prev) => [ + { + line: item.line, + success: true, + message: `创建成功 (${parsedNyItem.dest.length}个目标)`, + forwardName: nyForwardInput.name, + }, + ...prev, + ]); + } else { + setImportResults((prev) => [ + { + line: item.line, + success: false, + message: response.msg || "创建失败", + }, + ...prev, + ]); + } + } catch { + setImportResults((prev) => [ + { + line: item.line, + success: false, + message: "网络错误,创建失败", + }, + ...prev, + ]); + } + } + } else { + const lines = importData + .trim() + .split("\n") + .filter((line) => line.trim()); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + const parts = line.split("|"); + + if (parts.length < 2) { setImportResults((prev) => [ { line, success: false, - message: response.msg || "创建失败", + message: "格式错误:需要至少包含目标地址和转发名称", + }, + ...prev, + ]); + continue; + } + + const [remoteAddr, name, inPort] = parts; + + if (!remoteAddr.trim() || !name.trim()) { + setImportResults((prev) => [ + { + line, + success: false, + message: "目标地址和转发名称不能为空", + }, + ...prev, + ]); + continue; + } + + const addresses = remoteAddr.trim().split(","); + const addressPattern = /^[^:]+:\d+$/; + const isValidFormat = addresses.every((addr) => + addressPattern.test(addr.trim()), + ); + + if (!isValidFormat) { + setImportResults((prev) => [ + { + line, + success: false, + message: + "目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔", + }, + ...prev, + ]); + continue; + } + + try { + let portNumber: number | null = null; + + if (inPort && inPort.trim()) { + const port = parseInt(inPort.trim()); + + if (isNaN(port) || port < 1 || port > 65535) { + setImportResults((prev) => [ + { + line, + success: false, + message: "入口端口格式错误,应为1-65535之间的数字", + }, + ...prev, + ]); + continue; + } + portNumber = port; + } + + const response = await createForward({ + name: name.trim(), + tunnelId: selectedTunnelForImport, + inPort: portNumber, + remoteAddr: remoteAddr.trim(), + strategy: "fifo", + }); + + if (response.code === 0) { + setImportResults((prev) => [ + { + line, + success: true, + message: "创建成功", + forwardName: name.trim(), + }, + ...prev, + ]); + } else { + setImportResults((prev) => [ + { + line, + success: false, + message: response.msg || "创建失败", + }, + ...prev, + ]); + } + } catch { + setImportResults((prev) => [ + { + line, + success: false, + message: "网络错误,创建失败", }, ...prev, ]); } - } catch { - setImportResults((prev) => [ - { - line, - success: false, - message: "网络错误,创建失败", - }, - ...prev, - ]); } } - toast.success(`导入执行完成`); + toast.success("导入执行完成"); - // 导入完成后刷新转发列表 await loadData(false); } catch { toast.error("导入过程中发生错误"); @@ -1998,7 +2115,9 @@ export default function ForwardPage() { const activeTunnelGroupKey = buildForwardTunnelGroupKey( activeForward?.tunnelName, ); - const overTunnelGroupKey = buildForwardTunnelGroupKey(overForward?.tunnelName); + const overTunnelGroupKey = buildForwardTunnelGroupKey( + overForward?.tunnelName, + ); // 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排 if (!compactMode) { @@ -2286,13 +2405,7 @@ export default function ForwardPage() { } return sortedByDb; - }, [ - forwards, - forwardOrder, - filterUserId, - filterTunnelId, - searchKeyword, - ]); + }, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]); const availableGroupData = useMemo( () => buildAvailableGroupData(forwards), @@ -2429,7 +2542,10 @@ export default function ForwardPage() { return aIndex - bIndex; } - const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName); + const nameCompare = compareForwardTunnelNameAsc( + a.tunnelName, + b.tunnelName, + ); if (nameCompare !== 0) { return nameCompare; @@ -2522,7 +2638,10 @@ export default function ForwardPage() { bodyClassName: string; children: React.ReactNode; }) => { - const sortableId = buildTunnelGroupSortableId(groupUserId, tunnel.tunnelKey); + const sortableId = buildTunnelGroupSortableId( + groupUserId, + tunnel.tunnelKey, + ); const { attributes, listeners, @@ -2639,6 +2758,7 @@ export default function ForwardPage() { if (!existingUser) { userMap.set(uId, { id: uId, name: userName }); + return; } @@ -3620,7 +3740,9 @@ export default function ForwardPage() { }} > - {selectMode && 选择} + {selectMode && ( + 选择 + )} 用户 名称 @@ -3680,7 +3802,10 @@ export default function ForwardPage() { onDragEnd={handleDragEnd} onDragStart={() => {}} > - +
{sortedForwards.map((forward) => forward && forward.id ? ( @@ -3742,7 +3867,10 @@ export default function ForwardPage() { > - buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey), + buildTunnelGroupSortableId( + group.userId, + tunnel.tunnelKey, + ), )} strategy={verticalListSortingStrategy} > @@ -3801,10 +3929,14 @@ export default function ForwardPage() { )} 名称 @@ -3816,7 +3948,9 @@ export default function ForwardPage() { 入口 目标 @@ -3835,7 +3969,9 @@ export default function ForwardPage() { 总流量 状态 @@ -3860,14 +3996,22 @@ export default function ForwardPage() { - buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey), + buildTunnelGroupSortableId( + group.userId, + tunnel.tunnelKey, + ), )} strategy={verticalListSortingStrategy} > @@ -3949,7 +4096,10 @@ export default function ForwardPage() { .filter((id) => id > 0); const collapsed = sanitizedCollapsedTunnelGroups[ - buildTunnelGroupCollapseKey(group.userId, tunnel.tunnelKey) + buildTunnelGroupCollapseKey( + group.userId, + tunnel.tunnelKey, + ) ] === true; return ( @@ -3964,7 +4114,10 @@ export default function ForwardPage() { tunnel={tunnel} wrapperClassName="rounded-xl border border-secondary/20 bg-secondary/5 space-y-3" onToggleCollapsed={() => - toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey) + toggleTunnelGroupCollapsed( + group.userId, + tunnel.tunnelKey, + ) } >

导入转发数据

-

- 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口 -

-

- 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4) -

+ {importFormat === "flvx" ? ( + <> +

+ 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口 +

+

+ 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4) +

+ + ) : ( + <> +

+ ny格式:JSON对象,支持多个目标地址(负载均衡) +

+

+ 格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"} +

+ + )}
- {/* 隧道选择 */} -
+ {/* 格式选择 */} + + + {/* flvx格式:隧道选择 */} + {importFormat === "flvx" && ( -
+ )} + + {/* ny格式:入口节点选择 */} + {importFormat === "ny" && ( + + )} + + {/* ny格式:显示匹配的隧道 */} + {importFormat === "ny" && matchedTunnels.length > 0 && ( +
+ {matchedTunnels.length === 1 ? ( + + 已匹配隧道:{matchedTunnels[0].name} + + ) : ( + + 找到 {matchedTunnels.length}{" "} + 个匹配隧道,请点击下方按钮选择 + + )} +
+ )} + + {/* ny格式:多隧道选择按钮 */} + {importFormat === "ny" && + matchedTunnels.length > 1 && + !selectedTunnelForImport && ( + + )} {/* 输入区域 */} -
-