feat(node): split local and remote nodes into separate tabs

(cherry picked from commit 30793b2997040dd29ff5fe6397d0bcab174ecc9e)
This commit is contained in:
Su-cyber-art
2026-03-08 06:43:41 +08:00
parent bd30b61018
commit 0ad4904e20
+127 -32
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -64,6 +65,7 @@ import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import { PlusIcon, DeleteIcon, CheckAllIcon, XCircleIcon, ListCheckIcon, XIcon, ArrowUpIcon, ShareIcon } from "@/components/icons";
interface Node { interface Node {
id: number; id: number;
@@ -116,6 +118,8 @@ interface NodeForm {
socks: number; // 0 关 1 开 socks: number; // 0 关 1 开
} }
type NodeTab = "local" | "remote";
const SortableItem = ({ const SortableItem = ({
id, id,
children, children,
@@ -158,6 +162,7 @@ const SortableItem = ({
}; };
export default function NodePage() { export default function NodePage() {
const navigate = useNavigate();
const [nodeList, setNodeList] = useState<Node[]>([]); const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]); const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -165,6 +170,10 @@ export default function NodePage() {
"node-search-keyword", "node-search-keyword",
"", "",
); );
const [activeTab, setActiveTab] = useLocalStorageState<NodeTab>(
"node-active-tab",
"local",
);
const [isSearchVisible, setIsSearchVisible] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState(""); const [dialogTitle, setDialogTitle] = useState("");
@@ -963,7 +972,7 @@ export default function NodePage() {
}; };
const selectAll = () => { const selectAll = () => {
setSelectedIds(new Set(sortedNodes.map((n) => n.id))); setSelectedIds(new Set(displayNodes.map((n) => n.id)));
}; };
const deselectAll = () => { const deselectAll = () => {
@@ -1062,94 +1071,169 @@ export default function NodePage() {
return sortedByDb; return sortedByDb;
}, [nodeList, nodeOrder, searchKeyword]); }, [nodeList, nodeOrder, searchKeyword]);
const sortableNodeIds = useMemo( const localNodes = useMemo(
() => sortedNodes.map((n) => n.id), () => sortedNodes.filter((node) => node.isRemote !== 1),
[sortedNodes], [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 ( return (
<AnimatedPage className="px-3 lg:px-6 py-8"> <AnimatedPage className="px-3 lg:px-6 py-8">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3"> <div className="mb-6 space-y-3">
<div className="flex-1 max-w-sm flex items-center gap-2"> <div className="flex items-center gap-2 overflow-x-auto pb-1">
<SearchBar <Button
isVisible={isSearchVisible} className="shrink-0"
placeholder="搜索节点名称或IP" color={activeTab === "local" ? "primary" : "default"}
value={searchKeyword} size="sm"
onChange={setSearchKeyword} variant={activeTab === "local" ? "solid" : "flat"}
onClose={() => setIsSearchVisible(false)} onPress={() => setActiveTab("local")}
onOpen={() => setIsSearchVisible(true)} >
/> 本地节点
<Chip className="ml-1" size="sm" variant="flat">
{localNodes.length}
</Chip>
</Button>
<Button
className="shrink-0"
color={activeTab === "remote" ? "primary" : "default"}
size="sm"
variant={activeTab === "remote" ? "solid" : "flat"}
onPress={() => setActiveTab("remote")}
>
远程节点
<Chip className="ml-1" size="sm" variant="flat">
{remoteNodes.length}
</Chip>
</Button>
</div> </div>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x"> <div className="flex flex-row items-center justify-between gap-3 overflow-x-auto pb-1">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0"> <div
className={`flex-1 max-w-sm flex items-center gap-2 shrink-0 ${
isSearchVisible ? "min-w-[200px]" : "min-w-0"
}`}
>
<SearchBar
isVisible={isSearchVisible}
placeholder={
activeTab === "remote"
? "搜索远程节点名称或IP"
: "搜索本地节点名称或IP"
}
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
<div className="flex h-8 items-center justify-end gap-2 whitespace-nowrap shrink-0">
{selectMode ? ( {selectMode ? (
<> <>
<span className="text-sm text-default-600 shrink-0"> <span className="text-sm text-default-600 shrink-0">
已选 {selectedIds.size} 项 已选择 {selectedIds.size} 项
</span> </span>
<Button <Button
isIconOnly
color="primary" color="primary"
size="sm" size="sm"
title="全选"
variant="flat" variant="flat"
onPress={selectAll} onPress={selectAll}
> >
全选 <CheckAllIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
isIconOnly
color="secondary" color="secondary"
size="sm" size="sm"
title="清空"
variant="flat" variant="flat"
onPress={deselectAll} onPress={deselectAll}
> >
清空 <XCircleIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
isIconOnly
color="warning" color="warning"
isDisabled={selectedIds.size === 0} isDisabled={selectedIds.size === 0}
isLoading={batchUpgradeLoading} isLoading={batchUpgradeLoading}
size="sm" size="sm"
title="升级"
variant="flat" variant="flat"
onPress={() => openUpgradeModal("batch")} onPress={() => openUpgradeModal("batch")}
> >
升级 <ArrowUpIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
isIconOnly
color="danger" color="danger"
isDisabled={selectedIds.size === 0} isDisabled={selectedIds.size === 0}
size="sm" size="sm"
title="删除"
variant="flat" variant="flat"
onPress={() => setBatchDeleteModalOpen(true)} onPress={() => setBatchDeleteModalOpen(true)}
> >
删除 <DeleteIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
color="secondary" isIconOnly
color="default"
size="sm" size="sm"
title="退出"
variant="solid" variant="solid"
onPress={toggleSelectMode} onPress={toggleSelectMode}
> >
退出 <XIcon className="w-4 h-4" />
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Button <Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45" isIconOnly
className="bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-300 dark:hover:bg-violet-900/45"
color="default" color="default"
size="sm" size="sm"
title="选择"
variant="flat" variant="flat"
onPress={toggleSelectMode} onPress={toggleSelectMode}
> >
批量 <ListCheckIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
isIconOnly
className="bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/45"
color="default"
size="sm"
title="面板共享"
variant="flat"
onPress={() => navigate("/panel-sharing")}
>
<ShareIcon className="w-4 h-4" />
</Button>
<Button
isIconOnly
color="primary" color="primary"
size="sm" size="sm"
title="新增"
variant="flat" variant="flat"
onPress={handleAdd} onPress={handleAdd}
> >
新增 <PlusIcon className="w-4 h-4" />
</Button> </Button>
</> </>
)} )}
@@ -1176,6 +1260,15 @@ export default function NodePage() {
className="h-64" className="h-64"
message="暂无节点配置,点击上方按钮开始创建" message="暂无节点配置,点击上方按钮开始创建"
/> />
) : displayNodes.length === 0 ? (
<PageEmptyState
className="h-64"
message={
activeTab === "remote"
? "暂无远程节点"
: "暂无本地节点,点击上方按钮开始创建"
}
/>
) : ( ) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext <SortableContext
@@ -1183,7 +1276,7 @@ export default function NodePage() {
strategy={rectSortingStrategy} 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">
{sortedNodes.map((node) => { {displayNodes.map((node) => {
const isRemoteNode = node.isRemote === 1; const isRemoteNode = node.isRemote === 1;
return ( return (
@@ -1195,18 +1288,20 @@ export default function NodePage() {
> >
<CardHeader className="pb-2 md:pb-2"> <CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
{selectMode && ( {selectMode && (
<Checkbox <Checkbox
isSelected={selectedIds.has(node.id)} isSelected={selectedIds.has(node.id)}
onValueChange={() => toggleSelect(node.id)} onValueChange={() => toggleSelect(node.id)}
/> />
)} )}
<h3 className="font-semibold text-foreground truncate text-sm"> <div className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain whitespace-nowrap [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-default-300/80">
{node.name} <h3 className="inline-block min-w-full pr-2 font-semibold text-foreground text-sm">
</h3> {node.name}
</h3>
</div>
</div> </div>
<div className="flex items-center gap-1.5 ml-2"> <div className="flex shrink-0 items-center gap-1.5 ml-2 whitespace-nowrap">
<div <div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100" className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners} {...listeners}
@@ -1240,7 +1335,7 @@ export default function NodePage() {
return ( return (
<Chip <Chip
className="text-xs" className="text-xs shrink-0 whitespace-nowrap"
color={connectionStatusMeta.color} color={connectionStatusMeta.color}
size="sm" size="sm"
variant="flat" variant="flat"