feat: add ny format support for forward import with node-based tunnel matching

This commit is contained in:
sagitchu
2026-03-03 15:38:03 +08:00
parent be9d8773ce
commit ef613c1518
7 changed files with 854 additions and 205 deletions
+79 -10
View File
@@ -1,6 +1,7 @@
import type { TunnelDiagnosisApiItem } from "@/api/types";
import axios from "axios"; import axios from "axios";
import type { TunnelDiagnosisApiItem } from "@/api/types";
import { clearSession, getToken } from "@/utils/session"; import { clearSession, getToken } from "@/utils/session";
const DIAGNOSIS_STREAM_TIMEOUT_MS = 2 * 60 * 1000; const DIAGNOSIS_STREAM_TIMEOUT_MS = 2 * 60 * 1000;
@@ -106,13 +107,16 @@ const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => {
controller.abort(); controller.abort();
} }
}; };
signals.forEach((signal) => { signals.forEach((signal) => {
if (signal.aborted) { if (signal.aborted) {
onAbort(); onAbort();
return; return;
} }
signal.addEventListener("abort", onAbort, { once: true }); signal.addEventListener("abort", onAbort, { once: true });
}); });
return controller.signal; return controller.signal;
}; };
@@ -120,6 +124,7 @@ const parseMessage = (err: unknown, fallback: string): string => {
if (err instanceof Error && err.message) { if (err instanceof Error && err.message) {
return err.message; return err.message;
} }
return fallback; return fallback;
}; };
@@ -133,7 +138,12 @@ const runDiagnosisStream = async ({
onError, onError,
}: RunDiagnosisStreamOptions): Promise<DiagnosisStreamRunResult> => { }: RunDiagnosisStreamOptions): Promise<DiagnosisStreamRunResult> => {
if (!isStreamSupported()) { if (!isStreamSupported()) {
return { fallback: true, completed: false, timedOut: false, receivedItems: 0 }; return {
fallback: true,
completed: false,
timedOut: false,
receivedItems: 0,
};
} }
let receivedItems = 0; let receivedItems = 0;
@@ -170,27 +180,51 @@ const runDiagnosisStream = async ({
if (response.status === 401) { if (response.status === 401) {
handleTokenExpired(); handleTokenExpired();
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
} }
if (response.status === 404) { 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) { if (!response.ok || !response.body) {
const fallbackMessage = `请求失败(${response.status})`; const fallbackMessage = `请求失败(${response.status})`;
let message = fallbackMessage; let message = fallbackMessage;
try { try {
const data = (await response.json()) as RawObject; const data = (await response.json()) as RawObject;
if (typeof data.msg === "string" && data.msg.trim()) { if (typeof data.msg === "string" && data.msg.trim()) {
message = data.msg; message = data.msg;
} }
} catch {} } catch {}
if (receivedItems === 0) { if (receivedItems === 0) {
return { fallback: true, completed: false, timedOut: false, receivedItems }; return {
fallback: true,
completed: false,
timedOut: false,
receivedItems,
};
} }
onError?.(message); onError?.(message);
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
} }
const reader = response.body.getReader(); const reader = response.body.getReader();
@@ -202,6 +236,7 @@ const runDiagnosisStream = async ({
return; return;
} }
let parsed: DiagnosisStreamRawEvent; let parsed: DiagnosisStreamRawEvent;
try { try {
parsed = JSON.parse(line) as DiagnosisStreamRawEvent; parsed = JSON.parse(line) as DiagnosisStreamRawEvent;
} catch { } catch {
@@ -209,15 +244,18 @@ const runDiagnosisStream = async ({
} }
const eventType = (parsed.type || "").toLowerCase(); const eventType = (parsed.type || "").toLowerCase();
if (eventType === "start") { if (eventType === "start") {
if (parsed.data && typeof parsed.data === "object") { if (parsed.data && typeof parsed.data === "object") {
const startData = parsed.data as RawObject; const startData = parsed.data as RawObject;
const startTotal = Number(startData.total); const startTotal = Number(startData.total);
if (Number.isFinite(startTotal) && startTotal >= 0) { if (Number.isFinite(startTotal) && startTotal >= 0) {
currentProgress = { ...currentProgress, total: startTotal }; currentProgress = { ...currentProgress, total: startTotal };
} }
onStart?.(startData); onStart?.(startData);
} }
return; return;
} }
@@ -228,10 +266,12 @@ const runDiagnosisStream = async ({
const itemData = parsed.data as RawObject; const itemData = parsed.data as RawObject;
const index = Number(itemData.index); const index = Number(itemData.index);
const result = itemData.result as TunnelDiagnosisApiItem | undefined; const result = itemData.result as TunnelDiagnosisApiItem | undefined;
if (!Number.isFinite(index) || !result || typeof result !== "object") { if (!Number.isFinite(index) || !result || typeof result !== "object") {
return; return;
} }
const progress = normalizeProgress(itemData.progress, currentProgress); const progress = normalizeProgress(itemData.progress, currentProgress);
currentProgress = progress; currentProgress = progress;
receivedItems += 1; receivedItems += 1;
onItem({ onItem({
@@ -239,6 +279,7 @@ const runDiagnosisStream = async ({
result, result,
progress, progress,
}); });
return; return;
} }
@@ -252,6 +293,7 @@ const runDiagnosisStream = async ({
donePayload.progress ?? donePayload, donePayload.progress ?? donePayload,
currentProgress, currentProgress,
); );
if (typeof donePayload.timedOut === "boolean") { if (typeof donePayload.timedOut === "boolean") {
doneProgress.timedOut = donePayload.timedOut; doneProgress.timedOut = donePayload.timedOut;
timedOut = donePayload.timedOut; timedOut = donePayload.timedOut;
@@ -263,16 +305,19 @@ const runDiagnosisStream = async ({
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) { if (done) {
break; break;
} }
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; buffer = lines.pop() ?? "";
lines.forEach((line) => processLine(line.trim())); lines.forEach((line) => processLine(line.trim()));
} }
const tail = buffer.trim(); const tail = buffer.trim();
if (tail) { if (tail) {
processLine(tail); processLine(tail);
} }
@@ -282,6 +327,7 @@ const runDiagnosisStream = async ({
...currentProgress, ...currentProgress,
timedOut: true, timedOut: true,
}; };
onDone?.(timeoutProgress); onDone?.(timeoutProgress);
} }
@@ -297,20 +343,43 @@ const runDiagnosisStream = async ({
...currentProgress, ...currentProgress,
timedOut: true, timedOut: true,
}; };
onDone?.(timeoutProgress); onDone?.(timeoutProgress);
return { fallback: false, completed: false, timedOut: true, receivedItems };
return {
fallback: false,
completed: false,
timedOut: true,
receivedItems,
};
} }
if (signal?.aborted) { if (signal?.aborted) {
return { fallback: false, completed: false, timedOut: false, receivedItems }; return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
} }
if (receivedItems === 0) { if (receivedItems === 0) {
return { fallback: true, completed: false, timedOut: false, receivedItems }; return {
fallback: true,
completed: false,
timedOut: false,
receivedItems,
};
} }
onError?.(parseMessage(error, "流式诊断中断")); onError?.(parseMessage(error, "流式诊断中断"));
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
+504 -178
View File
@@ -87,6 +87,10 @@ import {
executeForwardBatchRedeploy, executeForwardBatchRedeploy,
executeForwardBatchToggleService, executeForwardBatchToggleService,
} from "@/pages/forward/batch-actions"; } from "@/pages/forward/batch-actions";
import {
convertNyItemToForwardInput,
parseNyFormatData,
} from "@/pages/forward/import-format";
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order"; import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
import { PageLoadingState } from "@/components/page-state"; import { PageLoadingState } from "@/components/page-state";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
@@ -119,6 +123,7 @@ interface Forward {
interface Tunnel { interface Tunnel {
id: number; id: number;
name: string; name: string;
type?: number;
inIp?: string; inIp?: string;
inNodeId?: Array<{ nodeId: number }>; inNodeId?: Array<{ nodeId: number }>;
inNodePortSta?: number; inNodePortSta?: number;
@@ -127,6 +132,7 @@ interface Tunnel {
interface Node { interface Node {
id: number; id: number;
name?: string;
serverIp?: string; serverIp?: string;
serverIpV4?: string; serverIpV4?: string;
serverIpV6?: 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_ORDER_CONFIG_KEY = "forward_group_order_map";
const FORWARD_GROUP_COLLAPSED_CONFIG_KEY = "forward_group_collapsed_map"; const FORWARD_GROUP_COLLAPSED_CONFIG_KEY = "forward_group_collapsed_map";
const FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = "forward-group-order"; const FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = "forward-group-order";
const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = "forward-group-collapsed";
"forward-group-collapsed";
const FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = "forward-tunnel-group"; const FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = "forward-tunnel-group";
const FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = "min-w-[1320px]"; const FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = "min-w-[1320px]";
const FORWARD_GROUPED_TABLE_COLUMN_CLASS = { const FORWARD_GROUPED_TABLE_COLUMN_CLASS = {
@@ -239,7 +244,9 @@ const buildForwardGroupCollapsedLocalKey = (tokenUserId: number): string => {
return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;
}; };
const parsePreferenceMap = <T,>(raw: string | null): Record<string, T> | null => { const parsePreferenceMap = <T,>(
raw: string | null,
): Record<string, T> | null => {
if (!raw) { if (!raw) {
return null; return null;
} }
@@ -283,7 +290,9 @@ const parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => {
return result; return result;
}; };
const parseGroupCollapsedMap = (raw: string | null): ForwardGroupCollapsedMap => { const parseGroupCollapsedMap = (
raw: string | null,
): ForwardGroupCollapsedMap => {
const parsed = parsePreferenceMap<unknown>(raw); const parsed = parsePreferenceMap<unknown>(raw);
if (!parsed) { if (!parsed) {
@@ -357,11 +366,17 @@ const sanitizeGroupCollapsedMap = (
return sanitized; return sanitized;
}; };
const buildTunnelGroupCollapseKey = (userId: number, tunnelKey: string): string => { const buildTunnelGroupCollapseKey = (
userId: number,
tunnelKey: string,
): string => {
return `${userId}:${tunnelKey}`; 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}`; return `${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:${userId}:${tunnelKey}`;
}; };
@@ -392,7 +407,9 @@ const parseTunnelGroupSortableId = (
return { userId, tunnelKey }; return { userId, tunnelKey };
}; };
const buildAvailableGroupData = (forwards: Forward[]): { const buildAvailableGroupData = (
forwards: Forward[],
): {
availableTunnelKeysByUser: Map<number, Set<string>>; availableTunnelKeysByUser: Map<number, Set<string>>;
availableCollapseKeys: Set<string>; availableCollapseKeys: Set<string>;
} => { } => {
@@ -541,9 +558,16 @@ export default function ForwardPage() {
>(null); >(null);
// 导入相关状态 // 导入相关状态
type ImportFormat = "flvx" | "ny";
const [importModalOpen, setImportModalOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false);
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
const [importLoading, setImportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false);
const [importFormat, setImportFormat] = useState<ImportFormat>("flvx");
const [selectedEntryNode, setSelectedEntryNode] = useState<number | null>(
null,
);
const [matchedTunnels, setMatchedTunnels] = useState<Tunnel[]>([]);
const [tunnelSelectModalOpen, setTunnelSelectModalOpen] = useState(false);
const [selectedTunnelForImport, setSelectedTunnelForImport] = useState< const [selectedTunnelForImport, setSelectedTunnelForImport] = useState<
number | null number | null
>(null); >(null);
@@ -724,7 +748,9 @@ export default function ForwardPage() {
} }
try { try {
const currentRes = await getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY); const currentRes = await getConfigByName(
FORWARD_GROUP_COLLAPSED_CONFIG_KEY,
);
const globalMap = const globalMap =
parsePreferenceMap<ForwardGroupCollapsedMap>( parsePreferenceMap<ForwardGroupCollapsedMap>(
currentRes.code === 0 && typeof currentRes.data?.value === "string" currentRes.code === 0 && typeof currentRes.data?.value === "string"
@@ -793,12 +819,13 @@ export default function ForwardPage() {
? globalOrderRes.data.value ? globalOrderRes.data.value
: null, : null,
); );
const globalCollapsedMap = parsePreferenceMap<ForwardGroupCollapsedMap>( const globalCollapsedMap =
globalCollapsedRes.code === 0 && parsePreferenceMap<ForwardGroupCollapsedMap>(
typeof globalCollapsedRes.data?.value === "string" globalCollapsedRes.code === 0 &&
? globalCollapsedRes.data.value typeof globalCollapsedRes.data?.value === "string"
: null, ? globalCollapsedRes.data.value
); : null,
);
const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()]; const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()];
const globalCollapsedBucket = const globalCollapsedBucket =
@@ -809,7 +836,9 @@ export default function ForwardPage() {
typeof globalOrderBucket === "object" && typeof globalOrderBucket === "object" &&
!Array.isArray(globalOrderBucket) !Array.isArray(globalOrderBucket)
) { ) {
localOrderMap = parseGroupOrderMap(JSON.stringify(globalOrderBucket)); localOrderMap = parseGroupOrderMap(
JSON.stringify(globalOrderBucket),
);
} }
if ( if (
@@ -1147,7 +1176,10 @@ export default function ForwardPage() {
} else { } else {
} }
if (allTunnelsRes.status === "fulfilled" && allTunnelsRes.value.code === 0) { if (
allTunnelsRes.status === "fulfilled" &&
allTunnelsRes.value.code === 0
) {
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]); setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
} }
@@ -1454,6 +1486,7 @@ export default function ForwardPage() {
const handleDiagnose = async (forward: Forward) => { const handleDiagnose = async (forward: Forward) => {
diagnosisAbortRef.current?.abort(); diagnosisAbortRef.current?.abort();
const abortController = new AbortController(); const abortController = new AbortController();
diagnosisAbortRef.current = abortController; diagnosisAbortRef.current = abortController;
setCurrentDiagnosisForward(forward); setCurrentDiagnosisForward(forward);
@@ -1487,6 +1520,7 @@ export default function ForwardPage() {
const startItems = Array.isArray(payload.items) const startItems = Array.isArray(payload.items)
? (payload.items as ForwardDiagnosisResult["results"]) ? (payload.items as ForwardDiagnosisResult["results"])
: []; : [];
setDiagnosisResult((prev) => ({ setDiagnosisResult((prev) => ({
forwardName: startForwardName, forwardName: startForwardName,
timestamp: Date.now(), timestamp: Date.now(),
@@ -1526,6 +1560,7 @@ export default function ForwardPage() {
diagnosing: false, diagnosing: false,
}); });
} }
return { return {
...base, ...base,
timestamp: Date.now(), timestamp: Date.now(),
@@ -1561,8 +1596,11 @@ export default function ForwardPage() {
if (response.code === 0) { if (response.code === 0) {
const resultData = response.data as ForwardDiagnosisResult; 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; const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData); setDiagnosisResult(resultData);
setDiagnosisProgress({ setDiagnosisProgress({
total: resultData.results.length, total: resultData.results.length,
@@ -1772,129 +1810,208 @@ export default function ForwardPage() {
} }
setImportLoading(true); setImportLoading(true);
setImportResults([]); // 清空之前的结果 setImportResults([]);
try { try {
const lines = importData if (importFormat === "ny") {
.trim() const parsedItems = parseNyFormatData(importData);
.split("\n")
.filter((line) => line.trim());
for (let i = 0; i < lines.length; i++) { if (parsedItems.length === 0) {
const line = lines[i].trim(); toast.error("未解析到有效的ny格式数据");
const parts = line.split("|");
if (parts.length < 2) { setImportLoading(false);
setImportResults((prev) => [
{ return;
line,
success: false,
message: "格式错误:需要至少包含目标地址和转发名称",
},
...prev,
]);
continue;
} }
const [remoteAddr, name, inPort] = parts; for (const item of parsedItems) {
if (item.error) {
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) => [ setImportResults((prev) => [
{ {
line, line: item.line,
success: true, success: false,
message: "创建成功", message: item.error || "解析失败",
forwardName: name.trim(),
}, },
...prev, ...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) => [ setImportResults((prev) => [
{ {
line, line,
success: false, 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, ...prev,
]); ]);
} }
} catch {
setImportResults((prev) => [
{
line,
success: false,
message: "网络错误,创建失败",
},
...prev,
]);
} }
} }
toast.success(`导入执行完成`); toast.success("导入执行完成");
// 导入完成后刷新转发列表
await loadData(false); await loadData(false);
} catch { } catch {
toast.error("导入过程中发生错误"); toast.error("导入过程中发生错误");
@@ -1998,7 +2115,9 @@ export default function ForwardPage() {
const activeTunnelGroupKey = buildForwardTunnelGroupKey( const activeTunnelGroupKey = buildForwardTunnelGroupKey(
activeForward?.tunnelName, activeForward?.tunnelName,
); );
const overTunnelGroupKey = buildForwardTunnelGroupKey(overForward?.tunnelName); const overTunnelGroupKey = buildForwardTunnelGroupKey(
overForward?.tunnelName,
);
// 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排 // 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排
if (!compactMode) { if (!compactMode) {
@@ -2286,13 +2405,7 @@ export default function ForwardPage() {
} }
return sortedByDb; return sortedByDb;
}, [ }, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]);
forwards,
forwardOrder,
filterUserId,
filterTunnelId,
searchKeyword,
]);
const availableGroupData = useMemo( const availableGroupData = useMemo(
() => buildAvailableGroupData(forwards), () => buildAvailableGroupData(forwards),
@@ -2429,7 +2542,10 @@ export default function ForwardPage() {
return aIndex - bIndex; return aIndex - bIndex;
} }
const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName); const nameCompare = compareForwardTunnelNameAsc(
a.tunnelName,
b.tunnelName,
);
if (nameCompare !== 0) { if (nameCompare !== 0) {
return nameCompare; return nameCompare;
@@ -2522,7 +2638,10 @@ export default function ForwardPage() {
bodyClassName: string; bodyClassName: string;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const sortableId = buildTunnelGroupSortableId(groupUserId, tunnel.tunnelKey); const sortableId = buildTunnelGroupSortableId(
groupUserId,
tunnel.tunnelKey,
);
const { const {
attributes, attributes,
listeners, listeners,
@@ -2639,6 +2758,7 @@ export default function ForwardPage() {
if (!existingUser) { if (!existingUser) {
userMap.set(uId, { id: uId, name: userName }); userMap.set(uId, { id: uId, name: userName });
return; return;
} }
@@ -3620,7 +3740,9 @@ export default function ForwardPage() {
}} }}
> >
<TableHeader> <TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>} {selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" /> <TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn> <TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn> <TableColumn>名称</TableColumn>
@@ -3680,7 +3802,10 @@ export default function ForwardPage() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragStart={() => {}} onDragStart={() => {}}
> >
<SortableContext items={sortableForwardIds} strategy={rectSortingStrategy}> <SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) => {sortedForwards.map((forward) =>
forward && forward.id ? ( forward && forward.id ? (
@@ -3742,7 +3867,10 @@ export default function ForwardPage() {
> >
<SortableContext <SortableContext
items={group.tunnels.map((tunnel) => items={group.tunnels.map((tunnel) =>
buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey), buildTunnelGroupSortableId(
group.userId,
tunnel.tunnelKey,
),
)} )}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
@@ -3801,10 +3929,14 @@ export default function ForwardPage() {
</TableColumn> </TableColumn>
)} )}
<TableColumn <TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag} className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag
}
/> />
<TableColumn <TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.name
}
> >
名称 名称
</TableColumn> </TableColumn>
@@ -3816,7 +3948,9 @@ export default function ForwardPage() {
入口 入口
</TableColumn> </TableColumn>
<TableColumn <TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.target
}
> >
目标 目标
</TableColumn> </TableColumn>
@@ -3835,7 +3969,9 @@ export default function ForwardPage() {
总流量 总流量
</TableColumn> </TableColumn>
<TableColumn <TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status} className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.status
}
> >
状态 状态
</TableColumn> </TableColumn>
@@ -3860,14 +3996,22 @@ export default function ForwardPage() {
<SortableTableRow <SortableTableRow
formatFlow={formatFlow} formatFlow={formatFlow}
formatInAddress={formatInAddress} formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress} formatRemoteAddress={
formatRemoteAddress
}
forward={forward} forward={forward}
getStrategyDisplay={getStrategyDisplay} getStrategyDisplay={
getStrategyDisplay
}
handleDelete={handleDelete} handleDelete={handleDelete}
handleDiagnose={handleDiagnose} handleDiagnose={handleDiagnose}
handleEdit={handleEdit} handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle} handleServiceToggle={
hasMultipleAddresses={hasMultipleAddresses} handleServiceToggle
}
hasMultipleAddresses={
hasMultipleAddresses
}
selectMode={selectMode} selectMode={selectMode}
selectedIds={selectedIds} selectedIds={selectedIds}
showAddressModal={showAddressModal} showAddressModal={showAddressModal}
@@ -3939,7 +4083,10 @@ export default function ForwardPage() {
> >
<SortableContext <SortableContext
items={group.tunnels.map((tunnel) => items={group.tunnels.map((tunnel) =>
buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey), buildTunnelGroupSortableId(
group.userId,
tunnel.tunnelKey,
),
)} )}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
@@ -3949,7 +4096,10 @@ export default function ForwardPage() {
.filter((id) => id > 0); .filter((id) => id > 0);
const collapsed = const collapsed =
sanitizedCollapsedTunnelGroups[ sanitizedCollapsedTunnelGroups[
buildTunnelGroupCollapseKey(group.userId, tunnel.tunnelKey) buildTunnelGroupCollapseKey(
group.userId,
tunnel.tunnelKey,
)
] === true; ] === true;
return ( return (
@@ -3964,7 +4114,10 @@ export default function ForwardPage() {
tunnel={tunnel} tunnel={tunnel}
wrapperClassName="rounded-xl border border-secondary/20 bg-secondary/5 space-y-3" wrapperClassName="rounded-xl border border-secondary/20 bg-secondary/5 space-y-3"
onToggleCollapsed={() => onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey) toggleTunnelGroupCollapsed(
group.userId,
tunnel.tunnelKey,
)
} }
> >
<DndContext <DndContext
@@ -4124,7 +4277,9 @@ export default function ForwardPage() {
<Select <Select
description="从入口节点IP中选择,留空使用默认" description="从入口节点IP中选择,留空使用默认"
isDisabled={!form.tunnelId || currentTunnelIpOptions.length === 0} isDisabled={
!form.tunnelId || currentTunnelIpOptions.length === 0
}
label="监听IP" label="监听IP"
placeholder={ placeholder={
form.tunnelId form.tunnelId
@@ -4458,17 +4613,58 @@ export default function ForwardPage() {
<ModalContent> <ModalContent>
<ModalHeader className="flex flex-col gap-1"> <ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">导入转发数据</h2> <h2 className="text-xl font-bold">导入转发数据</h2>
<p className="text-small text-default-500"> {importFormat === "flvx" ? (
格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口 <>
</p> <p className="text-small text-default-500">
<p className="text-small text-default-400"> 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4) </p>
</p> <p className="text-small text-default-400">
目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)
</p>
</>
) : (
<>
<p className="text-small text-default-500">
ny格式:JSON对象,支持多个目标地址(负载均衡)
</p>
<p className="text-small text-default-400">
格式:&#123;&quot;dest&quot;:[&quot;地址:端口&quot;],&quot;listen_port&quot;:端口,&quot;name&quot;:&quot;名称&quot;&#125;
</p>
</>
)}
</ModalHeader> </ModalHeader>
<ModalBody className="pb-6"> <ModalBody className="pb-6">
<div className="space-y-4"> <div className="space-y-4">
{/* 隧道选择 */} {/* 格式选择 */}
<div> <Select
isRequired
label="导入格式"
placeholder="请选择导入格式"
selectedKeys={[importFormat]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as ImportFormat;
if (selectedKey) {
setImportFormat(selectedKey);
setSelectedTunnelForImport(null);
setSelectedEntryNode(null);
setMatchedTunnels([]);
setImportData("");
setImportResults([]);
}
}}
>
<SelectItem key="flvx" textValue="flvx格式">
flvx格式(管道分隔)
</SelectItem>
<SelectItem key="ny" textValue="ny格式">
ny格式(JSON)
</SelectItem>
</Select>
{/* flvx格式:隧道选择 */}
{importFormat === "flvx" && (
<Select <Select
isRequired isRequired
label="选择导入隧道" label="选择导入隧道"
@@ -4496,35 +4692,113 @@ export default function ForwardPage() {
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
</div> )}
{/* ny格式:入口节点选择 */}
{importFormat === "ny" && (
<Select
isRequired
label="选择入口节点"
placeholder="请选择入口节点"
selectedKeys={
selectedEntryNode ? [selectedEntryNode.toString()] : []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const nodeId = selectedKey ? parseInt(selectedKey) : null;
setSelectedEntryNode(nodeId);
setSelectedTunnelForImport(null);
if (nodeId) {
const matched = allTunnels.filter(
(t) =>
t.type === 1 &&
t.inNodeId?.some((n) => n.nodeId === nodeId),
);
setMatchedTunnels(matched);
if (matched.length === 0) {
toast.error(
"该入口节点没有匹配的隧道,请先创建端口转发类型的隧道",
);
} else if (matched.length === 1) {
setSelectedTunnelForImport(matched[0].id);
} else {
setTunnelSelectModalOpen(true);
}
} else {
setMatchedTunnels([]);
}
}}
>
{nodes.map((node) => (
<SelectItem key={node.id.toString()} textValue={node.name}>
{node.name}
</SelectItem>
))}
</Select>
)}
{/* ny格式:显示匹配的隧道 */}
{importFormat === "ny" && matchedTunnels.length > 0 && (
<div className="text-xs text-default-500">
{matchedTunnels.length === 1 ? (
<span>
已匹配隧道:<strong>{matchedTunnels[0].name}</strong>
</span>
) : (
<span>
找到 {matchedTunnels.length}{" "}
个匹配隧道,请点击下方按钮选择
</span>
)}
</div>
)}
{/* ny格式:多隧道选择按钮 */}
{importFormat === "ny" &&
matchedTunnels.length > 1 &&
!selectedTunnelForImport && (
<Button
color="primary"
size="sm"
variant="flat"
onPress={() => setTunnelSelectModalOpen(true)}
>
选择隧道({matchedTunnels.length}个可选)
</Button>
)}
{/* 输入区域 */} {/* 输入区域 */}
<div> <Textarea
<Textarea classNames={{
classNames={{ input: "font-mono text-sm",
input: "font-mono text-sm", }}
}} label="导入数据"
label="导入数据" maxRows={12}
maxRows={12} minRows={8}
minRows={8} placeholder={
placeholder="请输入要导入的转发数据,格式:目标地址|转发名称|入口端口" importFormat === "flvx"
value={importData} ? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
variant="flat" : '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"}'
onChange={(e) => setImportData(e.target.value)} }
/> value={importData}
</div> variant="flat"
onChange={(e) => setImportData(e.target.value)}
/>
{/* 导入结果 */} {/* 导入结果 */}
{importResults.length > 0 && ( {importResults.length > 0 && (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-base font-semibold">导入结果</h3> <h3 className="text-base font-semibold">导入结果</h3>
<div className="flex items-center gap-2"> <span className="text-xs text-default-500">
<span className="text-xs text-default-500"> 成功:{importResults.filter((r) => r.success).length} /
成功:{importResults.filter((r) => r.success).length} / 总计:{importResults.length}
总计:{importResults.length} </span>
</span>
</div>
</div> </div>
<div <div
@@ -4613,7 +4887,11 @@ export default function ForwardPage() {
</Button> </Button>
<Button <Button
color="warning" color="warning"
isDisabled={!importData.trim() || !selectedTunnelForImport} isDisabled={
!importData.trim() ||
!selectedTunnelForImport ||
(importFormat === "ny" && !selectedEntryNode)
}
isLoading={importLoading} isLoading={importLoading}
onPress={executeImport} onPress={executeImport}
> >
@@ -4623,6 +4901,54 @@ export default function ForwardPage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* 隧道选择模态框(ny格式多隧道匹配时使用) */}
<Modal
backdrop="blur"
isOpen={tunnelSelectModalOpen}
placement="center"
size="md"
onClose={() => setTunnelSelectModalOpen(false)}
>
<ModalContent>
<ModalHeader>选择隧道</ModalHeader>
<ModalBody>
<p className="text-sm text-default-500 mb-3">
找到多个使用该入口节点的隧道,请选择一个:
</p>
<div className="space-y-2">
{matchedTunnels.map((tunnel) => (
<Button
key={tunnel.id}
className="w-full justify-start"
color={
selectedTunnelForImport === tunnel.id
? "primary"
: "default"
}
variant={
selectedTunnelForImport === tunnel.id ? "solid" : "bordered"
}
onPress={() => {
setSelectedTunnelForImport(tunnel.id);
setTunnelSelectModalOpen(false);
}}
>
{tunnel.name}
</Button>
))}
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={() => setTunnelSelectModalOpen(false)}
>
取消
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 诊断结果模态框 */} {/* 诊断结果模态框 */}
<Modal <Modal
backdrop="blur" backdrop="blur"
@@ -4815,8 +5141,8 @@ export default function ForwardPage() {
isDiagnosing isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20" ? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess : isSuccess
? "bg-white dark:bg-gray-800" ? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30" : "bg-danger-50 dark:bg-danger-900/30"
}`} }`}
> >
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -4851,8 +5177,8 @@ export default function ForwardPage() {
isDiagnosing isDiagnosing
? "warning" ? "warning"
: isSuccess : isSuccess
? "success" ? "success"
: "danger" : "danger"
} }
size="sm" size="sm"
variant="flat" variant="flat"
@@ -5002,8 +5328,8 @@ export default function ForwardPage() {
isDiagnosing isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20" ? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess : isSuccess
? "border-divider bg-white dark:bg-gray-800" ? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30" : "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`} }`}
> >
<div className="flex items-start gap-2 mb-2"> <div className="flex items-start gap-2 mb-2">
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
convertNyItemToForwardInput,
parseNyFormatData,
} from "./import-format.ts";
test("parseNyFormatData parses concatenated ny JSON objects", () => {
const input =
'{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}';
const result = parseNyFormatData(input);
assert.equal(result.length, 2);
assert.equal(result[0].error, undefined);
assert.equal(result[1].error, undefined);
assert.deepEqual(result[0].parsed?.dest, ["151.241.129.52:23609"]);
assert.equal(result[0].parsed?.listen_port, 20224);
assert.equal(result[1].parsed?.name, "Yolo-US-Lpt【三网通用】");
});
test("parseNyFormatData parses newline-separated ny JSON objects", () => {
const input = [
'{"dest":["1.1.1.1:1000","2.2.2.2:2000"],"listen_port":3000,"name":"A"}',
'{"dest":["3.3.3.3:4000"],"listen_port":5000,"name":"B"}',
].join("\n");
const result = parseNyFormatData(input);
assert.equal(result.length, 2);
assert.equal(result[0].error, undefined);
assert.equal(result[1].error, undefined);
assert.deepEqual(result[0].parsed?.dest, ["1.1.1.1:1000", "2.2.2.2:2000"]);
assert.equal(result[0].parsed?.listen_port, 3000);
});
test("parseNyFormatData returns validation errors for invalid fields", () => {
const input =
'{"dest":[],"listen_port":0,"name":""}{"dest":["bad-address"],"listen_port":80,"name":"ok"}';
const result = parseNyFormatData(input);
assert.equal(result.length, 2);
assert.match(result[0].error || "", /dest数组为空|listen_port|name/);
assert.match(result[1].error || "", /目标地址格式错误/);
});
test("convertNyItemToForwardInput maps ny fields correctly", () => {
const mapped = convertNyItemToForwardInput({
dest: ["1.1.1.1:1111", "2.2.2.2:2222"],
listen_port: 3333,
name: " Forward Name ",
});
assert.deepEqual(mapped, {
name: "Forward Name",
inPort: 3333,
remoteAddr: "1.1.1.1:1111,2.2.2.2:2222",
strategy: "fifo",
});
});
@@ -0,0 +1,169 @@
export interface NyImportItem {
dest: string[];
listen_port: number;
name: string;
}
export interface ParsedNyImportLine {
line: string;
parsed?: NyImportItem;
error?: string;
}
const ADDRESS_PATTERN = /^[^:]+:\d+$/;
const isValidListenPort = (value: unknown): value is number => {
return (
typeof value === "number" &&
Number.isFinite(value) &&
value >= 1 &&
value <= 65535
);
};
const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { line, error: "JSON结构错误" };
}
const item = value as Record<string, unknown>;
const dest = item.dest;
const listenPort = item.listen_port;
const name = item.name;
if (!Array.isArray(dest) || dest.length === 0) {
return { line, error: "dest数组为空或格式错误" };
}
if (typeof name !== "string" || name.trim() === "") {
return { line, error: "name不能为空" };
}
if (!isValidListenPort(listenPort)) {
return { line, error: "listen_port必须为1-65535之间的数字" };
}
const normalizedDest = dest.map((itemValue) =>
typeof itemValue === "string" ? itemValue.trim() : "",
);
if (normalizedDest.some((itemValue) => itemValue === "")) {
return { line, error: "dest中包含空地址" };
}
const invalid = normalizedDest.find(
(itemValue) => !ADDRESS_PATTERN.test(itemValue),
);
if (invalid) {
return { line, error: `目标地址格式错误: ${invalid}` };
}
return {
line,
parsed: {
dest: normalizedDest,
listen_port: listenPort,
name: name.trim(),
},
};
};
const splitConcatenatedJsonObjects = (input: string): string[] => {
const result: string[] = [];
let depth = 0;
let start = -1;
let inString = false;
let escaping = false;
for (let i = 0; i < input.length; i += 1) {
const char = input[i];
if (escaping) {
escaping = false;
continue;
}
if (char === "\\") {
escaping = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === "{") {
if (depth === 0) {
start = i;
}
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0 && start >= 0) {
result.push(input.slice(start, i + 1));
start = -1;
}
}
}
return result;
};
export const parseNyFormatData = (input: string): ParsedNyImportLine[] => {
const trimmed = input.trim();
if (!trimmed) {
return [];
}
const parsedResults: ParsedNyImportLine[] = [];
const objectChunks = splitConcatenatedJsonObjects(trimmed);
if (objectChunks.length > 0) {
objectChunks.forEach((chunk) => {
try {
const parsed = JSON.parse(chunk);
parsedResults.push(validateNyItem(chunk, parsed));
} catch {
parsedResults.push({ line: chunk, error: "JSON解析失败" });
}
});
return parsedResults;
}
trimmed
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "")
.forEach((line) => {
try {
const parsed = JSON.parse(line);
parsedResults.push(validateNyItem(line, parsed));
} catch {
parsedResults.push({ line, error: "JSON解析失败" });
}
});
return parsedResults;
};
export const convertNyItemToForwardInput = (item: NyImportItem) => {
return {
name: item.name.trim(),
inPort: item.listen_port,
remoteAddr: item.dest.join(","),
strategy: "fifo" as const,
};
};
+1
View File
@@ -195,6 +195,7 @@ export default function LimitPage() {
speed: payload.speed, speed: payload.speed,
status: payload.status, status: payload.status,
}; };
res = await createSpeedLimit(createData); res = await createSpeedLimit(createData);
} }
+5 -2
View File
@@ -100,7 +100,7 @@ interface Node {
rollbackLoading?: boolean; rollbackLoading?: boolean;
} }
interface NodeForm { interface NodeForm {
id: number | null; id: number | null;
name: string; name: string;
serverHost: string; serverHost: string;
@@ -1650,7 +1650,10 @@ export default function NodePage() {
value={form.extraIPs} value={form.extraIPs}
variant="bordered" variant="bordered"
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, extraIPs: e.target.value })) setForm((prev) => ({
...prev,
extraIPs: e.target.value,
}))
} }
/> />
+34 -15
View File
@@ -182,10 +182,14 @@ export default function TunnelPage() {
return []; return [];
} }
const optionSets = nodeIds.map((nodeId) => new Set(getNodeIpOptions(nodeId))); const optionSets = nodeIds.map(
(nodeId) => new Set(getNodeIpOptions(nodeId)),
);
const base = optionSets[0]; const base = optionSets[0];
return Array.from(base).filter((ip) => optionSets.every((set) => set.has(ip))); return Array.from(base).filter((ip) =>
optionSets.every((set) => set.has(ip)),
);
}; };
// 表单状态 // 表单状态
@@ -511,6 +515,7 @@ export default function TunnelPage() {
const handleDiagnose = async (tunnel: Tunnel) => { const handleDiagnose = async (tunnel: Tunnel) => {
diagnosisAbortRef.current?.abort(); diagnosisAbortRef.current?.abort();
const abortController = new AbortController(); const abortController = new AbortController();
diagnosisAbortRef.current = abortController; diagnosisAbortRef.current = abortController;
setCurrentDiagnosisTunnel(tunnel); setCurrentDiagnosisTunnel(tunnel);
@@ -552,6 +557,7 @@ export default function TunnelPage() {
const startItems = Array.isArray(payload.items) const startItems = Array.isArray(payload.items)
? (payload.items as DiagnosisResult["results"]) ? (payload.items as DiagnosisResult["results"])
: []; : [];
setDiagnosisResult((prev) => ({ setDiagnosisResult((prev) => ({
tunnelName: startTunnelName, tunnelName: startTunnelName,
tunnelType: startTunnelType, tunnelType: startTunnelType,
@@ -593,6 +599,7 @@ export default function TunnelPage() {
diagnosing: false, diagnosing: false,
}); });
} }
return { return {
...base, ...base,
timestamp: Date.now(), timestamp: Date.now(),
@@ -628,8 +635,11 @@ export default function TunnelPage() {
if (response.code === 0) { if (response.code === 0) {
const resultData = response.data as DiagnosisResult; const resultData = response.data as DiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length; const successCount = resultData.results.filter(
(r) => r.success,
).length;
const failedCount = resultData.results.length - successCount; const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData); setDiagnosisResult(resultData);
setDiagnosisProgress({ setDiagnosisProgress({
total: resultData.results.length, total: resultData.results.length,
@@ -1835,7 +1845,9 @@ export default function TunnelPage() {
size="sm" size="sm"
variant="bordered" variant="bordered"
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string; const selectedKey = Array.from(
keys,
)[0] as string;
updateChainConnectIp( updateChainConnectIp(
groupIndex, groupIndex,
@@ -1845,7 +1857,9 @@ export default function TunnelPage() {
); );
}} }}
> >
<SelectItem key="__default__">默认连接IP</SelectItem> <SelectItem key="__default__">
默认连接IP
</SelectItem>
{groupIpOptions.map((ip) => ( {groupIpOptions.map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem> <SelectItem key={ip}>{ip}</SelectItem>
))} ))}
@@ -2120,8 +2134,9 @@ export default function TunnelPage() {
}} }}
description="按出口节点共同可用IP选择,留空使用默认" description="按出口节点共同可用IP选择,留空使用默认"
isDisabled={ isDisabled={
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1) (form.outNodeId || []).filter(
.length === 0 || (ct) => ct.nodeId !== -1,
).length === 0 ||
getCommonIpOptions( getCommonIpOptions(
(form.outNodeId || []) (form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1) .filter((ct) => ct.nodeId !== -1)
@@ -2130,8 +2145,9 @@ export default function TunnelPage() {
} }
label="连接IP" label="连接IP"
placeholder={ placeholder={
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1) (form.outNodeId || []).filter(
.length === 0 (ct) => ct.nodeId !== -1,
).length === 0
? "请先选择出口节点" ? "请先选择出口节点"
: getCommonIpOptions( : getCommonIpOptions(
(form.outNodeId || []) (form.outNodeId || [])
@@ -2152,8 +2168,10 @@ export default function TunnelPage() {
const selectedKey = Array.from(keys)[0] as string; const selectedKey = Array.from(keys)[0] as string;
const value = const value =
selectedKey === "__default__" ? "" : selectedKey; selectedKey === "__default__" ? "" : selectedKey;
setForm((prev) => { setForm((prev) => {
const currentOutNodes = prev.outNodeId || []; const currentOutNodes = prev.outNodeId || [];
if (currentOutNodes.length === 0) { if (currentOutNodes.length === 0) {
return { return {
...prev, ...prev,
@@ -2168,6 +2186,7 @@ export default function TunnelPage() {
], ],
}; };
} }
return { return {
...prev, ...prev,
outNodeId: currentOutNodes.map((ct) => ({ outNodeId: currentOutNodes.map((ct) => ({
@@ -2451,8 +2470,8 @@ export default function TunnelPage() {
isDiagnosing isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20" ? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess : isSuccess
? "bg-white dark:bg-gray-800" ? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30" : "bg-danger-50 dark:bg-danger-900/30"
}`} }`}
> >
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -2487,8 +2506,8 @@ export default function TunnelPage() {
isDiagnosing isDiagnosing
? "warning" ? "warning"
: isSuccess : isSuccess
? "success" ? "success"
: "danger" : "danger"
} }
size="sm" size="sm"
variant="flat" variant="flat"
@@ -2637,8 +2656,8 @@ export default function TunnelPage() {
isDiagnosing isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20" ? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess : isSuccess
? "border-divider bg-white dark:bg-gray-800" ? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30" : "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`} }`}
> >
<div className="flex items-start gap-2 mb-2"> <div className="flex items-start gap-2 mb-2">