mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 03:06:37 +08:00
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帮助文本
This commit is contained in:
@@ -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
|
||||||
@@ -563,11 +563,6 @@ export default function ForwardPage() {
|
|||||||
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 [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);
|
||||||
@@ -4625,10 +4620,10 @@ export default function ForwardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-small text-default-500">
|
<p className="text-small text-default-500">
|
||||||
ny格式:JSON对象,支持多个目标地址(负载均衡)
|
ny格式:JSON对象,支持多个目标地址(负载均衡),按所选隧道导入
|
||||||
</p>
|
</p>
|
||||||
<p className="text-small text-default-400">
|
<p className="text-small text-default-400">
|
||||||
格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}
|
格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}(listen_port可省略,自动分配端口)
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -4648,8 +4643,6 @@ export default function ForwardPage() {
|
|||||||
if (selectedKey) {
|
if (selectedKey) {
|
||||||
setImportFormat(selectedKey);
|
setImportFormat(selectedKey);
|
||||||
setSelectedTunnelForImport(null);
|
setSelectedTunnelForImport(null);
|
||||||
setSelectedEntryNode(null);
|
|
||||||
setMatchedTunnels([]);
|
|
||||||
setImportData("");
|
setImportData("");
|
||||||
setImportResults([]);
|
setImportResults([]);
|
||||||
}
|
}
|
||||||
@@ -4663,114 +4656,34 @@ export default function ForwardPage() {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* flvx格式:隧道选择 */}
|
{/* 隧道选择 - 两种格式都需要 */}
|
||||||
{importFormat === "flvx" && (
|
<Select
|
||||||
<Select
|
isRequired
|
||||||
isRequired
|
label="选择导入隧道"
|
||||||
label="选择导入隧道"
|
placeholder="请选择要导入的隧道"
|
||||||
placeholder="请选择要导入的隧道"
|
selectedKeys={
|
||||||
selectedKeys={
|
selectedTunnelForImport
|
||||||
selectedTunnelForImport
|
? [selectedTunnelForImport.toString()]
|
||||||
? [selectedTunnelForImport.toString()]
|
: []
|
||||||
: []
|
}
|
||||||
}
|
variant="bordered"
|
||||||
variant="bordered"
|
onSelectionChange={(keys) => {
|
||||||
onSelectionChange={(keys) => {
|
const selectedKey = Array.from(keys)[0] as string;
|
||||||
const selectedKey = Array.from(keys)[0] as string;
|
|
||||||
|
|
||||||
setSelectedTunnelForImport(
|
setSelectedTunnelForImport(
|
||||||
selectedKey ? parseInt(selectedKey) : null,
|
selectedKey ? parseInt(selectedKey) : null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tunnels.map((tunnel) => (
|
{tunnels.map((tunnel) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={tunnel.id.toString()}
|
key={tunnel.id.toString()}
|
||||||
textValue={tunnel.name}
|
textValue={tunnel.name}
|
||||||
>
|
|
||||||
{tunnel.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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}个可选)
|
{tunnel.name}
|
||||||
</Button>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* 输入区域 */}
|
{/* 输入区域 */}
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -4783,7 +4696,7 @@ export default function ForwardPage() {
|
|||||||
placeholder={
|
placeholder={
|
||||||
importFormat === "flvx"
|
importFormat === "flvx"
|
||||||
? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
|
? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
|
||||||
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"}'
|
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"};listen_port可省略自动分配'
|
||||||
}
|
}
|
||||||
value={importData}
|
value={importData}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -4887,11 +4800,7 @@ export default function ForwardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="warning"
|
color="warning"
|
||||||
isDisabled={
|
isDisabled={!importData.trim() || !selectedTunnelForImport}
|
||||||
!importData.trim() ||
|
|
||||||
!selectedTunnelForImport ||
|
|
||||||
(importFormat === "ny" && !selectedEntryNode)
|
|
||||||
}
|
|
||||||
isLoading={importLoading}
|
isLoading={importLoading}
|
||||||
onPress={executeImport}
|
onPress={executeImport}
|
||||||
>
|
>
|
||||||
@@ -4901,54 +4810,6 @@ 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"
|
||||||
|
|||||||
@@ -46,6 +46,33 @@ test("parseNyFormatData returns validation errors for invalid fields", () => {
|
|||||||
assert.match(result[1].error || "", /目标地址格式错误/);
|
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", () => {
|
test("convertNyItemToForwardInput maps ny fields correctly", () => {
|
||||||
const mapped = convertNyItemToForwardInput({
|
const mapped = convertNyItemToForwardInput({
|
||||||
dest: ["1.1.1.1:1111", "2.2.2.2:2222"],
|
dest: ["1.1.1.1:1111", "2.2.2.2:2222"],
|
||||||
@@ -60,3 +87,18 @@ test("convertNyItemToForwardInput maps ny fields correctly", () => {
|
|||||||
strategy: "fifo",
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface NyImportItem {
|
export interface NyImportItem {
|
||||||
dest: string[];
|
dest: string[];
|
||||||
listen_port: number;
|
listen_port: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,70 @@ export interface ParsedNyImportLine {
|
|||||||
|
|
||||||
const ADDRESS_PATTERN = /^[^:]+:\d+$/;
|
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 => {
|
const isValidListenPort = (value: unknown): value is number => {
|
||||||
return (
|
return (
|
||||||
typeof value === "number" &&
|
typeof value === "number" &&
|
||||||
@@ -27,11 +91,19 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = value as Record<string, unknown>;
|
const item = value as Record<string, unknown>;
|
||||||
const dest = item.dest;
|
const dest = getAliasField(item, ["dest", "dst", "target", "targets"]);
|
||||||
const listenPort = item.listen_port;
|
const listenPortRaw = getAliasField(item, [
|
||||||
const name = item.name;
|
"listen_port",
|
||||||
|
"listenPort",
|
||||||
|
"port",
|
||||||
|
"in_port",
|
||||||
|
"inPort",
|
||||||
|
]);
|
||||||
|
const name = getAliasField(item, ["name", "forward_name", "forwardName"]);
|
||||||
|
const normalizedDest = normalizeDestList(dest);
|
||||||
|
const normalizedListenPort = normalizeListenPort(listenPortRaw);
|
||||||
|
|
||||||
if (!Array.isArray(dest) || dest.length === 0) {
|
if (!normalizedDest || normalizedDest.length === 0) {
|
||||||
return { line, error: "dest数组为空或格式错误" };
|
return { line, error: "dest数组为空或格式错误" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,16 +111,12 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
|||||||
return { line, error: "name不能为空" };
|
return { line, error: "name不能为空" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidListenPort(listenPort)) {
|
if (normalizedListenPort === undefined) {
|
||||||
return { line, error: "listen_port必须为1-65535之间的数字" };
|
return { line, error: "listen_port格式错误,应为1-65535之间的数字" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedDest = dest.map((itemValue) =>
|
if (normalizedListenPort !== null && !isValidListenPort(normalizedListenPort)) {
|
||||||
typeof itemValue === "string" ? itemValue.trim() : "",
|
return { line, error: "listen_port必须为1-65535之间的数字" };
|
||||||
);
|
|
||||||
|
|
||||||
if (normalizedDest.some((itemValue) => itemValue === "")) {
|
|
||||||
return { line, error: "dest中包含空地址" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalid = normalizedDest.find(
|
const invalid = normalizedDest.find(
|
||||||
@@ -63,7 +131,7 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
|||||||
line,
|
line,
|
||||||
parsed: {
|
parsed: {
|
||||||
dest: normalizedDest,
|
dest: normalizedDest,
|
||||||
listen_port: listenPort,
|
listen_port: normalizedListenPort,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user