mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 03:06: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,
|
executeForwardBatchToggleService,
|
||||||
} from "@/pages/forward/batch-actions";
|
} from "@/pages/forward/batch-actions";
|
||||||
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
|
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
|
||||||
|
import { SearchIcon } from "@/components/icons";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
|
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
|
||||||
import { saveOrder } from "@/utils/order-storage";
|
import { saveOrder } from "@/utils/order-storage";
|
||||||
@@ -126,6 +127,8 @@ export default function ForwardPage() {
|
|||||||
const [forwards, setForwards] = useState<Forward[]>([]);
|
const [forwards, setForwards] = useState<Forward[]>([]);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
const isMobile = useMobileBreakpoint();
|
const isMobile = useMobileBreakpoint();
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
|
||||||
// 显示模式状态 - 从localStorage读取,默认为平铺显示
|
// 显示模式状态 - 从localStorage读取,默认为平铺显示
|
||||||
const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => {
|
const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => {
|
||||||
@@ -1103,6 +1106,15 @@ export default function ForwardPage() {
|
|||||||
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
|
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) {
|
if (!filteredForwards || filteredForwards.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -1144,7 +1156,7 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortedByDb;
|
return sortedByDb;
|
||||||
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId]);
|
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId, searchKeyword]);
|
||||||
|
|
||||||
const sortableForwardIds = useMemo(
|
const sortableForwardIds = useMemo(
|
||||||
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
|
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
|
||||||
@@ -1609,8 +1621,64 @@ export default function ForwardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div className="px-3 lg:px-6 py-8">
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div className="flex items-center justify-between mb-6 gap-2">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||||
<div className="flex-1"></div>
|
<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">
|
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
|
||||||
{/* 筛选按钮 */}
|
{/* 筛选按钮 */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
deleteSpeedLimit,
|
deleteSpeedLimit,
|
||||||
getTunnelList,
|
getTunnelList,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { SearchIcon } from "@/components/icons";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
|
|
||||||
interface SpeedLimitRule {
|
interface SpeedLimitRule {
|
||||||
@@ -51,6 +52,18 @@ export default function LimitPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
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);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -220,9 +233,64 @@ export default function LimitPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div 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="flex items-center justify-between mb-6">
|
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||||
<div className="flex-1" />
|
{!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}>
|
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||||
新增
|
新增
|
||||||
@@ -230,9 +298,9 @@ export default function LimitPage() {
|
|||||||
</div>
|
</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">
|
<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
|
<Card
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
|
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
getRemoteSyncErrorMessage,
|
getRemoteSyncErrorMessage,
|
||||||
} from "@/pages/node/display";
|
} from "@/pages/node/display";
|
||||||
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||||
|
import { SearchIcon } from "@/components/icons";
|
||||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
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";
|
||||||
@@ -150,6 +151,8 @@ export default function NodePage() {
|
|||||||
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);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
const [dialogTitle, setDialogTitle] = useState("");
|
const [dialogTitle, setDialogTitle] = useState("");
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
@@ -821,23 +824,23 @@ export default function NodePage() {
|
|||||||
prev.map((n) =>
|
prev.map((n) =>
|
||||||
n.id === form.id
|
n.id === form.id
|
||||||
? {
|
? {
|
||||||
...n,
|
...n,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
form.serverIpV6?.trim() ||
|
form.serverIpV6?.trim() ||
|
||||||
form.serverHost?.trim() ||
|
form.serverHost?.trim() ||
|
||||||
"",
|
"",
|
||||||
serverIpV4: form.serverIpV4,
|
serverIpV4: form.serverIpV4,
|
||||||
serverIpV6: form.serverIpV6,
|
serverIpV6: form.serverIpV6,
|
||||||
port: form.port,
|
port: form.port,
|
||||||
tcpListenAddr: form.tcpListenAddr,
|
tcpListenAddr: form.tcpListenAddr,
|
||||||
udpListenAddr: form.udpListenAddr,
|
udpListenAddr: form.udpListenAddr,
|
||||||
interfaceName: form.interfaceName,
|
interfaceName: form.interfaceName,
|
||||||
http: form.http,
|
http: form.http,
|
||||||
tls: form.tls,
|
tls: form.tls,
|
||||||
socks: form.socks,
|
socks: form.socks,
|
||||||
}
|
}
|
||||||
: n,
|
: n,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -994,7 +997,19 @@ export default function NodePage() {
|
|||||||
const sortedNodes = useMemo((): Node[] => {
|
const sortedNodes = useMemo((): Node[] => {
|
||||||
if (!nodeList || nodeList.length === 0) return [];
|
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 aInx = a.inx ?? 0;
|
||||||
const bInx = b.inx ?? 0;
|
const bInx = b.inx ?? 0;
|
||||||
|
|
||||||
@@ -1007,7 +1022,7 @@ export default function NodePage() {
|
|||||||
nodeOrder.length > 0 &&
|
nodeOrder.length > 0 &&
|
||||||
sortedByDb.every((n) => n.inx === undefined || n.inx === 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[] = [];
|
const localSorted: Node[] = [];
|
||||||
|
|
||||||
nodeOrder.forEach((id) => {
|
nodeOrder.forEach((id) => {
|
||||||
@@ -1016,7 +1031,7 @@ export default function NodePage() {
|
|||||||
if (node) localSorted.push(node);
|
if (node) localSorted.push(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
nodeList.forEach((node) => {
|
filteredNodes.forEach((node) => {
|
||||||
if (!nodeOrder.includes(node.id)) {
|
if (!nodeOrder.includes(node.id)) {
|
||||||
localSorted.push(node);
|
localSorted.push(node);
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1041,7 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortedByDb;
|
return sortedByDb;
|
||||||
}, [nodeList, nodeOrder]);
|
}, [nodeList, nodeOrder, searchKeyword]);
|
||||||
|
|
||||||
const sortableNodeIds = useMemo(
|
const sortableNodeIds = useMemo(
|
||||||
() => sortedNodes.map((n) => n.id),
|
() => sortedNodes.map((n) => n.id),
|
||||||
@@ -1035,9 +1050,64 @@ export default function NodePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div 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="flex items-center justify-between mb-6">
|
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||||
<div className="flex-1" />
|
{!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">
|
<div className="flex gap-2 items-center">
|
||||||
<Button
|
<Button
|
||||||
@@ -1208,7 +1278,7 @@ export default function NodePage() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
|
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
|
||||||
{node.serverIpV4?.trim() ||
|
{node.serverIpV4?.trim() ||
|
||||||
node.serverIpV6?.trim() ? (
|
node.serverIpV6?.trim() ? (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{node.serverIpV4?.trim() && (
|
{node.serverIpV4?.trim() && (
|
||||||
<span
|
<span
|
||||||
@@ -1264,7 +1334,7 @@ export default function NodePage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatUptime(node.systemInfo.uptime)
|
? formatUptime(node.systemInfo.uptime)
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1283,7 +1353,7 @@ export default function NodePage() {
|
|||||||
<span>CPU</span>
|
<span>CPU</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1300,7 +1370,7 @@ export default function NodePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={
|
value={
|
||||||
node.connectionStatus === "online" &&
|
node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? node.systemInfo.cpuUsage
|
? node.systemInfo.cpuUsage
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
@@ -1311,7 +1381,7 @@ export default function NodePage() {
|
|||||||
<span>内存</span>
|
<span>内存</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1328,7 +1398,7 @@ export default function NodePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={
|
value={
|
||||||
node.connectionStatus === "online" &&
|
node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? node.systemInfo.memoryUsage
|
? node.systemInfo.memoryUsage
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
@@ -1343,10 +1413,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatSpeed(
|
? formatSpeed(
|
||||||
node.systemInfo.uploadSpeed,
|
node.systemInfo.uploadSpeed,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1356,10 +1426,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatSpeed(
|
? formatSpeed(
|
||||||
node.systemInfo.downloadSpeed,
|
node.systemInfo.downloadSpeed,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1373,10 +1443,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatTraffic(
|
? formatTraffic(
|
||||||
node.systemInfo.uploadTraffic,
|
node.systemInfo.uploadTraffic,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1386,10 +1456,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-success-700 dark:text-success-300">
|
<div className="font-mono text-success-700 dark:text-success-300">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatTraffic(
|
? formatTraffic(
|
||||||
node.systemInfo.downloadTraffic,
|
node.systemInfo.downloadTraffic,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
batchDeleteTunnels,
|
batchDeleteTunnels,
|
||||||
batchRedeployTunnels,
|
batchRedeployTunnels,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { SearchIcon } from "@/components/icons";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
import {
|
import {
|
||||||
buildDiagnosisFallbackResult,
|
buildDiagnosisFallbackResult,
|
||||||
@@ -111,6 +112,8 @@ export default function TunnelPage() {
|
|||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -597,7 +600,17 @@ export default function TunnelPage() {
|
|||||||
const sortedTunnels = useMemo((): Tunnel[] => {
|
const sortedTunnels = useMemo((): Tunnel[] => {
|
||||||
if (!tunnels || tunnels.length === 0) return [];
|
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 aInx = a.inx ?? 0;
|
||||||
const bInx = b.inx ?? 0;
|
const bInx = b.inx ?? 0;
|
||||||
|
|
||||||
@@ -610,7 +623,7 @@ export default function TunnelPage() {
|
|||||||
tunnelOrder.length > 0 &&
|
tunnelOrder.length > 0 &&
|
||||||
sortedByDb.every((t) => t.inx === undefined || t.inx === 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[] = [];
|
const localSorted: Tunnel[] = [];
|
||||||
|
|
||||||
tunnelOrder.forEach((id) => {
|
tunnelOrder.forEach((id) => {
|
||||||
@@ -619,7 +632,7 @@ export default function TunnelPage() {
|
|||||||
if (tunnel) localSorted.push(tunnel);
|
if (tunnel) localSorted.push(tunnel);
|
||||||
});
|
});
|
||||||
|
|
||||||
tunnels.forEach((tunnel) => {
|
filteredTunnels.forEach((tunnel) => {
|
||||||
if (!tunnelOrder.includes(tunnel.id)) {
|
if (!tunnelOrder.includes(tunnel.id)) {
|
||||||
localSorted.push(tunnel);
|
localSorted.push(tunnel);
|
||||||
}
|
}
|
||||||
@@ -629,7 +642,7 @@ export default function TunnelPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortedByDb;
|
return sortedByDb;
|
||||||
}, [tunnels, tunnelOrder]);
|
}, [tunnels, tunnelOrder, searchKeyword]);
|
||||||
|
|
||||||
const sortableTunnelIds = useMemo(
|
const sortableTunnelIds = useMemo(
|
||||||
() => sortedTunnels.map((t) => t.id),
|
() => sortedTunnels.map((t) => t.id),
|
||||||
@@ -672,9 +685,64 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div 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="flex items-center justify-between mb-6">
|
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||||
<div className="flex-1" />
|
{!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">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export default function UserPage() {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
const [pagination, setPagination] = useState<PaginationType>({
|
const [pagination, setPagination] = useState<PaginationType>({
|
||||||
current: 1,
|
current: 1,
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -611,40 +612,69 @@ export default function UserPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div className="px-3 lg:px-6 py-8">
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||||
<div className="flex items-center gap-3" />
|
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||||
|
{!isSearchVisible ? (
|
||||||
<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()}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
aria-label="搜索用户"
|
aria-label="搜索"
|
||||||
className="min-h-10 w-10"
|
className="text-default-600"
|
||||||
color="primary"
|
color="default"
|
||||||
variant="solid"
|
size="sm"
|
||||||
onPress={handleSearch}
|
variant="flat"
|
||||||
|
onPress={() => setIsSearchVisible(true)}
|
||||||
>
|
>
|
||||||
<SearchIcon className="w-4 h-4" />
|
<SearchIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
||||||
<Button color="primary" variant="flat" onPress={handleAdd}>
|
<Input
|
||||||
新增
|
autoFocus
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
|
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户列表 */}
|
{/* 用户列表 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user