From 0ad4904e20c0728b0fe755aca54f4c5484e00b7c Mon Sep 17 00:00:00 2001 From: Su-cyber-art <72259479+Su-cyber-art@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:43:41 +0800 Subject: [PATCH 1/4] feat(node): split local and remote nodes into separate tabs (cherry picked from commit 30793b2997040dd29ff5fe6397d0bcab174ecc9e) --- vite-frontend/src/pages/node.tsx | 159 ++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 32 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index fc78251..23ed558 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; import { DndContext, KeyboardSensor, @@ -64,6 +65,7 @@ import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; +import { PlusIcon, DeleteIcon, CheckAllIcon, XCircleIcon, ListCheckIcon, XIcon, ArrowUpIcon, ShareIcon } from "@/components/icons"; interface Node { id: number; @@ -116,6 +118,8 @@ interface NodeForm { socks: number; // 0 关 1 开 } +type NodeTab = "local" | "remote"; + const SortableItem = ({ id, children, @@ -158,6 +162,7 @@ const SortableItem = ({ }; export default function NodePage() { + const navigate = useNavigate(); const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); @@ -165,6 +170,10 @@ export default function NodePage() { "node-search-keyword", "", ); + const [activeTab, setActiveTab] = useLocalStorageState( + "node-active-tab", + "local", + ); const [isSearchVisible, setIsSearchVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); @@ -963,7 +972,7 @@ export default function NodePage() { }; const selectAll = () => { - setSelectedIds(new Set(sortedNodes.map((n) => n.id))); + setSelectedIds(new Set(displayNodes.map((n) => n.id))); }; const deselectAll = () => { @@ -1062,94 +1071,169 @@ export default function NodePage() { return sortedByDb; }, [nodeList, nodeOrder, searchKeyword]); - const sortableNodeIds = useMemo( - () => sortedNodes.map((n) => n.id), + const localNodes = useMemo( + () => sortedNodes.filter((node) => node.isRemote !== 1), [sortedNodes], ); + const remoteNodes = useMemo( + () => sortedNodes.filter((node) => node.isRemote === 1), + [sortedNodes], + ); + + const displayNodes = useMemo( + () => (activeTab === "remote" ? remoteNodes : localNodes), + [activeTab, localNodes, remoteNodes], + ); + + const sortableNodeIds = useMemo( + () => displayNodes.map((n) => n.id), + [displayNodes], + ); + return ( -
-
- setIsSearchVisible(false)} - onOpen={() => setIsSearchVisible(true)} - /> +
+
+ +
-
-
+
+
+ setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
+ +
{selectMode ? ( <> - 已选 {selectedIds.size} 项 + 已选择 {selectedIds.size} 项 ) : ( <> + )} @@ -1176,6 +1260,15 @@ export default function NodePage() { className="h-64" message="暂无节点配置,点击上方按钮开始创建" /> + ) : displayNodes.length === 0 ? ( + ) : (
- {sortedNodes.map((node) => { + {displayNodes.map((node) => { const isRemoteNode = node.isRemote === 1; return ( @@ -1195,18 +1288,20 @@ export default function NodePage() { >
-
+
{selectMode && ( toggleSelect(node.id)} /> )} -

- {node.name} -

+
+

+ {node.name} +

+
-
+
Date: Sun, 8 Mar 2026 12:19:16 +0800 Subject: [PATCH 2/4] feat(node): render remote usage details on remote node cards (cherry picked from commit 67294fa76422f994de8d95ec88f8f03e02ea6b9f) --- vite-frontend/src/pages/node.tsx | 191 ++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 23ed558..37a3ff7 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -52,6 +52,7 @@ import { batchUpgradeNodes, getNodeReleases, rollbackNode, + getPeerRemoteUsageList, type ReleaseChannel, } from "@/api"; import { PageEmptyState, PageLoadingState } from "@/components/page-state"; @@ -120,6 +121,33 @@ interface NodeForm { type NodeTab = "local" | "remote"; +interface RemoteUsageBinding { + bindingId: number; + tunnelId: number; + tunnelName: string; + chainType: number; + hopInx: number; + allocatedPort: number; + resourceKey: string; + remoteBindingId: string; + updatedTime: number; +} + +interface RemoteUsageNode { + nodeId: number; + nodeName: string; + remoteUrl: string; + shareId: number; + portRangeStart: number; + portRangeEnd: number; + maxBandwidth: number; + currentFlow: number; + usedPorts: number[]; + bindings: RemoteUsageBinding[]; + activeBindingNum: number; + syncError?: string; +} + const SortableItem = ({ id, children, @@ -174,6 +202,9 @@ export default function NodePage() { "node-active-tab", "local", ); + const [remoteUsageMap, setRemoteUsageMap] = useState< + Record + >({}); const [isSearchVisible, setIsSearchVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); @@ -259,6 +290,25 @@ export default function NodePage() { onNodeOffline: handleNodeOffline, }); + const loadRemoteUsage = useCallback(async () => { + try { + const res = await getPeerRemoteUsageList(); + + if (res.code === 0 && Array.isArray(res.data)) { + const nextMap: Record = {}; + + (res.data as unknown as RemoteUsageNode[]).forEach((item) => { + if (!item || typeof item.nodeId !== "number") return; + nextMap[item.nodeId] = item; + }); + + setRemoteUsageMap(nextMap); + } + } catch { + // ignore remote usage errors in node page + } + }, []); + // 加载节点列表 const loadNodes = useCallback(async () => { setLoading(true); @@ -385,7 +435,8 @@ export default function NodePage() { useEffect(() => { loadNodes(); - }, [loadNodes]); + loadRemoteUsage(); + }, [loadNodes, loadRemoteUsage]); // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { @@ -428,6 +479,32 @@ export default function NodePage() { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; + const formatFlow = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + }; + + const formatChainType = (chainType: number, hopInx: number) => { + if (chainType === 1) { + return "入口节点"; + } + if (chainType === 2) { + return `中继跳点 #${hopInx}`; + } + if (chainType === 3) { + return "出口节点"; + } + + return "未知链路"; + }; + // 获取进度条颜色 const getProgressColor = ( value: number, @@ -1278,6 +1355,7 @@ export default function NodePage() {
{displayNodes.map((node) => { const isRemoteNode = node.isRemote === 1; + const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null; return ( @@ -1427,6 +1505,117 @@ export default function NodePage() { )}
+ {isRemoteNode && ( +
+ {remoteUsage ? ( + <> +
+
+ 远程地址 + + {remoteUsage.remoteUrl || node.remoteUrl || "-"} + +
+
+ 共享ID + #{remoteUsage.shareId} +
+
+ 流量 + + {formatFlow(remoteUsage.currentFlow)} + +
+
+ 带宽上限 + + {remoteUsage.maxBandwidth > 0 + ? formatSpeed(remoteUsage.maxBandwidth) + : "不限"} + +
+
+ +
+
+ 占用端口 + + {remoteUsage.usedPorts.length}/{Math.max( + remoteUsage.portRangeEnd - + remoteUsage.portRangeStart + + 1, + 0, + )} + +
+
+ {remoteUsage.usedPorts.length > 0 ? ( +
+ {remoteUsage.usedPorts.map((port) => ( + + {port} + + ))} +
+ ) : ( +
暂无占用端口
+ )} +
+
+ +
+
+ 绑定明细 + + {remoteUsage.activeBindingNum} + +
+
+ {remoteUsage.bindings.length > 0 ? ( + remoteUsage.bindings.map((binding) => ( +
+
+ + {binding.tunnelName} + + + #{binding.tunnelId} + +
+
+ {formatChainType(binding.chainType, binding.hopInx)} + 端口 {binding.allocatedPort} +
+
+ )) + ) : ( +
暂无绑定明细
+ )} +
+
+ + ) : ( +
+ 暂未获取到远程占用数据 +
+ )} +
+ )} + {!isRemoteNode && ( <> {/* 系统监控 */} From 61b71a11c79d250198c8a01c44312ed977bcb96c Mon Sep 17 00:00:00 2001 From: Su-cyber-art <72259479+Su-cyber-art@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:45:56 +0800 Subject: [PATCH 3/4] feat(node): split node page into local and remote sections --- vite-frontend/src/pages/node.tsx | 50 ++++++++------------------------ 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 37a3ff7..8438fb1 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; + import { DndContext, KeyboardSensor, @@ -66,7 +66,7 @@ import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; -import { PlusIcon, DeleteIcon, CheckAllIcon, XCircleIcon, ListCheckIcon, XIcon, ArrowUpIcon, ShareIcon } from "@/components/icons"; + interface Node { id: number; @@ -190,7 +190,6 @@ const SortableItem = ({ }; export default function NodePage() { - const navigate = useNavigate(); const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); @@ -1222,95 +1221,70 @@ export default function NodePage() { {selectMode ? ( <> - 已选择 {selectedIds.size} 项 + 已选 {selectedIds.size} 项 ) : ( <> - )} From 647446a2a2aa5a6c9a0c62602aa237e993630edb Mon Sep 17 00:00:00 2001 From: sagitchu Date: Mon, 9 Mar 2026 12:17:25 +0800 Subject: [PATCH 4/4] fix(node): isolate tab search and selection state --- vite-frontend/src/pages/node.tsx | 131 +++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 34 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 8438fb1..5da8f61 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -193,8 +193,12 @@ export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); - const [searchKeyword, setSearchKeyword] = useLocalStorageState( - "node-search-keyword", + const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState( + "node-search-keyword-local", + "", + ); + const [remoteSearchKeyword, setRemoteSearchKeyword] = useLocalStorageState( + "node-search-keyword-remote", "", ); const [activeTab, setActiveTab] = useLocalStorageState( @@ -437,6 +441,10 @@ export default function NodePage() { loadRemoteUsage(); }, [loadNodes, loadRemoteUsage]); + useEffect(() => { + setSelectedIds(new Set()); + }, [activeTab]); + // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { if (bytesPerSecond === 0) return "0 B/s"; @@ -849,17 +857,30 @@ export default function NodePage() { ); } } else if (upgradeTarget === "batch") { + const selectedLocalIds = Array.from(selectedIds).filter((id) => { + const matchedNode = nodeList.find((node) => node.id === id); + + return matchedNode?.isRemote !== 1; + }); + + if (selectedLocalIds.length === 0) { + toast.error("请选择本地节点进行升级"); + setUpgradeModalOpen(false); + + return; + } + setBatchUpgradeLoading(true); setUpgradeModalOpen(false); try { const res = await batchUpgradeNodes( - Array.from(selectedIds), + selectedLocalIds, version, releaseChannel, ); if (res.code === 0) { - toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); + toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`); } else { toast.error(res.msg || "批量升级失败"); } @@ -990,12 +1011,26 @@ export default function NodePage() { if (isNaN(activeId) || isNaN(overId)) return; - const oldIndex = nodeOrder.indexOf(activeId); - const newIndex = nodeOrder.indexOf(overId); + const displayNodeIds = displayNodes.map((node) => node.id); + const oldIndex = displayNodeIds.indexOf(activeId); + const newIndex = displayNodeIds.indexOf(overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; - const newOrder = arrayMove(nodeOrder, oldIndex, newIndex); + const reorderedDisplayIds = arrayMove(displayNodeIds, oldIndex, newIndex); + const displayIdSet = new Set(displayNodeIds); + let reorderedDisplayIndex = 0; + + const newOrder = nodeOrder.map((id) => { + if (!displayIdSet.has(id)) { + return id; + } + + const nextId = reorderedDisplayIds[reorderedDisplayIndex]; + reorderedDisplayIndex += 1; + + return nextId; + }); setNodeOrder(newOrder); @@ -1099,21 +1134,7 @@ export default function NodePage() { const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; - let filteredNodes = nodeList; - - if (searchKeyword.trim()) { - const lowerKeyword = searchKeyword.toLowerCase(); - - filteredNodes = filteredNodes.filter( - (n) => - (n.name && n.name.toLowerCase().includes(lowerKeyword)) || - (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), - ); - } - - const sortedByDb = [...filteredNodes].sort((a, b) => { + const sortedByDb = [...nodeList].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; @@ -1126,7 +1147,7 @@ export default function NodePage() { nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0) ) { - const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const)); + const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const)); const localSorted: Node[] = []; nodeOrder.forEach((id) => { @@ -1135,7 +1156,7 @@ export default function NodePage() { if (node) localSorted.push(node); }); - filteredNodes.forEach((node) => { + nodeList.forEach((node) => { if (!nodeOrder.includes(node.id)) { localSorted.push(node); } @@ -1145,7 +1166,26 @@ export default function NodePage() { } return sortedByDb; - }, [nodeList, nodeOrder, searchKeyword]); + }, [nodeList, nodeOrder]); + + const filterNodesByKeyword = useCallback((nodes: Node[], keyword: string) => { + const normalizedKeyword = keyword.trim().toLowerCase(); + + if (!normalizedKeyword) { + return nodes; + } + + return nodes.filter( + (node) => + (node.name && node.name.toLowerCase().includes(normalizedKeyword)) || + (node.serverIp && + node.serverIp.toLowerCase().includes(normalizedKeyword)) || + (node.serverIpV4 && + node.serverIpV4.toLowerCase().includes(normalizedKeyword)) || + (node.serverIpV6 && + node.serverIpV6.toLowerCase().includes(normalizedKeyword)), + ); + }, []); const localNodes = useMemo( () => sortedNodes.filter((node) => node.isRemote !== 1), @@ -1157,11 +1197,30 @@ export default function NodePage() { [sortedNodes], ); - const displayNodes = useMemo( - () => (activeTab === "remote" ? remoteNodes : localNodes), - [activeTab, localNodes, remoteNodes], + const filteredLocalNodes = useMemo( + () => filterNodesByKeyword(localNodes, localSearchKeyword), + [filterNodesByKeyword, localNodes, localSearchKeyword], ); + const filteredRemoteNodes = useMemo( + () => filterNodesByKeyword(remoteNodes, remoteSearchKeyword), + [filterNodesByKeyword, remoteNodes, remoteSearchKeyword], + ); + + const currentSearchKeyword = + activeTab === "remote" ? remoteSearchKeyword : localSearchKeyword; + + const setCurrentSearchKeyword = + activeTab === "remote" ? setRemoteSearchKeyword : setLocalSearchKeyword; + + const displayNodes = useMemo( + () => (activeTab === "remote" ? filteredRemoteNodes : filteredLocalNodes), + [activeTab, filteredLocalNodes, filteredRemoteNodes], + ); + + const canBatchUpgrade = activeTab === "local"; + const isSearchFiltered = currentSearchKeyword.trim().length > 0; + const sortableNodeIds = useMemo( () => displayNodes.map((n) => n.id), [displayNodes], @@ -1210,8 +1269,8 @@ export default function NodePage() { ? "搜索远程节点名称或IP" : "搜索本地节点名称或IP" } - value={searchKeyword} - onChange={setSearchKeyword} + value={currentSearchKeyword} + onChange={setCurrentSearchKeyword} onClose={() => setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} /> @@ -1241,7 +1300,7 @@ export default function NodePage() {