mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56:37 +08:00
feat: add ny format support for forward import with node-based tunnel matching
This commit is contained in:
@@ -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
@@ -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">
|
||||||
|
格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}
|
||||||
|
</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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user