From feb357ff177bbaaa29906bc68a0a272c0628184a Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:30:25 +0800 Subject: [PATCH] fix: tunnel chain order and speed limit UI improvements (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): use correct chain order index for tunnel nodes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix(frontend): filter '不限速' from speed limit dropdowns and preserve node order Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --------- Co-authored-by: Sisyphus --- go-backend/internal/http/handler/mutations.go | 8 +-- vite-frontend/src/pages/forward.tsx | 34 +++++++++--- vite-frontend/src/pages/tunnel.tsx | 53 ++++++++++--------- vite-frontend/src/pages/user.tsx | 40 +++++++++++--- 4 files changed, 93 insertions(+), 42 deletions(-) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9308287..eb4b99b 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -2806,7 +2806,7 @@ func pickNodeAddressV6(node *nodeRecord) string { func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error { allocated := map[int64]int{} inNodes := asMapSlice(req["inNodeId"]) - for _, n := range inNodes { + for i, n := range inNodes { nodeID := asInt64(n["nodeId"], 0) if nodeID <= 0 { continue @@ -2818,13 +2818,13 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str nodeID, sql.NullInt64{}, defaultString(asString(n["strategy"]), "round"), - 0, + i+1, defaultString(asString(n["protocol"]), "tls"), ); err != nil { return err } } - for _, n := range asMapSlice(req["outNodeId"]) { + for i, n := range asMapSlice(req["outNodeId"]) { nodeID := asInt64(n["nodeId"], 0) if nodeID <= 0 { continue @@ -2844,7 +2844,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str nodeID, sql.NullInt64{Int64: int64(port), Valid: true}, defaultString(asString(n["strategy"]), "round"), - 0, + i+1, defaultString(asString(n["protocol"]), "tls"), ); err != nil { return err diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index b9d1226..8903131 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -503,10 +503,30 @@ export default function ForwardPage() { }; // 表单验证 - const availableSpeedLimits = useMemo(() => { - return speedLimits; + const noLimitSpeedLimitIds = useMemo(() => { + return new Set( + speedLimits + .filter((speedLimit) => speedLimit.name.trim() === "不限速") + .map((speedLimit) => speedLimit.id), + ); }, [speedLimits]); + const availableSpeedLimits = useMemo(() => { + return speedLimits.filter( + (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id), + ); + }, [speedLimits, noLimitSpeedLimitIds]); + + const normalizeSpeedId = (speedId?: number | null): number | null => { + if (speedId === null || speedId === undefined) { + return null; + } + + return noLimitSpeedLimitIds.has(speedId) ? null : speedId; + }; + + const selectedSpeedId = normalizeSpeedId(form.speedId); + const validateForm = (): boolean => { const newErrors: { [key: string]: string } = {}; @@ -591,7 +611,7 @@ export default function ForwardPage() { remoteAddr: forward.remoteAddr.split(",").join("\n"), interfaceName: forward.interfaceName || "", strategy: forward.strategy || "fifo", - speedId: forward.speedId ?? null, + speedId: normalizeSpeedId(forward.speedId), }); setErrors({}); setModalOpen(true); @@ -671,7 +691,7 @@ export default function ForwardPage() { inPort: form.inPort, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : "fifo", - speedId: form.speedId, + speedId: normalizeSpeedId(form.speedId), }; res = await updateForward(updateData); @@ -683,7 +703,7 @@ export default function ForwardPage() { inPort: form.inPort, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : "fifo", - speedId: form.speedId, + speedId: normalizeSpeedId(form.speedId), }; res = await createForward(createData); @@ -2331,8 +2351,8 @@ export default function ForwardPage() { label="限速规则" placeholder="不限速" selectedKeys={ - form.speedId !== null && form.speedId !== undefined - ? [form.speedId.toString()] + selectedSpeedId !== null + ? [selectedSpeedId.toString()] : ["null"] } variant="bordered" diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index afbf5e3..e52dd9d 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -366,6 +366,21 @@ export default function TunnelPage() { return form.chainNodes || []; }; + const mergeOrderedNodes = ( + currentNodes: ChainTunnel[], + selectedNodeIds: number[], + buildDefault: (nodeId: number) => ChainTunnel, + ): ChainTunnel[] => { + const selectedSet = new Set(selectedNodeIds); + const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId)); + const keptIds = new Set(kept.map((node) => node.nodeId)); + const added = selectedNodeIds + .filter((nodeId) => !keptIds.has(nodeId)) + .map((nodeId) => buildDefault(nodeId)); + + return [...kept, ...added]; + }; + // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; @@ -1241,15 +1256,10 @@ export default function TunnelPage() { const selectedIds = Array.from(keys).map((key) => parseInt(key as string), ); - const newInNodeId: ChainTunnel[] = selectedIds.map( - (nodeId) => { - // 保留已有的端口配置 - const existing = form.inNodeId.find( - (ct) => ct.nodeId === nodeId, - ); - - return existing || { nodeId, chainType: 1 }; - }, + const newInNodeId = mergeOrderedNodes( + form.inNodeId, + selectedIds, + (nodeId) => ({ nodeId, chainType: 1 }), ); setForm((prev) => ({ ...prev, inNodeId: newInNodeId })); @@ -1659,21 +1669,16 @@ export default function TunnelPage() { const realNodes = currentOutNodes.filter( (ct) => ct.nodeId !== -1, ); - const newOutNodeId: ChainTunnel[] = - selectedIds.map((nodeId) => { - const existing = realNodes.find( - (ct) => ct.nodeId === nodeId, - ); - - return ( - existing || { - nodeId, - chainType: 3, - protocol, - strategy, - } - ); - }); + const newOutNodeId = mergeOrderedNodes( + realNodes, + selectedIds, + (nodeId) => ({ + nodeId, + chainType: 3, + protocol, + strategy, + }), + ); setForm((prev) => ({ ...prev, diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index 6c1161d..4de0c00 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; import { parseDate } from "@internationalized/date"; @@ -219,6 +219,22 @@ export default function UserPage() { const [speedLimits, setSpeedLimits] = useState([]); const [userGroups, setUserGroups] = useState([]); + const noLimitSpeedLimitIds = useMemo(() => { + return new Set( + speedLimits + .filter((speedLimit) => speedLimit.name.trim() === "不限速") + .map((speedLimit) => speedLimit.id), + ); + }, [speedLimits]); + + const normalizeSpeedId = (speedId?: number | null): number | null => { + if (speedId === null || speedId === undefined) { + return null; + } + + return noLimitSpeedLimitIds.has(speedId) ? null : speedId; + }; + // 生命周期 useEffect(() => { loadUsers(); @@ -432,7 +448,10 @@ export default function UserPage() { try { const tunnelsToAssign: TunnelAssignItem[] = Array.from( batchTunnelSelections.entries(), - ).map(([tunnelId, speedId]) => ({ tunnelId, speedId })); + ).map(([tunnelId, speedId]) => ({ + tunnelId, + speedId: normalizeSpeedId(speedId), + })); const response = await batchAssignUserTunnel({ userId: currentUser.id, @@ -456,6 +475,7 @@ export default function UserPage() { const handleEditTunnel = (userTunnel: UserTunnel) => { setEditTunnelForm({ ...userTunnel, + speedId: normalizeSpeedId(userTunnel.speedId), expTime: userTunnel.expTime, }); onEditTunnelModalOpen(); @@ -472,7 +492,7 @@ export default function UserPage() { num: editTunnelForm.num, expTime: editTunnelForm.expTime, flowResetTime: editTunnelForm.flowResetTime, - speedId: editTunnelForm.speedId, + speedId: normalizeSpeedId(editTunnelForm.speedId), status: editTunnelForm.status, }); @@ -582,12 +602,18 @@ export default function UserPage() { } }; - const editAvailableSpeedLimits = speedLimits; + const editAvailableSpeedLimits = speedLimits.filter( + (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id), + ); const getSpeedLimitsForTunnel = (_tunnelId: number) => { - return speedLimits; + return speedLimits.filter( + (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id), + ); }; + const editTunnelSelectedSpeedId = normalizeSpeedId(editTunnelForm?.speedId); + const toggleTunnelSelection = (tunnelId: number) => { setBatchTunnelSelections((prev) => { const newMap = new Map(prev); @@ -1404,8 +1430,8 @@ export default function UserPage() {