feat: Implement toggleable search input and data filtering across tunnel, user, and forward pages.

This commit is contained in:
sagitchu
2026-02-21 20:43:32 +08:00
parent ca24573803
commit 572d1c16a6
5 changed files with 390 additions and 86 deletions
+71 -3
View File
@@ -76,6 +76,7 @@ import {
executeForwardBatchToggleService,
} from "@/pages/forward/batch-actions";
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
import { SearchIcon } from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
import { saveOrder } from "@/utils/order-storage";
@@ -126,6 +127,8 @@ export default function ForwardPage() {
const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const isMobile = useMobileBreakpoint();
const [searchKeyword, setSearchKeyword] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 显示模式状态 - 从localStorage读取,默认为平铺显示
const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => {
@@ -1103,6 +1106,15 @@ export default function ForwardPage() {
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
}
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredForwards = filteredForwards.filter(f =>
(f.name && f.name.toLowerCase().includes(lowerKeyword)) ||
(f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||
(f.userName && f.userName.toLowerCase().includes(lowerKeyword))
);
}
// 确保过滤后的转发列表有效
if (!filteredForwards || filteredForwards.length === 0) {
return [];
@@ -1144,7 +1156,7 @@ export default function ForwardPage() {
}
return sortedByDb;
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId]);
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId, searchKeyword]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
@@ -1609,8 +1621,64 @@ export default function ForwardPage() {
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6 gap-2">
<div className="flex-1"></div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索转发名称、地址或用户名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
{/* 筛选按钮 */}
<Button
+74 -6
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
@@ -20,6 +20,7 @@ import {
deleteSpeedLimit,
getTunnelList,
} from "@/api";
import { SearchIcon } from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
interface SpeedLimitRule {
@@ -51,6 +52,18 @@ export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules;
const lowerKeyword = searchKeyword.toLowerCase();
return rules.filter(r =>
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword))
);
}, [rules, searchKeyword]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -220,9 +233,64 @@ export default function LimitPage() {
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索规则名称或绑定隧道"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
@@ -230,9 +298,9 @@ export default function LimitPage() {
</div>
{/* 统一卡片网格 */}
{rules.length > 0 ? (
{filteredRules.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{rules.map((rule) => (
{filteredRules.map((rule) => (
<Card
key={rule.id}
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
+112 -42
View File
@@ -57,6 +57,7 @@ import {
getRemoteSyncErrorMessage,
} from "@/pages/node/display";
import { tryCopyInstallCommand } from "@/pages/node/install-command";
import { SearchIcon } from "@/components/icons";
import { buildNodeSystemInfo } from "@/pages/node/system-info";
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
@@ -150,6 +151,8 @@ export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
const [isEdit, setIsEdit] = useState(false);
@@ -821,23 +824,23 @@ export default function NodePage() {
prev.map((n) =>
n.id === form.id
? {
...n,
name: form.name,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
form.serverHost?.trim() ||
"",
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
http: form.http,
tls: form.tls,
socks: form.socks,
}
...n,
name: form.name,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
form.serverHost?.trim() ||
"",
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
http: form.http,
tls: form.tls,
socks: form.socks,
}
: n,
),
);
@@ -994,7 +997,19 @@ export default function NodePage() {
const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
const sortedByDb = [...nodeList].sort((a, b) => {
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 aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -1007,7 +1022,7 @@ export default function NodePage() {
nodeOrder.length > 0 &&
sortedByDb.every((n) => n.inx === undefined || n.inx === 0)
) {
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));
const localSorted: Node[] = [];
nodeOrder.forEach((id) => {
@@ -1016,7 +1031,7 @@ export default function NodePage() {
if (node) localSorted.push(node);
});
nodeList.forEach((node) => {
filteredNodes.forEach((node) => {
if (!nodeOrder.includes(node.id)) {
localSorted.push(node);
}
@@ -1026,7 +1041,7 @@ export default function NodePage() {
}
return sortedByDb;
}, [nodeList, nodeOrder]);
}, [nodeList, nodeOrder, searchKeyword]);
const sortableNodeIds = useMemo(
() => sortedNodes.map((n) => n.id),
@@ -1035,9 +1050,64 @@ export default function NodePage() {
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索节点名称或IP"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
</div>
<div className="flex gap-2 items-center">
<Button
@@ -1208,7 +1278,7 @@ export default function NodePage() {
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span
@@ -1264,7 +1334,7 @@ export default function NodePage() {
</span>
<span className="text-xs">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
</span>
@@ -1283,7 +1353,7 @@ export default function NodePage() {
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
@@ -1300,7 +1370,7 @@ export default function NodePage() {
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
@@ -1311,7 +1381,7 @@ export default function NodePage() {
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
@@ -1328,7 +1398,7 @@ export default function NodePage() {
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
@@ -1343,10 +1413,10 @@ export default function NodePage() {
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatSpeed(
node.systemInfo.uploadSpeed,
)
node.systemInfo.uploadSpeed,
)
: "-"}
</div>
</div>
@@ -1356,10 +1426,10 @@ export default function NodePage() {
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatSpeed(
node.systemInfo.downloadSpeed,
)
node.systemInfo.downloadSpeed,
)
: "-"}
</div>
</div>
@@ -1373,10 +1443,10 @@ export default function NodePage() {
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatTraffic(
node.systemInfo.uploadTraffic,
)
node.systemInfo.uploadTraffic,
)
: "-"}
</div>
</div>
@@ -1386,10 +1456,10 @@ export default function NodePage() {
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
+75 -7
View File
@@ -45,6 +45,7 @@ import {
batchDeleteTunnels,
batchRedeployTunnels,
} from "@/api";
import { SearchIcon } from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import {
buildDiagnosisFallbackResult,
@@ -111,6 +112,8 @@ export default function TunnelPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -597,7 +600,17 @@ export default function TunnelPage() {
const sortedTunnels = useMemo((): Tunnel[] => {
if (!tunnels || tunnels.length === 0) return [];
const sortedByDb = [...tunnels].sort((a, b) => {
let filteredTunnels = tunnels;
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredTunnels = filteredTunnels.filter(t =>
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword))
);
}
const sortedByDb = [...filteredTunnels].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -610,7 +623,7 @@ export default function TunnelPage() {
tunnelOrder.length > 0 &&
sortedByDb.every((t) => t.inx === undefined || t.inx === 0)
) {
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
const tunnelMap = new Map(filteredTunnels.map((t) => [t.id, t] as const));
const localSorted: Tunnel[] = [];
tunnelOrder.forEach((id) => {
@@ -619,7 +632,7 @@ export default function TunnelPage() {
if (tunnel) localSorted.push(tunnel);
});
tunnels.forEach((tunnel) => {
filteredTunnels.forEach((tunnel) => {
if (!tunnelOrder.includes(tunnel.id)) {
localSorted.push(tunnel);
}
@@ -629,7 +642,7 @@ export default function TunnelPage() {
}
return sortedByDb;
}, [tunnels, tunnelOrder]);
}, [tunnels, tunnelOrder, searchKeyword]);
const sortableTunnelIds = useMemo(
() => sortedTunnels.map((t) => t.id),
@@ -672,9 +685,64 @@ export default function TunnelPage() {
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索隧道名称或IP"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
+58 -28
View File
@@ -121,6 +121,7 @@ export default function UserPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
size: 10,
@@ -611,40 +612,69 @@ export default function UserPage() {
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center gap-3" />
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="flex items-center gap-3 flex-1 max-w-md">
<Input
className="flex-1"
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索用户名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索用户"
className="min-h-10 w-10"
color="primary"
variant="solid"
onPress={handleSearch}
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
</div>
<Button color="primary" variant="flat" onPress={handleAdd}>
新增
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索用户名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
{/* 用户列表 */}