Compare commits

...

6 Commits

Author SHA1 Message Date
sagit b815be54b8 feat: ny import compatibility and forward enhancements (#252)
## Summary
- **ny import compatibility**: 支持可选的 `listen_port` 字段自动分配端口
- **alias field mapping**: 支持字段别名映射 (dest/dst/target, listenPort/port,
name/forward_name)
- **help text update**: 更新帮助文本说明自动端口分配功能
- **parser tests**: 添加解析器测试覆盖别名字段和缺失端口处理
- **tunnel selection refactor**: 简化转发导入隧道选择逻辑
- **custom IP selection**: 为节点、隧道和转发添加自定义IP选择
- **compact mode**: 添加全局紧凑模式和隧道组折叠排序

## Changes
- `vite-frontend/src/pages/forward/import-format.ts`:
ny格式解析器增强,支持字段别名和可选端口
- `vite-frontend/src/pages/forward/import-format.test.ts`: 添加解析器测试
- `vite-frontend/src/pages/forward.tsx`: 更新UI帮助文本
2026-03-03 16:55:31 +08:00
sagitchu 75edeb9afa Merge remote-tracking branch 'origin/main' into opencode/mighty-nebula
# Conflicts:
#	vite-frontend/src/pages/forward.tsx
#	vite-frontend/src/pages/forward/import-format.test.ts
#	vite-frontend/src/pages/forward/import-format.ts
2026-03-03 16:55:14 +08:00
sagitchu 7c54192055 feat: add ny import compatibility with auto port assignment
- Support optional listen_port field for automatic port assignment
- Add alias field mapping (dest/dst/target, listenPort/port, name/forward_name)
- Update help text to document auto port assignment
- Add parser tests for alias fields and missing port handling

Entire-Checkpoint: efae74a1f03c
2026-03-03 16:54:05 +08:00
sagitchu 7ba68778c1 refactor: simplify forward import tunnel selection
- Remove separate entry node selection for ny format
- Unify tunnel selection for both flvx and ny formats
- Remove unused tunnel select modal component
- Simplify import button validation logic
2026-03-03 16:17:36 +08:00
sagit 7b736b2e60 feat: add ny format support for forward import with node-based tunnel matching (#250) 2026-03-03 15:39:07 +08:00
sagitchu ef613c1518 feat: add ny format support for forward import with node-based tunnel matching 2026-03-03 15:38:03 +08:00
8 changed files with 857 additions and 230 deletions
@@ -0,0 +1,7 @@
- [x] Review current forward import flow and confirm ny import uses tunnel selection
- [x] Define ny compatibility update with tunnel-first behavior and auto port assignment fallback
- [x] Update ny parser to accept alias fields and optional `listen_port`
- [x] Keep import execution bound to selected tunnel and remove entry-selection dependency from ux copy
- [x] Update ny import help text to document optional port auto assignment
- [x] Add parser tests for alias-field compatibility and missing-port auto assignment
- [x] Validate updated import parser tests locally
+79 -10
View File
@@ -1,6 +1,7 @@
import type { TunnelDiagnosisApiItem } from "@/api/types";
import axios from "axios";
import type { TunnelDiagnosisApiItem } from "@/api/types";
import { clearSession, getToken } from "@/utils/session";
const DIAGNOSIS_STREAM_TIMEOUT_MS = 2 * 60 * 1000;
@@ -106,13 +107,16 @@ const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => {
controller.abort();
}
};
signals.forEach((signal) => {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
return controller.signal;
};
@@ -120,6 +124,7 @@ const parseMessage = (err: unknown, fallback: string): string => {
if (err instanceof Error && err.message) {
return err.message;
}
return fallback;
};
@@ -133,7 +138,12 @@ const runDiagnosisStream = async ({
onError,
}: RunDiagnosisStreamOptions): Promise<DiagnosisStreamRunResult> => {
if (!isStreamSupported()) {
return { fallback: true, completed: false, timedOut: false, receivedItems: 0 };
return {
fallback: true,
completed: false,
timedOut: false,
receivedItems: 0,
};
}
let receivedItems = 0;
@@ -170,27 +180,51 @@ const runDiagnosisStream = async ({
if (response.status === 401) {
handleTokenExpired();
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
}
if (response.status === 404) {
return { fallback: true, completed: false, timedOut: false, receivedItems };
return {
fallback: true,
completed: false,
timedOut: false,
receivedItems,
};
}
if (!response.ok || !response.body) {
const fallbackMessage = `请求失败(${response.status})`;
let message = fallbackMessage;
try {
const data = (await response.json()) as RawObject;
if (typeof data.msg === "string" && data.msg.trim()) {
message = data.msg;
}
} catch {}
if (receivedItems === 0) {
return { fallback: true, completed: false, timedOut: false, receivedItems };
return {
fallback: true,
completed: false,
timedOut: false,
receivedItems,
};
}
onError?.(message);
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
}
const reader = response.body.getReader();
@@ -202,6 +236,7 @@ const runDiagnosisStream = async ({
return;
}
let parsed: DiagnosisStreamRawEvent;
try {
parsed = JSON.parse(line) as DiagnosisStreamRawEvent;
} catch {
@@ -209,15 +244,18 @@ const runDiagnosisStream = async ({
}
const eventType = (parsed.type || "").toLowerCase();
if (eventType === "start") {
if (parsed.data && typeof parsed.data === "object") {
const startData = parsed.data as RawObject;
const startTotal = Number(startData.total);
if (Number.isFinite(startTotal) && startTotal >= 0) {
currentProgress = { ...currentProgress, total: startTotal };
}
onStart?.(startData);
}
return;
}
@@ -228,10 +266,12 @@ const runDiagnosisStream = async ({
const itemData = parsed.data as RawObject;
const index = Number(itemData.index);
const result = itemData.result as TunnelDiagnosisApiItem | undefined;
if (!Number.isFinite(index) || !result || typeof result !== "object") {
return;
}
const progress = normalizeProgress(itemData.progress, currentProgress);
currentProgress = progress;
receivedItems += 1;
onItem({
@@ -239,6 +279,7 @@ const runDiagnosisStream = async ({
result,
progress,
});
return;
}
@@ -252,6 +293,7 @@ const runDiagnosisStream = async ({
donePayload.progress ?? donePayload,
currentProgress,
);
if (typeof donePayload.timedOut === "boolean") {
doneProgress.timedOut = donePayload.timedOut;
timedOut = donePayload.timedOut;
@@ -263,16 +305,19 @@ const runDiagnosisStream = async ({
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
lines.forEach((line) => processLine(line.trim()));
}
const tail = buffer.trim();
if (tail) {
processLine(tail);
}
@@ -282,6 +327,7 @@ const runDiagnosisStream = async ({
...currentProgress,
timedOut: true,
};
onDone?.(timeoutProgress);
}
@@ -297,20 +343,43 @@ const runDiagnosisStream = async ({
...currentProgress,
timedOut: true,
};
onDone?.(timeoutProgress);
return { fallback: false, completed: false, timedOut: true, receivedItems };
return {
fallback: false,
completed: false,
timedOut: true,
receivedItems,
};
}
if (signal?.aborted) {
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
}
if (receivedItems === 0) {
return { fallback: true, completed: false, timedOut: false, receivedItems };
return {
fallback: true,
completed: false,
timedOut: false,
receivedItems,
};
}
onError?.(parseMessage(error, "流式诊断中断"));
return { fallback: false, completed: false, timedOut: false, receivedItems };
return {
fallback: false,
completed: false,
timedOut: false,
receivedItems,
};
} finally {
clearTimeout(timeoutId);
}
+390 -203
View File
@@ -87,6 +87,10 @@ import {
executeForwardBatchRedeploy,
executeForwardBatchToggleService,
} from "@/pages/forward/batch-actions";
import {
convertNyItemToForwardInput,
parseNyFormatData,
} from "@/pages/forward/import-format";
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
import { PageLoadingState } from "@/components/page-state";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
@@ -119,6 +123,7 @@ interface Forward {
interface Tunnel {
id: number;
name: string;
type?: number;
inIp?: string;
inNodeId?: Array<{ nodeId: number }>;
inNodePortSta?: number;
@@ -127,6 +132,7 @@ interface Tunnel {
interface Node {
id: number;
name?: string;
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
@@ -168,8 +174,7 @@ const FORWARD_COMPACT_MODE_EVENT = "forwardCompactModeChanged";
const FORWARD_GROUP_ORDER_CONFIG_KEY = "forward_group_order_map";
const FORWARD_GROUP_COLLAPSED_CONFIG_KEY = "forward_group_collapsed_map";
const FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = "forward-group-order";
const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX =
"forward-group-collapsed";
const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = "forward-group-collapsed";
const FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = "forward-tunnel-group";
const FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = "min-w-[1320px]";
const FORWARD_GROUPED_TABLE_COLUMN_CLASS = {
@@ -239,7 +244,9 @@ const buildForwardGroupCollapsedLocalKey = (tokenUserId: number): string => {
return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;
};
const parsePreferenceMap = <T,>(raw: string | null): Record<string, T> | null => {
const parsePreferenceMap = <T,>(
raw: string | null,
): Record<string, T> | null => {
if (!raw) {
return null;
}
@@ -283,7 +290,9 @@ const parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => {
return result;
};
const parseGroupCollapsedMap = (raw: string | null): ForwardGroupCollapsedMap => {
const parseGroupCollapsedMap = (
raw: string | null,
): ForwardGroupCollapsedMap => {
const parsed = parsePreferenceMap<unknown>(raw);
if (!parsed) {
@@ -357,11 +366,17 @@ const sanitizeGroupCollapsedMap = (
return sanitized;
};
const buildTunnelGroupCollapseKey = (userId: number, tunnelKey: string): string => {
const buildTunnelGroupCollapseKey = (
userId: number,
tunnelKey: string,
): string => {
return `${userId}:${tunnelKey}`;
};
const buildTunnelGroupSortableId = (userId: number, tunnelKey: string): string => {
const buildTunnelGroupSortableId = (
userId: number,
tunnelKey: string,
): string => {
return `${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:${userId}:${tunnelKey}`;
};
@@ -392,7 +407,9 @@ const parseTunnelGroupSortableId = (
return { userId, tunnelKey };
};
const buildAvailableGroupData = (forwards: Forward[]): {
const buildAvailableGroupData = (
forwards: Forward[],
): {
availableTunnelKeysByUser: Map<number, Set<string>>;
availableCollapseKeys: Set<string>;
} => {
@@ -541,9 +558,11 @@ export default function ForwardPage() {
>(null);
// 导入相关状态
type ImportFormat = "flvx" | "ny";
const [importModalOpen, setImportModalOpen] = useState(false);
const [importData, setImportData] = useState("");
const [importLoading, setImportLoading] = useState(false);
const [importFormat, setImportFormat] = useState<ImportFormat>("flvx");
const [selectedTunnelForImport, setSelectedTunnelForImport] = useState<
number | null
>(null);
@@ -724,7 +743,9 @@ export default function ForwardPage() {
}
try {
const currentRes = await getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY);
const currentRes = await getConfigByName(
FORWARD_GROUP_COLLAPSED_CONFIG_KEY,
);
const globalMap =
parsePreferenceMap<ForwardGroupCollapsedMap>(
currentRes.code === 0 && typeof currentRes.data?.value === "string"
@@ -793,12 +814,13 @@ export default function ForwardPage() {
? globalOrderRes.data.value
: null,
);
const globalCollapsedMap = parsePreferenceMap<ForwardGroupCollapsedMap>(
globalCollapsedRes.code === 0 &&
typeof globalCollapsedRes.data?.value === "string"
? globalCollapsedRes.data.value
: null,
);
const globalCollapsedMap =
parsePreferenceMap<ForwardGroupCollapsedMap>(
globalCollapsedRes.code === 0 &&
typeof globalCollapsedRes.data?.value === "string"
? globalCollapsedRes.data.value
: null,
);
const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()];
const globalCollapsedBucket =
@@ -809,7 +831,9 @@ export default function ForwardPage() {
typeof globalOrderBucket === "object" &&
!Array.isArray(globalOrderBucket)
) {
localOrderMap = parseGroupOrderMap(JSON.stringify(globalOrderBucket));
localOrderMap = parseGroupOrderMap(
JSON.stringify(globalOrderBucket),
);
}
if (
@@ -1147,7 +1171,10 @@ export default function ForwardPage() {
} else {
}
if (allTunnelsRes.status === "fulfilled" && allTunnelsRes.value.code === 0) {
if (
allTunnelsRes.status === "fulfilled" &&
allTunnelsRes.value.code === 0
) {
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
}
@@ -1454,6 +1481,7 @@ export default function ForwardPage() {
const handleDiagnose = async (forward: Forward) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
diagnosisAbortRef.current = abortController;
setCurrentDiagnosisForward(forward);
@@ -1487,6 +1515,7 @@ export default function ForwardPage() {
const startItems = Array.isArray(payload.items)
? (payload.items as ForwardDiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
forwardName: startForwardName,
timestamp: Date.now(),
@@ -1526,6 +1555,7 @@ export default function ForwardPage() {
diagnosing: false,
});
}
return {
...base,
timestamp: Date.now(),
@@ -1561,8 +1591,11 @@ export default function ForwardPage() {
if (response.code === 0) {
const resultData = response.data as ForwardDiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length;
const successCount = resultData.results.filter(
(r) => r.success,
).length;
const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
@@ -1772,129 +1805,208 @@ export default function ForwardPage() {
}
setImportLoading(true);
setImportResults([]); // 清空之前的结果
setImportResults([]);
try {
const lines = importData
.trim()
.split("\n")
.filter((line) => line.trim());
if (importFormat === "ny") {
const parsedItems = parseNyFormatData(importData);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const parts = line.split("|");
if (parsedItems.length === 0) {
toast.error("未解析到有效的ny格式数据");
if (parts.length < 2) {
setImportResults((prev) => [
{
line,
success: false,
message: "格式错误:需要至少包含目标地址和转发名称",
},
...prev,
]);
continue;
setImportLoading(false);
return;
}
const [remoteAddr, name, inPort] = parts;
if (!remoteAddr.trim() || !name.trim()) {
setImportResults((prev) => [
{
line,
success: false,
message: "目标地址和转发名称不能为空",
},
...prev,
]);
continue;
}
// 验证远程地址格式 - 支持单个地址或多个地址用逗号分隔
const addresses = remoteAddr.trim().split(",");
const addressPattern = /^[^:]+:\d+$/;
const isValidFormat = addresses.every((addr) =>
addressPattern.test(addr.trim()),
);
if (!isValidFormat) {
setImportResults((prev) => [
{
line,
success: false,
message:
"目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔",
},
...prev,
]);
continue;
}
try {
// 处理入口端口
let portNumber: number | null = null;
if (inPort && inPort.trim()) {
const port = parseInt(inPort.trim());
if (isNaN(port) || port < 1 || port > 65535) {
setImportResults((prev) => [
{
line,
success: false,
message: "入口端口格式错误,应为1-65535之间的数字",
},
...prev,
]);
continue;
}
portNumber = port;
}
// 调用创建转发接口
const response = await createForward({
name: name.trim(),
tunnelId: selectedTunnelForImport, // 使用用户选择的隧道
inPort: portNumber, // 使用指定端口或自动分配
remoteAddr: remoteAddr.trim(),
strategy: "fifo",
});
if (response.code === 0) {
for (const item of parsedItems) {
if (item.error) {
setImportResults((prev) => [
{
line,
success: true,
message: "创建成功",
forwardName: name.trim(),
line: item.line,
success: false,
message: item.error || "解析失败",
},
...prev,
]);
} else {
continue;
}
if (!item.parsed) {
setImportResults((prev) => [
{
line: item.line,
success: false,
message: "解析失败",
},
...prev,
]);
continue;
}
const parsedNyItem = item.parsed;
const nyForwardInput = convertNyItemToForwardInput(parsedNyItem);
try {
const response = await createForward({
name: nyForwardInput.name,
tunnelId: selectedTunnelForImport,
inPort: nyForwardInput.inPort,
remoteAddr: nyForwardInput.remoteAddr,
strategy: nyForwardInput.strategy,
});
if (response.code === 0) {
setImportResults((prev) => [
{
line: item.line,
success: true,
message: `创建成功 (${parsedNyItem.dest.length}个目标)`,
forwardName: nyForwardInput.name,
},
...prev,
]);
} else {
setImportResults((prev) => [
{
line: item.line,
success: false,
message: response.msg || "创建失败",
},
...prev,
]);
}
} catch {
setImportResults((prev) => [
{
line: item.line,
success: false,
message: "网络错误,创建失败",
},
...prev,
]);
}
}
} else {
const lines = importData
.trim()
.split("\n")
.filter((line) => line.trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const parts = line.split("|");
if (parts.length < 2) {
setImportResults((prev) => [
{
line,
success: false,
message: response.msg || "创建失败",
message: "格式错误:需要至少包含目标地址和转发名称",
},
...prev,
]);
continue;
}
const [remoteAddr, name, inPort] = parts;
if (!remoteAddr.trim() || !name.trim()) {
setImportResults((prev) => [
{
line,
success: false,
message: "目标地址和转发名称不能为空",
},
...prev,
]);
continue;
}
const addresses = remoteAddr.trim().split(",");
const addressPattern = /^[^:]+:\d+$/;
const isValidFormat = addresses.every((addr) =>
addressPattern.test(addr.trim()),
);
if (!isValidFormat) {
setImportResults((prev) => [
{
line,
success: false,
message:
"目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔",
},
...prev,
]);
continue;
}
try {
let portNumber: number | null = null;
if (inPort && inPort.trim()) {
const port = parseInt(inPort.trim());
if (isNaN(port) || port < 1 || port > 65535) {
setImportResults((prev) => [
{
line,
success: false,
message: "入口端口格式错误,应为1-65535之间的数字",
},
...prev,
]);
continue;
}
portNumber = port;
}
const response = await createForward({
name: name.trim(),
tunnelId: selectedTunnelForImport,
inPort: portNumber,
remoteAddr: remoteAddr.trim(),
strategy: "fifo",
});
if (response.code === 0) {
setImportResults((prev) => [
{
line,
success: true,
message: "创建成功",
forwardName: name.trim(),
},
...prev,
]);
} else {
setImportResults((prev) => [
{
line,
success: false,
message: response.msg || "创建失败",
},
...prev,
]);
}
} catch {
setImportResults((prev) => [
{
line,
success: false,
message: "网络错误,创建失败",
},
...prev,
]);
}
} catch {
setImportResults((prev) => [
{
line,
success: false,
message: "网络错误,创建失败",
},
...prev,
]);
}
}
toast.success(`导入执行完成`);
toast.success("导入执行完成");
// 导入完成后刷新转发列表
await loadData(false);
} catch {
toast.error("导入过程中发生错误");
@@ -1998,7 +2110,9 @@ export default function ForwardPage() {
const activeTunnelGroupKey = buildForwardTunnelGroupKey(
activeForward?.tunnelName,
);
const overTunnelGroupKey = buildForwardTunnelGroupKey(overForward?.tunnelName);
const overTunnelGroupKey = buildForwardTunnelGroupKey(
overForward?.tunnelName,
);
// 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排
if (!compactMode) {
@@ -2286,13 +2400,7 @@ export default function ForwardPage() {
}
return sortedByDb;
}, [
forwards,
forwardOrder,
filterUserId,
filterTunnelId,
searchKeyword,
]);
}, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]);
const availableGroupData = useMemo(
() => buildAvailableGroupData(forwards),
@@ -2429,7 +2537,10 @@ export default function ForwardPage() {
return aIndex - bIndex;
}
const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName);
const nameCompare = compareForwardTunnelNameAsc(
a.tunnelName,
b.tunnelName,
);
if (nameCompare !== 0) {
return nameCompare;
@@ -2522,7 +2633,10 @@ export default function ForwardPage() {
bodyClassName: string;
children: React.ReactNode;
}) => {
const sortableId = buildTunnelGroupSortableId(groupUserId, tunnel.tunnelKey);
const sortableId = buildTunnelGroupSortableId(
groupUserId,
tunnel.tunnelKey,
);
const {
attributes,
listeners,
@@ -2639,6 +2753,7 @@ export default function ForwardPage() {
if (!existingUser) {
userMap.set(uId, { id: uId, name: userName });
return;
}
@@ -3620,7 +3735,9 @@ export default function ForwardPage() {
}}
>
<TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn>
@@ -3680,7 +3797,10 @@ export default function ForwardPage() {
onDragEnd={handleDragEnd}
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">
{sortedForwards.map((forward) =>
forward && forward.id ? (
@@ -3742,7 +3862,10 @@ export default function ForwardPage() {
>
<SortableContext
items={group.tunnels.map((tunnel) =>
buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey),
buildTunnelGroupSortableId(
group.userId,
tunnel.tunnelKey,
),
)}
strategy={verticalListSortingStrategy}
>
@@ -3801,10 +3924,14 @@ export default function ForwardPage() {
</TableColumn>
)}
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag
}
/>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.name}
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.name
}
>
名称
</TableColumn>
@@ -3816,7 +3943,9 @@ export default function ForwardPage() {
入口
</TableColumn>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.target}
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.target
}
>
目标
</TableColumn>
@@ -3835,7 +3964,9 @@ export default function ForwardPage() {
总流量
</TableColumn>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.status
}
>
状态
</TableColumn>
@@ -3860,14 +3991,22 @@ export default function ForwardPage() {
<SortableTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
formatRemoteAddress={
formatRemoteAddress
}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
getStrategyDisplay={
getStrategyDisplay
}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
handleServiceToggle={
handleServiceToggle
}
hasMultipleAddresses={
hasMultipleAddresses
}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
@@ -3939,7 +4078,10 @@ export default function ForwardPage() {
>
<SortableContext
items={group.tunnels.map((tunnel) =>
buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey),
buildTunnelGroupSortableId(
group.userId,
tunnel.tunnelKey,
),
)}
strategy={verticalListSortingStrategy}
>
@@ -3949,7 +4091,10 @@ export default function ForwardPage() {
.filter((id) => id > 0);
const collapsed =
sanitizedCollapsedTunnelGroups[
buildTunnelGroupCollapseKey(group.userId, tunnel.tunnelKey)
buildTunnelGroupCollapseKey(
group.userId,
tunnel.tunnelKey,
)
] === true;
return (
@@ -3964,7 +4109,10 @@ export default function ForwardPage() {
tunnel={tunnel}
wrapperClassName="rounded-xl border border-secondary/20 bg-secondary/5 space-y-3"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey)
toggleTunnelGroupCollapsed(
group.userId,
tunnel.tunnelKey,
)
}
>
<DndContext
@@ -4124,7 +4272,9 @@ export default function ForwardPage() {
<Select
description="从入口节点IP中选择,留空使用默认"
isDisabled={!form.tunnelId || currentTunnelIpOptions.length === 0}
isDisabled={
!form.tunnelId || currentTunnelIpOptions.length === 0
}
label="监听IP"
placeholder={
form.tunnelId
@@ -4458,73 +4608,110 @@ export default function ForwardPage() {
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">导入转发数据</h2>
<p className="text-small text-default-500">
格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
</p>
<p className="text-small text-default-400">
目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)
</p>
{importFormat === "flvx" ? (
<>
<p className="text-small text-default-500">
格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
</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;(listen_port可省略,自动分配端口)
</p>
</>
)}
</ModalHeader>
<ModalBody className="pb-6">
<div className="space-y-4">
{/* 隧道选择 */}
<div>
<Select
isRequired
label="选择导入隧道"
placeholder="请选择要导入的隧道"
selectedKeys={
selectedTunnelForImport
? [selectedTunnelForImport.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
{/* 格式选择 */}
<Select
isRequired
label="导入格式"
placeholder="请选择导入格式"
selectedKeys={[importFormat]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as ImportFormat;
setSelectedTunnelForImport(
selectedKey ? parseInt(selectedKey) : null,
);
}}
>
{tunnels.map((tunnel) => (
<SelectItem
key={tunnel.id.toString()}
textValue={tunnel.name}
>
{tunnel.name}
</SelectItem>
))}
</Select>
</div>
if (selectedKey) {
setImportFormat(selectedKey);
setSelectedTunnelForImport(null);
setImportData("");
setImportResults([]);
}
}}
>
<SelectItem key="flvx" textValue="flvx格式">
flvx格式(管道分隔)
</SelectItem>
<SelectItem key="ny" textValue="ny格式">
ny格式(JSON)
</SelectItem>
</Select>
{/* 隧道选择 - 两种格式都需要 */}
<Select
isRequired
label="选择导入隧道"
placeholder="请选择要导入的隧道"
selectedKeys={
selectedTunnelForImport
? [selectedTunnelForImport.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
setSelectedTunnelForImport(
selectedKey ? parseInt(selectedKey) : null,
);
}}
>
{tunnels.map((tunnel) => (
<SelectItem
key={tunnel.id.toString()}
textValue={tunnel.name}
>
{tunnel.name}
</SelectItem>
))}
</Select>
{/* 输入区域 */}
<div>
<Textarea
classNames={{
input: "font-mono text-sm",
}}
label="导入数据"
maxRows={12}
minRows={8}
placeholder="请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
value={importData}
variant="flat"
onChange={(e) => setImportData(e.target.value)}
/>
</div>
<Textarea
classNames={{
input: "font-mono text-sm",
}}
label="导入数据"
maxRows={12}
minRows={8}
placeholder={
importFormat === "flvx"
? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"};listen_port可省略自动分配'
}
value={importData}
variant="flat"
onChange={(e) => setImportData(e.target.value)}
/>
{/* 导入结果 */}
{importResults.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-base font-semibold">导入结果</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">
成功:{importResults.filter((r) => r.success).length} /
总计:{importResults.length}
</span>
</div>
<span className="text-xs text-default-500">
成功:{importResults.filter((r) => r.success).length} /
总计:{importResults.length}
</span>
</div>
<div
@@ -4815,8 +5002,8 @@ export default function ForwardPage() {
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
@@ -4851,8 +5038,8 @@ export default function ForwardPage() {
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
? "success"
: "danger"
}
size="sm"
variant="flat"
@@ -5002,8 +5189,8 @@ export default function ForwardPage() {
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "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-divider bg-white dark:bg-gray-800"
: "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">
@@ -0,0 +1,104 @@
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("parseNyFormatData allows missing listen_port for auto assignment", () => {
const input = '{"dest":["1.1.1.1:1000"],"name":"No Port"}';
const result = parseNyFormatData(input);
assert.equal(result.length, 1);
assert.equal(result[0].error, undefined);
assert.equal(result[0].parsed?.listen_port, null);
});
test("parseNyFormatData supports ny alias fields", () => {
const input =
'{"dst":["2.2.2.2:2000"],"listenPort":"3000","forward_name":"Alias A"}\n{"target":"3.3.3.3:4000,4.4.4.4:5000","port":6000,"forwardName":"Alias B"}';
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, ["2.2.2.2:2000"]);
assert.equal(result[0].parsed?.listen_port, 3000);
assert.equal(result[0].parsed?.name, "Alias A");
assert.deepEqual(result[1].parsed?.dest, ["3.3.3.3:4000", "4.4.4.4:5000"]);
assert.equal(result[1].parsed?.listen_port, 6000);
assert.equal(result[1].parsed?.name, "Alias B");
});
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",
});
});
test("convertNyItemToForwardInput keeps null inPort for auto assignment", () => {
const mapped = convertNyItemToForwardInput({
dest: ["1.1.1.1:1111"],
listen_port: null,
name: "No Port",
});
assert.deepEqual(mapped, {
name: "No Port",
inPort: null,
remoteAddr: "1.1.1.1:1111",
strategy: "fifo",
});
});
@@ -0,0 +1,237 @@
export interface NyImportItem {
dest: string[];
listen_port: number | null;
name: string;
}
export interface ParsedNyImportLine {
line: string;
parsed?: NyImportItem;
error?: string;
}
const ADDRESS_PATTERN = /^[^:]+:\d+$/;
const getAliasField = (
item: Record<string, unknown>,
aliases: string[],
): unknown => {
for (const alias of aliases) {
if (Object.prototype.hasOwnProperty.call(item, alias)) {
return item[alias];
}
}
return undefined;
};
const normalizeDestList = (value: unknown): string[] | null => {
if (Array.isArray(value)) {
const normalized = value.map((itemValue) =>
typeof itemValue === "string" ? itemValue.trim() : "",
);
if (normalized.some((itemValue) => itemValue === "")) {
return null;
}
return normalized;
}
if (typeof value === "string") {
const normalized = value
.split(",")
.map((itemValue) => itemValue.trim())
.filter((itemValue) => itemValue !== "");
return normalized.length > 0 ? normalized : null;
}
return null;
};
const normalizeListenPort = (value: unknown): number | null | undefined => {
if (value === undefined || value === null || value === "") {
return null;
}
if (typeof value === "number") {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (!/^\d+$/.test(trimmed)) {
return undefined;
}
return Number.parseInt(trimmed, 10);
}
return undefined;
};
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 = getAliasField(item, ["dest", "dst", "target", "targets"]);
const listenPortRaw = getAliasField(item, [
"listen_port",
"listenPort",
"port",
"in_port",
"inPort",
]);
const name = getAliasField(item, ["name", "forward_name", "forwardName"]);
const normalizedDest = normalizeDestList(dest);
const normalizedListenPort = normalizeListenPort(listenPortRaw);
if (!normalizedDest || normalizedDest.length === 0) {
return { line, error: "dest数组为空或格式错误" };
}
if (typeof name !== "string" || name.trim() === "") {
return { line, error: "name不能为空" };
}
if (normalizedListenPort === undefined) {
return { line, error: "listen_port格式错误,应为1-65535之间的数字" };
}
if (normalizedListenPort !== null && !isValidListenPort(normalizedListenPort)) {
return { line, error: "listen_port必须为1-65535之间的数字" };
}
const invalid = normalizedDest.find(
(itemValue) => !ADDRESS_PATTERN.test(itemValue),
);
if (invalid) {
return { line, error: `目标地址格式错误: ${invalid}` };
}
return {
line,
parsed: {
dest: normalizedDest,
listen_port: normalizedListenPort,
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,
status: payload.status,
};
res = await createSpeedLimit(createData);
}
+5 -2
View File
@@ -100,7 +100,7 @@ interface Node {
rollbackLoading?: boolean;
}
interface NodeForm {
interface NodeForm {
id: number | null;
name: string;
serverHost: string;
@@ -1650,7 +1650,10 @@ export default function NodePage() {
value={form.extraIPs}
variant="bordered"
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 [];
}
const optionSets = nodeIds.map((nodeId) => new Set(getNodeIpOptions(nodeId)));
const optionSets = nodeIds.map(
(nodeId) => new Set(getNodeIpOptions(nodeId)),
);
const base = optionSets[0];
return Array.from(base).filter((ip) => optionSets.every((set) => set.has(ip)));
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) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
diagnosisAbortRef.current = abortController;
setCurrentDiagnosisTunnel(tunnel);
@@ -552,6 +557,7 @@ export default function TunnelPage() {
const startItems = Array.isArray(payload.items)
? (payload.items as DiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
tunnelName: startTunnelName,
tunnelType: startTunnelType,
@@ -593,6 +599,7 @@ export default function TunnelPage() {
diagnosing: false,
});
}
return {
...base,
timestamp: Date.now(),
@@ -628,8 +635,11 @@ export default function TunnelPage() {
if (response.code === 0) {
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;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
@@ -1835,7 +1845,9 @@ export default function TunnelPage() {
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const selectedKey = Array.from(
keys,
)[0] as string;
updateChainConnectIp(
groupIndex,
@@ -1845,7 +1857,9 @@ export default function TunnelPage() {
);
}}
>
<SelectItem key="__default__">默认连接IP</SelectItem>
<SelectItem key="__default__">
默认连接IP
</SelectItem>
{groupIpOptions.map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
@@ -2120,8 +2134,9 @@ export default function TunnelPage() {
}}
description="按出口节点共同可用IP选择,留空使用默认"
isDisabled={
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1)
.length === 0 ||
(form.outNodeId || []).filter(
(ct) => ct.nodeId !== -1,
).length === 0 ||
getCommonIpOptions(
(form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
@@ -2130,8 +2145,9 @@ export default function TunnelPage() {
}
label="连接IP"
placeholder={
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1)
.length === 0
(form.outNodeId || []).filter(
(ct) => ct.nodeId !== -1,
).length === 0
? "请先选择出口节点"
: getCommonIpOptions(
(form.outNodeId || [])
@@ -2152,8 +2168,10 @@ export default function TunnelPage() {
const selectedKey = Array.from(keys)[0] as string;
const value =
selectedKey === "__default__" ? "" : selectedKey;
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
if (currentOutNodes.length === 0) {
return {
...prev,
@@ -2168,6 +2186,7 @@ export default function TunnelPage() {
],
};
}
return {
...prev,
outNodeId: currentOutNodes.map((ct) => ({
@@ -2451,8 +2470,8 @@ export default function TunnelPage() {
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
@@ -2487,8 +2506,8 @@ export default function TunnelPage() {
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
? "success"
: "danger"
}
size="sm"
variant="flat"
@@ -2637,8 +2656,8 @@ export default function TunnelPage() {
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "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-divider bg-white dark:bg-gray-800"
: "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">