mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56:37 +08:00
feat: Implement toggleable search input and data filtering across tunnel, user, and forward pages.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 用户列表 */}
|
||||
|
||||
Reference in New Issue
Block a user